live_cable 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0376ac919c3ed29e3c63181364e512f51aa39aeeb84f1b8a0584b42e1c89985
4
- data.tar.gz: 8bb42eb86866bddf18fb83e0a16770c4ddb48c3ec22524b680e272d155e22431
3
+ metadata.gz: 9328496395172b7689b680c9fc54f222f26da2cc2d5a1cd25683fbcbdbe3c2bf
4
+ data.tar.gz: c824da0a8a7cc10f3ca84a2965d264d88b879944b108d57e37599b0865b07b9d
5
5
  SHA512:
6
- metadata.gz: 4f95c867c6f65a85ceca7fad4c23cc572bc1cfb6c90e008ebb24403dbc7624cd9977ae711bc133484612b0e3ac16440f3d18bf365c656c8d537f009387dfd0fe
7
- data.tar.gz: 01d716fd63f39335bbaaed9aa8b0643f5b9a1493b9f1ca449635b4aa7f3cecc6d67992f31503412cbba6fd53860778918627430365af614b8598beb0666306b4
6
+ metadata.gz: ed7c3db367dea001f0d50cfd0d05dd5c7ba837800438924e944c7a92041dd0b252293682792b17bb8730934a659ba2839a1c6ad283469fdacc00bfa4771f4a79
7
+ data.tar.gz: a97bb8a0096edb1a4daa016b59051aef62a1d93eb1e8635d4a5462b5b6444ae5bdf28ae997b141f0e3ff0dfafde71fdcb08d98d322921c4e527e8e2c3ed416b6
data/CHANGELOG.md ADDED
@@ -0,0 +1,118 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The Ruby gem (`live_cable`) and the npm package (`@isometriks/live_cable`) are
6
+ released together and share a single version number. Entries below note which
7
+ side of the pair a change affects when it isn't both.
8
+
9
+ ## 0.2.1 - 2026-08-13
10
+
11
+ ### Removed
12
+
13
+ - `Component#channel_name` and `Connection#channel_name`. A component no longer
14
+ subscribes to a stream of its own now that payloads are written straight to its
15
+ channel, so the name identified a stream nothing published to or read from.
16
+ Subscribing to external streams with `stream_from` is unaffected (gem).
17
+
18
+ ### Fixed
19
+
20
+ - **The opening payload from `LiveChannel#subscribed` could be dropped.**
21
+ Payloads were published through the pubsub adapter, but ActionCable registers
22
+ `stream_from` asynchronously — so the initial render could be published before
23
+ the subscription it targets existed, and pubsub delivers only to subscribers
24
+ present at that moment, with no buffering or retry. The component was left
25
+ showing `data-live-status-value="disconnected"`, with no cached render on the
26
+ client for a Turbo reattach to replay, until some later action happened to
27
+ produce a refresh. When a subscribe render raised, the `_error` payload was
28
+ lost outright and the failure never surfaced in the browser at all. A
29
+ component's stream belongs to exactly one connection, so payloads are now
30
+ written straight to that connection rather than published, which removes the
31
+ race along with a pubsub round trip (gem).
32
+ - **Components not reattaching after a Turbo Drive navigation.** Navigating to a
33
+ page containing a component that is already subscribed deliberately keeps the
34
+ existing subscription, so `LiveChannel#subscribed` does not run again and the
35
+ server sends nothing. The newly rendered element was left with its
36
+ server-rendered status of `disconnected` and never received the component's
37
+ current state. A reconnecting controller now re-syncs the subscription's status
38
+ and replays the last render into the new element (npm).
39
+ - Building DOM from a server render used iterator helpers
40
+ (`childNodes.values().find(...)`), an ES2025 feature, which raised
41
+ `TypeError: ... .find is not a function` on runtimes that do not implement them
42
+ — Node 20 and earlier, and browsers older than Chrome 122 / Firefox 131 /
43
+ Safari 18.4. Rewritten with `Array.from` (npm).
44
+
45
+ ## 0.2.0
46
+
47
+ The gem and npm package versions are realigned in this release. The npm package
48
+ jumps from 0.1.1 straight to 0.2.0, skipping 0.1.2, which was published for the
49
+ gem only.
50
+
51
+ ### Added
52
+
53
+ - **Server-dispatched DOM events.** Components can queue browser events with
54
+ `dispatch_event`, delivered with the next broadcast and fired after the DOM has
55
+ been morphed, so handlers observe the updated markup. Events are bubbling
56
+ `CustomEvent`s dispatched from the component root (or from `window` with
57
+ `window: true`), so they can be wired up with plain Stimulus `data-action`
58
+ syntax. (`LiveCable::Component::Events`)
59
+ - **Loading states.** While a message is in flight, a `live-loading` attribute is
60
+ added to the component's root element and to the element that triggered the
61
+ message, so pending feedback can be styled with plain CSS. `live-disable-with`
62
+ on a button or submit button swaps its label and disables it for the duration
63
+ of the round trip; form values are serialized before anything is disabled.
64
+ Reactive inputs (`live-reactive`) receive `live-loading` but are never
65
+ disabled, so typing is not interrupted.
66
+ - **Component test harness.** `LiveCable::Testing` can be included in specs to
67
+ mount and drive components without a browser, via `live_mount`.
68
+ - `./loading` subpath export for the new loading module (npm).
69
+ - Gemspec `homepage_uri`, `documentation_uri`, and `bug_tracker_uri` metadata.
70
+
71
+ ### Changed
72
+
73
+ - Minimum Rails version raised from 7.0 to 7.1 (`actioncable`, `actionview`,
74
+ `activemodel`, `activesupport`) (gem).
75
+ - Herb upgraded from `~> 0.8.10` to `~> 0.10.2`, and `prism >= 1.0` added as an
76
+ explicit dependency (gem).
77
+ - Gem homepage now points at https://livecable.io rather than the RubyGems page.
78
+ - The `Live` namespace for user components moved out of `lib/live_cable.rb` into
79
+ its own `lib/live.rb`, required explicitly and ignored by the gem's Zeitwerk
80
+ loader (gem).
81
+ - Dev dependencies updated: Vitest 2.x to 4.x, happy-dom 15.x to 20.x (npm).
82
+
83
+ ### Fixed
84
+
85
+ - Template compiler: block sentinel tokens now carry a newline value so Herb's
86
+ whitespace helpers (`at_line_start?`, `preceding_token_ends_with_newline?`)
87
+ treat them as a line boundary instead of raising on a `nil` value.
88
+ - Template compiler: the closing `end` of an output block is emitted as plain
89
+ code instead of going through Herb's paren-balancing block-end helper, since
90
+ escaping is delegated to Rails' output buffer.
91
+
92
+ ## 0.1.2 - 2026-03-25
93
+
94
+ Gem only; no corresponding npm release.
95
+
96
+ ### Added
97
+
98
+ - JavaScript assets packaged so they can be consumed from package managers as
99
+ well as through the asset pipeline.
100
+
101
+ ## 0.1.1 - 2026-03-25
102
+
103
+ ### Added
104
+
105
+ - Component generator.
106
+
107
+ ### Changed
108
+
109
+ - Herb pinned to `0.8.*`, and other library versions pinned.
110
+
111
+ ### Fixed
112
+
113
+ - `render` inside a live template.
114
+ - Generator no longer creates `app/live` when the directory does not exist.
115
+
116
+ ## 0.1.0
117
+
118
+ Initial public release.
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  LiveCable is a Phoenix LiveView-style live component system for Ruby on Rails that tracks state server-side and allows
4
4
  you to call actions from the frontend using Stimulus with a React style state management API.
5
5
 
6
- **Full documentation: [livecable.io](https://livecable.io)**
6
+ **Full documentation: [livecable.io](https://livecable.io)** | **Sandbox app: [live_cable_app](https://github.com/isometriks/live_cable_app)**
7
7
 
8
8
  ## Features
9
9
 
@@ -1000,6 +1000,53 @@ The `live-key` attribute acts as a hint for the diffing algorithm to identify el
1000
1000
  <% end %>
1001
1001
  ```
1002
1002
 
1003
+ ## Loading States
1004
+
1005
+ Every interaction is a round trip to the server. While a message is in flight, LiveCable adds a `live-loading` attribute to the component's root element and to the element that triggered the message, so you can show pending feedback with plain CSS:
1006
+
1007
+ ```css
1008
+ button[live-loading] { opacity: 0.5; cursor: wait; }
1009
+
1010
+ .spinner { display: none; }
1011
+ [live-loading] .spinner { display: inline-block; }
1012
+ ```
1013
+
1014
+ To prevent double-clicks, mark buttons with `live-disable-with`. The element is disabled while the message is in flight and restored when the server responds. Give the attribute a value to also swap the label:
1015
+
1016
+ ```erb
1017
+ <button live-action="checkout" live-disable-with="Processing...">Checkout</button>
1018
+
1019
+ <form live-form="save">
1020
+ <input type="text" name="title">
1021
+ <button type="submit" live-disable-with="Saving...">Save</button>
1022
+ </form>
1023
+ ```
1024
+
1025
+ For forms, put `live-disable-with` on the submit button(s); form values are serialized before anything is disabled. Reactive inputs (`live-reactive`) get the `live-loading` attribute but are never disabled, so typing is not interrupted.
1026
+
1027
+ The loading state is cleared when the server responds — with a re-render, an error, or a lightweight acknowledgement when the action didn't change any state — so it never gets stuck.
1028
+
1029
+ ## Server Events
1030
+
1031
+ Components can trigger DOM events on the client with `dispatch_event` — for scroll-to-bottom, closing modals, toasts, or anything else the client should do after the server finishes:
1032
+
1033
+ ```ruby
1034
+ def send_message(params)
1035
+ messages << { text: params[:text] }
1036
+ dispatch_event('chat:message-sent')
1037
+ end
1038
+ ```
1039
+
1040
+ Events fire as bubbling `CustomEvent`s from the component's root element **after the DOM has been morphed**, so handlers see the updated markup. Plain Stimulus `data-action` syntax handles them — no LiveCable-specific JavaScript:
1041
+
1042
+ ```erb
1043
+ <div data-controller="chat" data-action="chat:message-sent->chat#scrollToBottom">
1044
+ ...
1045
+ </div>
1046
+ ```
1047
+
1048
+ Pass a payload (`dispatch_event('toast:show', message: 'Saved')`, available as `event.detail`) or target global listeners with `dispatch_event('analytics:tracked', window: true)`. Events work from actions, lifecycle callbacks, and `stream_from` callbacks, and are delivered exactly once, in order, whether or not the action re-rendered.
1049
+
1003
1050
  ## Compound Components
1004
1051
 
1005
1052
  By default, components render the partial at `app/views/live/component_name.html.live.erb`. You can organize your templates differently by marking a component as `compound`.
@@ -1237,6 +1284,27 @@ When a broadcast is received:
1237
1284
  - **Live dashboards**: Update metrics and charts in real-time
1238
1285
  - **Presence tracking**: Show who's currently online or viewing a resource
1239
1286
 
1287
+ ## Testing Components
1288
+
1289
+ LiveCable includes a test harness for fast, browser-free component tests. Actions go through the real message pipeline (whitelisting, parameter parsing, change tracking, re-rendering), so tests exercise what production runs:
1290
+
1291
+ ```ruby
1292
+ RSpec.describe Live::Counter do
1293
+ include LiveCable::Testing
1294
+
1295
+ it 'increments by the step size' do
1296
+ counter = live_mount('counter', step: 2)
1297
+
1298
+ counter.perform(:increment)
1299
+
1300
+ expect(counter.count).to eq(2)
1301
+ expect(counter.rendered).to have_css('[data-testid="counter-value"]', text: '2')
1302
+ end
1303
+ end
1304
+ ```
1305
+
1306
+ The harness supports client reactive updates (`set_reactive`), broadcast assertions (`broadcasts(:_refresh)`), simulated stream broadcasts (`receive_stream`), shared state across components mounted on the same connection, connection identifiers like `current_user`, and lifecycle testing via `unmount`. See the [testing guide](https://livecable.io/guide/testing) for the full API.
1307
+
1240
1308
  ## Error Handling
1241
1309
 
1242
1310
  When an unhandled exception is raised inside a component action, LiveCable replaces the component in the DOM with an error message and cleans up the server-side component.
@@ -1,5 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import SubscriptionManager from "@isometriks/live_cable/subscriptions"
3
+ import LoadingState from "@isometriks/live_cable/loading"
3
4
 
4
5
  export default class extends Controller {
5
6
  static values = {
@@ -12,16 +13,18 @@ export default class extends Controller {
12
13
 
13
14
  #subscription
14
15
  #debounces = new Map()
16
+ #loading
15
17
 
16
18
  #callActionCallback = (event) => {
17
19
  event.stopPropagation()
18
20
 
19
21
  const { action, params } = event.detail
20
22
 
21
- this.sendCall(action, params)
23
+ this.sendCall(action, params, event.target)
22
24
  }
23
25
 
24
26
  connect() {
27
+ this.#loading = new LoadingState(this.element)
25
28
  this.element.addEventListener("call", this.#callActionCallback)
26
29
 
27
30
  this.#subscription = SubscriptionManager.subscribe(
@@ -33,8 +36,8 @@ export default class extends Controller {
33
36
 
34
37
  // Create callbacks for each action or form
35
38
  this.actionsValue.forEach((action) => {
36
- this[`action_$${action}`] = ({ params }) => {
37
- this.sendCall(action, this.#convertKeysToSnakeCase(params))
39
+ this[`action_$${action}`] = ({ params, currentTarget }) => {
40
+ this.sendCall(action, this.#convertKeysToSnakeCase(params), currentTarget)
38
41
  }
39
42
 
40
43
  this[`form_$${action}`] = (event) => {
@@ -47,12 +50,30 @@ export default class extends Controller {
47
50
  this.element.removeEventListener("call", this.#callActionCallback)
48
51
  }
49
52
 
50
- sendCall(action, params = {}) {
53
+ sendCall(action, params = {}, trigger = null) {
54
+ this.#loading.start(trigger)
51
55
  this.#subscription.send(
52
56
  this.#flushDebounced(this.#callMessage(params, action))
53
57
  )
54
58
  }
55
59
 
60
+ // Called by the subscription when the server answers a message
61
+ // (refresh, ack, or error). Restores any live-loading / live-disable-with
62
+ // state once all in-flight messages have been answered.
63
+ finishLoading() {
64
+ this.#loading?.finish()
65
+ }
66
+
67
+ // Whether any message is still awaiting a server response
68
+ get isLoading() {
69
+ return this.#loading?.active ?? false
70
+ }
71
+
72
+ // Called by the subscription when the component is being torn down.
73
+ resetLoading() {
74
+ this.#loading?.reset()
75
+ }
76
+
56
77
  #callMessage(params, action) {
57
78
  return {
58
79
  _action: action,
@@ -83,6 +104,8 @@ export default class extends Controller {
83
104
 
84
105
  sendReactive(target) {
85
106
  this.#clearDebounce(target)
107
+ // Never disable reactive inputs while in flight - it would drop focus
108
+ this.#loading.start(target, { disable: false })
86
109
  this.#subscription.send(
87
110
  this.#flushDebounced(this.#reactiveMessage(target))
88
111
  )
@@ -114,9 +137,13 @@ export default class extends Controller {
114
137
  sendForm(action, formEl) {
115
138
  this.#clearDebounce(formEl)
116
139
 
140
+ // Serialize before starting the loading state - disabled controls
141
+ // (live-disable-with) are excluded from FormData
117
142
  const formData = new FormData(formEl)
118
143
  const params = new URLSearchParams(formData).toString()
119
144
 
145
+ this.#loading.start(formEl)
146
+
120
147
  this.#subscription.send(
121
148
  this.#flushDebounced(this.#callMessage(params, action))
122
149
  )
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Loading state tracking for LiveCable components.
3
+ *
4
+ * Tracks in-flight messages for a single component and reflects them in the
5
+ * DOM so users can style pending states with CSS:
6
+ *
7
+ * - The component root element gets a `live-loading` attribute while any
8
+ * message is awaiting a server response.
9
+ * - The element that triggered the message (button, form, input) also gets
10
+ * a `live-loading` attribute.
11
+ * - Elements with a `live-disable-with` attribute are disabled while the
12
+ * message is in flight. If the attribute has a value, the element's label
13
+ * (textContent, or value for inputs) is swapped for it.
14
+ *
15
+ * The state is cleared when the server responds with a refresh, an error,
16
+ * or an ack (sent when an action didn't change any reactive variables).
17
+ * Multiple in-flight messages are counted; the DOM is only restored once
18
+ * all of them have been answered.
19
+ */
20
+ export default class LoadingState {
21
+ /** @type {HTMLElement} */
22
+ #root
23
+ /** @type {number} */
24
+ #inFlight = 0
25
+ /** @type {Set<Element>} - Elements marked with the live-loading attribute */
26
+ #markedElements = new Set()
27
+ /** @type {Map<Element, Object>} - Original state of disabled elements */
28
+ #disabledElements = new Map()
29
+
30
+ /**
31
+ * @param {HTMLElement} root - The component's root element
32
+ */
33
+ constructor(root) {
34
+ this.#root = root
35
+ }
36
+
37
+ /**
38
+ * Whether any message is currently awaiting a server response.
39
+ * @returns {boolean}
40
+ */
41
+ get active() {
42
+ return this.#inFlight > 0
43
+ }
44
+
45
+ /**
46
+ * Mark the component as loading.
47
+ * Called right before a message is sent to the server.
48
+ *
49
+ * @param {Element|null} trigger - The element that triggered the message
50
+ * @param {Object} options
51
+ * @param {boolean} options.disable - Whether to process live-disable-with
52
+ * elements. Disabled for reactive inputs so typing doesn't lose focus.
53
+ */
54
+ start(trigger = null, { disable = true } = {}) {
55
+ this.#inFlight++
56
+
57
+ this.#mark(this.#root)
58
+
59
+ if (trigger instanceof Element && trigger !== this.#root) {
60
+ this.#mark(trigger)
61
+
62
+ if (disable) {
63
+ this.#disableElements(trigger)
64
+ }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Record a server response for this component.
70
+ * Restores the DOM once all in-flight messages have been answered.
71
+ *
72
+ * @returns {boolean} true if the loading state was fully cleared
73
+ */
74
+ finish() {
75
+ if (this.#inFlight === 0) {
76
+ return false
77
+ }
78
+
79
+ this.#inFlight--
80
+
81
+ if (this.#inFlight > 0) {
82
+ return false
83
+ }
84
+
85
+ this.#restore()
86
+ return true
87
+ }
88
+
89
+ /**
90
+ * Clear all loading state immediately, regardless of in-flight count.
91
+ * Used when the component is being torn down (e.g. on error).
92
+ */
93
+ reset() {
94
+ this.#inFlight = 0
95
+ this.#restore()
96
+ }
97
+
98
+ #mark(element) {
99
+ element.setAttribute('live-loading', '')
100
+ this.#markedElements.add(element)
101
+ }
102
+
103
+ /**
104
+ * Disable the trigger and/or its descendants marked with live-disable-with.
105
+ * For a button the attribute lives on the button itself; for a form it
106
+ * usually lives on the submit button(s) inside it.
107
+ */
108
+ #disableElements(trigger) {
109
+ const elements = []
110
+
111
+ if (trigger.hasAttribute('live-disable-with')) {
112
+ elements.push(trigger)
113
+ }
114
+
115
+ elements.push(...trigger.querySelectorAll('[live-disable-with]'))
116
+
117
+ elements.forEach(element => {
118
+ // Already disabled by an earlier in-flight message
119
+ if (this.#disabledElements.has(element)) {
120
+ return
121
+ }
122
+
123
+ const text = element.getAttribute('live-disable-with')
124
+ const isInput = element instanceof HTMLInputElement
125
+
126
+ this.#disabledElements.set(element, {
127
+ disabled: element.disabled,
128
+ content: text ? (isInput ? element.value : element.textContent) : null,
129
+ })
130
+
131
+ element.disabled = true
132
+
133
+ if (text) {
134
+ if (isInput) {
135
+ element.value = text
136
+ } else {
137
+ element.textContent = text
138
+ }
139
+ }
140
+ })
141
+ }
142
+
143
+ #restore() {
144
+ this.#markedElements.forEach(element => {
145
+ element.removeAttribute('live-loading')
146
+ })
147
+ this.#markedElements.clear()
148
+
149
+ this.#disabledElements.forEach(({ disabled, content }, element) => {
150
+ element.disabled = disabled
151
+
152
+ if (content !== null) {
153
+ if (element instanceof HTMLInputElement) {
154
+ element.value = content
155
+ } else {
156
+ element.textContent = content
157
+ }
158
+ }
159
+ })
160
+ this.#disabledElements.clear()
161
+ }
162
+ }
@@ -34,8 +34,7 @@ function createDOMFromHTML(html) {
34
34
  template.innerHTML = html
35
35
 
36
36
  // Find a node that isn't a comment
37
- const node = template.content.childNodes
38
- .values()
37
+ const node = Array.from(template.content.childNodes)
39
38
  .find(n => n.nodeName !== '#comment')
40
39
 
41
40
  if (node) {
@@ -230,6 +229,15 @@ class ComponentState {
230
229
  return this.#element
231
230
  }
232
231
 
232
+ /**
233
+ * Whether a render has been received and cached, and can therefore be
234
+ * replayed into a freshly attached element.
235
+ * @returns {boolean}
236
+ */
237
+ get hasRender() {
238
+ return this.#lastTemplate !== null && Boolean(this.#partsByTemplate[this.#lastTemplate])
239
+ }
240
+
233
241
  /**
234
242
  * Create a DOM element from stored state.
235
243
  * @param {Object} refresh - Optional refresh data to update state
@@ -345,12 +353,44 @@ class Subscription {
345
353
 
346
354
  /**
347
355
  * Update the controller reference.
348
- * Called when a Stimulus controller reconnects to an existing subscription.
356
+ * Called when a Stimulus controller reconnects to an existing subscription
357
+ * most importantly after a Turbo Drive navigation to a page that contains the
358
+ * same component, where prune() deliberately keeps the subscription alive.
359
+ *
360
+ * In that case the ActionCable subscription is never recreated, so
361
+ * LiveChannel#subscribed does not run again and the server sends nothing.
362
+ * Without re-syncing here, the newly rendered element keeps the
363
+ * server-rendered status of "disconnected" forever and never receives the
364
+ * component's current state.
349
365
  *
350
366
  * @param {Object} controller - Stimulus controller instance
351
367
  */
352
368
  set controller(controller) {
369
+ const previous = this.#controller
370
+
353
371
  this.#controller = controller
372
+ this.#componentState.element = controller.element
373
+
374
+ if (previous && previous !== controller) {
375
+ this.#reattach()
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Bring a freshly connected controller up to date with state this
381
+ * subscription already holds.
382
+ * @private
383
+ */
384
+ #reattach() {
385
+ if (this.#currentStatus) {
386
+ this.#controller.statusValue = this.#currentStatus
387
+ }
388
+
389
+ // Replay the last render into the new element. The server-side component
390
+ // is the same instance, so the cached parts are its current state.
391
+ if (this.#componentState.hasRender) {
392
+ this.#handleRefresh(null)
393
+ }
354
394
  }
355
395
 
356
396
  /**
@@ -396,6 +436,10 @@ class Subscription {
396
436
  * @param {string} [data._status] - Status update (e.g., 'subscribed', 'destroy')
397
437
  * @param {string} [data._refresh] - HTML to morph into the DOM
398
438
  * @param {string} [data._error] - Raw error HTML to replace the component with
439
+ * @param {boolean} [data._ack] - Acknowledgement that a message was processed
440
+ * without producing a re-render; clears the loading state
441
+ * @param {Array} [data._events] - Events to dispatch as CustomEvents; when
442
+ * attached to a _refresh they fire after the DOM has been morphed
399
443
  * @private
400
444
  */
401
445
  #received = (data) => {
@@ -405,9 +449,34 @@ class Subscription {
405
449
  this.#handleRefresh(data['_refresh'])
406
450
  } else if (data['_error']) {
407
451
  this.#handleError(data['_error'])
452
+ } else if (data['_ack']) {
453
+ this.#controller?.finishLoading()
454
+ }
455
+
456
+ // Dispatch after the branch above so events attached to a refresh fire
457
+ // once the morph has completed and handlers see the updated DOM
458
+ if (data['_events']) {
459
+ this.#dispatchEvents(data['_events'])
408
460
  }
409
461
  }
410
462
 
463
+ /**
464
+ * Fire server-dispatched events as bubbling CustomEvents from the
465
+ * component's root element, or from window when the event asks for it.
466
+ *
467
+ * @param {Array<{name: string, detail: Object, window: boolean}>} events
468
+ * @private
469
+ */
470
+ #dispatchEvents(events) {
471
+ events.forEach(({ name, detail, window: onWindow }) => {
472
+ const target = onWindow ? window : this.#controller?.element
473
+
474
+ if (target) {
475
+ target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }))
476
+ }
477
+ })
478
+ }
479
+
411
480
  /**
412
481
  * Handle error messages from the server.
413
482
  * Replaces the component element with raw error HTML, then unsubscribes
@@ -420,6 +489,7 @@ class Subscription {
420
489
  return
421
490
  }
422
491
 
492
+ this.#controller.resetLoading()
423
493
  this.#controller.element.outerHTML = html
424
494
  this.unsubscribe()
425
495
  }
@@ -458,10 +528,40 @@ class Subscription {
458
528
  return
459
529
  }
460
530
 
461
- morphdom(this.#controller.element, this.#buildRefreshDOM(refresh), {
531
+ // Restore live-loading / live-disable-with state before morphing so the
532
+ // morph applies the server-rendered truth on top of the original DOM.
533
+ // With multiple messages in flight this only restores once the last
534
+ // response arrives - until then the morph below preserves the pending
535
+ // elements so live-disable-with buttons can't be clicked early.
536
+ this.#controller.finishLoading()
537
+
538
+ const rootElement = this.#controller.element
539
+ const stillLoading = this.#controller.isLoading
540
+
541
+ const refreshDOM = this.#buildRefreshDOM(refresh)
542
+
543
+ if (stillLoading) {
544
+ refreshDOM.setAttribute('live-loading', '')
545
+ }
546
+
547
+ morphdom(rootElement, refreshDOM, {
462
548
  // Preserve elements marked with live-ignore attribute
463
549
  onBeforeElUpdated(fromEl, toEl) {
464
- return fromEl.hasAttribute && !fromEl.hasAttribute('live-ignore')
550
+ if (!fromEl.hasAttribute) {
551
+ return true
552
+ }
553
+
554
+ if (fromEl.hasAttribute('live-ignore')) {
555
+ return false
556
+ }
557
+
558
+ // Keep elements that are still awaiting a server response untouched
559
+ // (the root is handled above so the rest of the tree still morphs)
560
+ if (stillLoading && fromEl !== rootElement && fromEl.hasAttribute('live-loading')) {
561
+ return false
562
+ }
563
+
564
+ return true
465
565
  },
466
566
  // Use stable keys for better morphing performance and state preservation
467
567
  getNodeKey(node) {
@@ -41,6 +41,11 @@ class LiveChannel < ActionCable::Channel::Base
41
41
  @component = nil
42
42
  end
43
43
 
44
+ # Exposes #transmit, which ActionCable keeps private to the channel.
45
+ def broadcast(data)
46
+ transmit(data)
47
+ end
48
+
44
49
  private
45
50
 
46
51
  # @return [LiveCable::Component, nil]
data/config/importmap.rb CHANGED
@@ -6,6 +6,7 @@ pin 'morphdom', to: 'https://ga.jspm.io/npm:morphdom@2.7.8/dist/morphdom-esm.js'
6
6
  pin '@isometriks/live_cable/controller', to: 'controllers/live_controller.js'
7
7
  pin '@isometriks/live_cable/blessing', to: 'live_cable_blessing.js'
8
8
  pin '@isometriks/live_cable/subscriptions', to: 'subscriptions.js'
9
+ pin '@isometriks/live_cable/loading', to: 'loading.js'
9
10
  pin '@isometriks/live_cable/observer', to: 'observer.js'
10
11
  pin '@isometriks/live_cable/dom', to: 'dom.js'
11
12
  pin '@isometriks/live_cable', to: 'live_cable.js'
data/lib/live.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Live
4
+ # For components to live in
5
+ end