live_cable 0.3.0 → 0.4.0

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +567 -0
  3. data/README.md +14 -2
  4. data/app/assets/javascript/controllers/live_controller.js +51 -7
  5. data/app/assets/javascript/live_cable.js +2 -0
  6. data/app/assets/javascript/loading.js +58 -6
  7. data/app/assets/javascript/subscriptions.js +292 -22
  8. data/app/channels/live_channel.rb +51 -29
  9. data/app/helpers/live_cable_helper.rb +31 -2
  10. data/lib/live_cable/action_cable_connection.rb +4 -1
  11. data/lib/live_cable/component/broadcasting.rb +37 -6
  12. data/lib/live_cable/component/dispatching.rb +36 -0
  13. data/lib/live_cable/component/lifecycle.rb +1 -1
  14. data/lib/live_cable/component/reactive_variables.rb +33 -5
  15. data/lib/live_cable/component/rendering.rb +30 -14
  16. data/lib/live_cable/component/streaming.rb +35 -4
  17. data/lib/live_cable/component.rb +4 -3
  18. data/lib/live_cable/connection/broadcasting.rb +15 -3
  19. data/lib/live_cable/connection/component_management.rb +12 -8
  20. data/lib/live_cable/connection/error_handling.rb +28 -7
  21. data/lib/live_cable/connection/messaging.rb +40 -52
  22. data/lib/live_cable/connection/state_management.rb +13 -16
  23. data/lib/live_cable/connection.rb +33 -0
  24. data/lib/live_cable/container.rb +17 -11
  25. data/lib/live_cable/defaults_signer.rb +95 -0
  26. data/lib/live_cable/delegation/array.rb +27 -1
  27. data/lib/live_cable/delegation/hash.rb +55 -1
  28. data/lib/live_cable/delegation/methods.rb +25 -1
  29. data/lib/live_cable/delegation/model.rb +12 -0
  30. data/lib/live_cable/delegator.rb +54 -6
  31. data/lib/live_cable/dispatch.rb +54 -0
  32. data/lib/live_cable/engine.rb +3 -1
  33. data/lib/live_cable/forbidden.rb +7 -0
  34. data/lib/live_cable/identity_digest.rb +27 -0
  35. data/lib/live_cable/model_observer.rb +37 -0
  36. data/lib/live_cable/observer_tracking.rb +10 -1
  37. data/lib/live_cable/predicate_builder.rb +13 -0
  38. data/lib/live_cable/render_context.rb +82 -8
  39. data/lib/live_cable/rendering/controller_renderer.rb +26 -0
  40. data/lib/live_cable/rendering/dependency_visitor.rb +68 -24
  41. data/lib/live_cable/rendering/handler.rb +1 -1
  42. data/lib/live_cable/rendering/method_analyzer.rb +18 -4
  43. data/lib/live_cable/rendering/method_collector.rb +13 -5
  44. data/lib/live_cable/rendering/partial.rb +13 -1
  45. data/lib/live_cable/rendering/partial_renderer.rb +59 -7
  46. data/lib/live_cable/rendering/render_result.rb +5 -0
  47. data/lib/live_cable/rendering/renderer.rb +18 -14
  48. data/lib/live_cable/rendering/view_buffer.rb +21 -0
  49. data/lib/live_cable/testing/render_state.rb +5 -3
  50. data/lib/live_cable/testing/test_channel.rb +6 -0
  51. data/lib/live_cable/testing/test_component.rb +3 -3
  52. data/lib/live_cable/testing.rb +9 -5
  53. data/lib/live_cable/version.rb +1 -1
  54. data/lib/live_cable.rb +32 -3
  55. metadata +9 -1
@@ -45,6 +45,18 @@ function createDOMFromHTML(html) {
45
45
  return template.content.childNodes[0]
46
46
  }
47
47
 
48
+ const TEXT_INPUT_TYPES = new Set(['text', 'search', 'email', 'url', 'tel', 'password', 'number'])
49
+
50
+ /**
51
+ * Whether the user types the element's value.
52
+ * @param {Element} element
53
+ * @returns {boolean}
54
+ */
55
+ function isTextField(element) {
56
+ return element instanceof HTMLTextAreaElement ||
57
+ (element instanceof HTMLInputElement && TEXT_INPUT_TYPES.has(element.type))
58
+ }
59
+
48
60
  /**
49
61
  * Manages all LiveCable subscriptions across the application.
50
62
  * Ensures that each component (identified by liveId) has at most one
@@ -55,6 +67,8 @@ class SubscriptionManager {
55
67
  #subscriptions = {}
56
68
  /** @type {Object.<string, ComponentState>} */
57
69
  #componentStates = {}
70
+ /** @type {string|null} - Digest from live_cable_identity_tag the socket was opened under */
71
+ #identity = null
58
72
 
59
73
  /**
60
74
  * Register a component state before subscription is created.
@@ -75,18 +89,24 @@ class SubscriptionManager {
75
89
  /**
76
90
  * Subscribe to or reconnect to a LiveCable component.
77
91
  * If a subscription already exists for this liveId, updates the controller
78
- * reference instead of creating a new subscription.
92
+ * reference instead of creating a new subscription - unless the element
93
+ * brings different defaults, in which case the old subscription is replaced
94
+ * so the server builds the component again from them.
79
95
  * If a ComponentState exists and the controller element matches, reuses it.
80
96
  *
81
97
  * @param {string} id - Raw ID for the component (e.g., "room-1")
82
98
  * @param {string} component - Component class name (e.g., "chat/chat_room")
83
- * @param {Object} defaults - Default values for reactive variables
99
+ * @param {string} defaults - Opaque signed defaults blob, forwarded to the server
84
100
  * @param {Object} controller - Stimulus controller instance
85
101
  * @returns {Subscription} The subscription instance
86
102
  */
87
103
  subscribe(id, component, defaults, controller) {
88
104
  const liveId = `${component}/${id}`
89
105
 
106
+ if (this.#subscriptions[liveId] && this.#defaultsChanged(this.#subscriptions[liveId], defaults)) {
107
+ this.#subscriptions[liveId].unsubscribe()
108
+ }
109
+
90
110
  if (!this.#subscriptions[liveId]) {
91
111
  const componentState = this.#componentStates[liveId]
92
112
 
@@ -107,6 +127,23 @@ class SubscriptionManager {
107
127
  return this.#subscriptions[liveId]
108
128
  }
109
129
 
130
+ /**
131
+ * Only a page rendered over HTTP carries a defaults blob; an element rendered
132
+ * over the socket has none and says nothing about them. Signing is
133
+ * deterministic, so the same defaults always produce the same blob. A Turbo
134
+ * preview shows a cached copy of the page, whose blob may be stale; the
135
+ * page itself follows it.
136
+ *
137
+ * @param {Subscription} subscription
138
+ * @param {string} defaults
139
+ * @returns {boolean}
140
+ */
141
+ #defaultsChanged(subscription, defaults) {
142
+ return Boolean(defaults) &&
143
+ defaults !== subscription.defaults &&
144
+ !document.documentElement.hasAttribute('data-turbo-preview')
145
+ }
146
+
110
147
  /**
111
148
  * Remove a subscription from the manager.
112
149
  * Called when the server sends a 'destroy' status, indicating the
@@ -118,6 +155,38 @@ class SubscriptionManager {
118
155
  delete this.#subscriptions[liveId]
119
156
  }
120
157
 
158
+ /**
159
+ * Reopen the socket when the page was rendered for someone other than the
160
+ * socket was opened for.
161
+ *
162
+ * A socket is identified once, at its handshake, and Turbo keeps it open
163
+ * across a sign-in, sign-out or impersonation, so its components would
164
+ * carry on as the previous identity. The live_cable_identity_tag meta tag
165
+ * carries a digest of who each page was rendered for; Turbo merges the new
166
+ * page's head before turbo:before-render, so this runs before the new
167
+ * body's components subscribe. Reopening closes the socket, the server
168
+ * drops its components, and they resubscribe on the new socket as the new
169
+ * session. A page without the tag, and a Turbo preview of a cached page,
170
+ * say nothing.
171
+ */
172
+ syncIdentity() {
173
+ if (document.documentElement.hasAttribute('data-turbo-preview')) {
174
+ return
175
+ }
176
+
177
+ const identity = document.head.querySelector('meta[name="live-cable-identity"]')?.content
178
+
179
+ if (!identity) {
180
+ return
181
+ }
182
+
183
+ if (this.#identity && identity !== this.#identity && consumer.connection.isActive()) {
184
+ consumer.connection.reopen()
185
+ }
186
+
187
+ this.#identity = identity
188
+ }
189
+
121
190
  /**
122
191
  * Unsubscribe components not present in the new page body.
123
192
  * Called before Turbo Drive renders a new page so that only components
@@ -134,6 +203,12 @@ class SubscriptionManager {
134
203
  prune(newBody) {
135
204
  const newLiveIds = this.#extractLiveIds(newBody)
136
205
 
206
+ // Inline children are prerendered without a live id, but a kept component
207
+ // rebuilds them from their state; the set grows to take in grandchildren
208
+ newLiveIds.forEach(liveId => {
209
+ this.getComponentState(liveId)?.childLiveIds.forEach(childId => newLiveIds.add(childId))
210
+ })
211
+
137
212
  Object.entries(this.#subscriptions).forEach(([liveId, subscription]) => {
138
213
  if (!newLiveIds.has(liveId)) {
139
214
  subscription.unsubscribe()
@@ -198,6 +273,8 @@ class ComponentState {
198
273
  #lastTemplate
199
274
  /** @type {HTMLElement} - The DOM element for this component */
200
275
  #element
276
+ /** @type {number} */
277
+ #renderCount = 0
201
278
 
202
279
  /**
203
280
  * Creates component state from initial render data.
@@ -238,6 +315,24 @@ class ComponentState {
238
315
  return this.#lastTemplate !== null && Boolean(this.#partsByTemplate[this.#lastTemplate])
239
316
  }
240
317
 
318
+ /**
319
+ * How many renders have been stored, counting those that arrived in a
320
+ * parent's refresh.
321
+ * @returns {number}
322
+ */
323
+ get renderCount() {
324
+ return this.#renderCount
325
+ }
326
+
327
+ /**
328
+ * Live ids of the child components placed in the last render.
329
+ * @returns {string[]}
330
+ */
331
+ get childLiveIds() {
332
+ const html = (this.#partsByTemplate[this.#lastTemplate] ?? []).join('')
333
+ return Array.from(html.matchAll(/<LiveCable child-live-id="([^"]+)"/g), ([, liveId]) => liveId)
334
+ }
335
+
241
336
  /**
242
337
  * Create a DOM element from stored state.
243
338
  * @param {Object} refresh - Optional refresh data to update state
@@ -251,6 +346,7 @@ class ComponentState {
251
346
  return this.#buildRefreshDOM(html)
252
347
  }
253
348
 
349
+ this.#renderCount++
254
350
  const [template, parts] = [refresh['h'], refresh['p']]
255
351
 
256
352
  // Use a default template ID for backward compatibility
@@ -302,7 +398,7 @@ class Subscription {
302
398
  #id
303
399
  /** @type {string} */
304
400
  #component
305
- /** @type {Object} */
401
+ /** @type {string} */
306
402
  #defaults
307
403
  /** @type {Object|null} */
308
404
  #controller
@@ -312,12 +408,18 @@ class Subscription {
312
408
  #subscription
313
409
  /** @type {string|null} */
314
410
  #currentStatus = null
411
+ /** @type {boolean} - Whether the server has confirmed this subscription on the current socket */
412
+ #confirmed = false
413
+ /** @type {Array<Object>} - Messages held until the subscription is confirmed */
414
+ #pending = []
415
+ /** @type {number} - The render count when the first in-flight message was sent */
416
+ #renderCountAtSend = 0
315
417
  /**
316
418
  * Creates a new subscription to a LiveCable component.
317
419
  *
318
420
  * @param {string} id - Raw ID for the component (e.g., "room-1")
319
421
  * @param {string} component - Component class name (e.g., "chat/chat_room")
320
- * @param {Object} defaults - Default values for reactive variables
422
+ * @param {string} defaults - Opaque signed defaults blob, forwarded to the server
321
423
  * @param {Object} controller - Stimulus controller instance
322
424
  * @param {ComponentState} [existingState] - Optional existing component state to reuse
323
425
  */
@@ -343,6 +445,14 @@ class Subscription {
343
445
  this.#subscribe()
344
446
  }
345
447
 
448
+ /**
449
+ * The signed defaults blob this subscription was created with.
450
+ * @returns {string}
451
+ */
452
+ get defaults() {
453
+ return this.#defaults
454
+ }
455
+
346
456
  /**
347
457
  * Get the component state.
348
458
  * @returns {ComponentState}
@@ -394,12 +504,48 @@ class Subscription {
394
504
  }
395
505
 
396
506
  /**
397
- * Send a message to the server through the ActionCable subscription.
507
+ * Send a message to the server, or hold it until the subscription is
508
+ * confirmed on an open socket.
509
+ *
510
+ * ActionCable drops a message sent while its socket is closed - after a
511
+ * laptop wakes, a network blip, a deploy - and one sent after the socket
512
+ * reopens but before the re-subscribe is confirmed can reach the server
513
+ * before the subscription exists there, which drops it too. Either way the
514
+ * component would wait in its loading state for a reply that is never
515
+ * coming. Held messages go out, in order, once the server confirms the
516
+ * subscription.
398
517
  *
399
518
  * @param {Object} message - Message to send (e.g., action calls, reactive updates)
400
519
  */
401
520
  send(message) {
402
- this.#subscription.send(message)
521
+ if (this.#controller?.inFlight === 1) {
522
+ this.#renderCountAtSend = this.#componentState.renderCount
523
+ }
524
+
525
+ if (!this.#confirmed || !this.#subscription.send(message)) {
526
+ this.#pending.push(message)
527
+ }
528
+ }
529
+
530
+ /**
531
+ * How many messages are waiting for a connection.
532
+ * @returns {number}
533
+ */
534
+ get pendingCount() {
535
+ return this.#pending.length
536
+ }
537
+
538
+ /**
539
+ * Drop any messages still waiting for a connection. Called when the loading
540
+ * state gives up on them, so a message the page has said didn't go through
541
+ * can't then go through after a reconnect.
542
+ *
543
+ * @returns {number} How many messages were dropped
544
+ */
545
+ discardPending() {
546
+ const count = this.#pending.length
547
+ this.#pending = []
548
+ return count
403
549
  }
404
550
 
405
551
  /**
@@ -424,10 +570,57 @@ class Subscription {
424
570
  component: this.#component,
425
571
  defaults: this.#defaults,
426
572
  }, {
573
+ connected: this.#connected,
574
+ disconnected: this.#disconnected,
427
575
  received: this.#received,
428
576
  })
429
577
  }
430
578
 
579
+ /**
580
+ * The server confirmed the subscription - on first connect, or again after
581
+ * a reconnect. Anything held while it wasn't goes out now.
582
+ * @private
583
+ */
584
+ #connected = () => {
585
+ this.#confirmed = true
586
+ // ActionCable can replace a stale socket without calling disconnected
587
+ this.#settleLostMessages()
588
+
589
+ while (this.#pending.length > 0) {
590
+ // The socket closed again mid-flush; the next confirmation resumes it
591
+ if (!this.#subscription.send(this.#pending[0])) {
592
+ this.#confirmed = false
593
+ break
594
+ }
595
+
596
+ this.#pending.shift()
597
+ }
598
+ }
599
+
600
+ /**
601
+ * The socket closed. ActionCable reconnects and re-subscribes by itself;
602
+ * until it has, sends are held and the component says so.
603
+ * @private
604
+ */
605
+ #disconnected = () => {
606
+ this.#confirmed = false
607
+ this.#settleLostMessages()
608
+ this.#handleStatus('disconnected')
609
+ }
610
+
611
+ /**
612
+ * End the loading state of messages sent on a socket that has since
613
+ * closed - their replies will never arrive. Held messages still will.
614
+ * @private
615
+ */
616
+ #settleLostMessages() {
617
+ const lost = (this.#controller?.inFlight ?? 0) - this.#pending.length
618
+
619
+ for (let i = 0; i < lost; i++) {
620
+ this.#controller.finishLoading()
621
+ }
622
+ }
623
+
431
624
  /**
432
625
  * Handle incoming messages from the server.
433
626
  * Processes status updates and DOM refreshes.
@@ -436,21 +629,34 @@ class Subscription {
436
629
  * @param {string} [data._status] - Status update (e.g., 'subscribed', 'destroy')
437
630
  * @param {string} [data._refresh] - HTML to morph into the DOM
438
631
  * @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
632
+ * @param {boolean} [data._reply] - Whether a _refresh answers this
633
+ * component's own message; absent from servers before 0.4.1, whose
634
+ * refreshes all count as replies
635
+ * @param {boolean} [data._subscribed] - Marks the frame the server sends
636
+ * when it (re)subscribes the component
637
+ * @param {boolean} [data._ack] - Answers a message when the component's own
638
+ * re-render didn't: nothing changed, or its render rode in its parent's
639
+ * refresh; clears the loading state
441
640
  * @param {Array} [data._events] - Events to dispatch as CustomEvents; when
442
641
  * attached to a _refresh they fire after the DOM has been morphed
443
642
  * @private
444
643
  */
445
644
  #received = (data) => {
645
+ if (data['_subscribed']) {
646
+ this.#settleLostMessages()
647
+ }
648
+
446
649
  if (data['_status']) {
447
650
  this.#handleStatus(data['_status'])
448
651
  } else if (data['_refresh']) {
449
- this.#handleRefresh(data['_refresh'])
652
+ this.#handleRefresh(data['_refresh'], { reply: !('_reply' in data) || data['_reply'] === true })
450
653
  } else if (data['_error']) {
451
654
  this.#handleError(data['_error'])
452
655
  } else if (data['_ack']) {
453
- this.#controller?.finishLoading()
656
+ // Pending elements skipped the renders that came while they waited
657
+ if (this.#controller?.finishLoading() && this.#componentState.renderCount !== this.#renderCountAtSend) {
658
+ this.#handleRefresh(null)
659
+ }
454
660
  }
455
661
 
456
662
  // Dispatch after the branch above so events attached to a refresh fire
@@ -509,6 +715,8 @@ class Subscription {
509
715
 
510
716
  // Handle destroy status - permanently remove this subscription
511
717
  if (status === 'destroy') {
718
+ // Replies still on their way go to a subscription that no longer exists
719
+ this.#controller?.resetLoading()
512
720
  this.unsubscribe()
513
721
  }
514
722
  }
@@ -517,9 +725,12 @@ class Subscription {
517
725
  * Handle DOM refreshes from the server.
518
726
  * Updates the DOM using morphdom.
519
727
  * @param {Object} refresh - Refresh data from the server
728
+ * @param {Object} [options]
729
+ * @param {boolean} [options.reply] - Whether the refresh answers this
730
+ * component's own message, which ends its loading state
520
731
  * @private
521
732
  */
522
- #handleRefresh(refresh) {
733
+ #handleRefresh(refresh, { reply = false } = {}) {
523
734
  // If we're getting a refresh we must be connected
524
735
  this.#handleStatus('subscribed')
525
736
 
@@ -528,15 +739,24 @@ class Subscription {
528
739
  return
529
740
  }
530
741
 
742
+ // A focused field whose form or trigger awaits a reply takes that reply's
743
+ // value like any other; read before the restore below unmarks it
744
+ const focused = document.activeElement
745
+ const focusedAwaitsReply = Boolean(focused?.closest('[live-loading]:not([data-live-id-value])'))
746
+
531
747
  // Restore live-loading / live-disable-with state before morphing so the
532
748
  // 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()
749
+ // Only replies count, and with multiple messages in flight this only
750
+ // restores once the last one arrives - until then the morph below
751
+ // preserves the pending elements so live-disable-with buttons can't be
752
+ // clicked early.
753
+ if (reply) {
754
+ this.#controller.finishLoading()
755
+ }
537
756
 
538
757
  const rootElement = this.#controller.element
539
758
  const stillLoading = this.#controller.isLoading
759
+ const childResults = refresh?.c || {}
540
760
 
541
761
  const refreshDOM = this.#buildRefreshDOM(refresh)
542
762
 
@@ -544,6 +764,8 @@ class Subscription {
544
764
  refreshDOM.setAttribute('live-loading', '')
545
765
  }
546
766
 
767
+ let keepFocusedValue = false
768
+
547
769
  morphdom(rootElement, refreshDOM, {
548
770
  // Preserve elements marked with live-ignore attribute
549
771
  onBeforeElUpdated(fromEl, toEl) {
@@ -561,6 +783,39 @@ class Subscription {
561
783
  return false
562
784
  }
563
785
 
786
+ // A nested component's pending elements wait for its own reply,
787
+ // unless this refresh carries its render
788
+ if (fromEl.hasAttribute('live-loading') && !fromEl.hasAttribute('data-live-id-value')) {
789
+ const owner = fromEl.closest('[data-live-id-value]')
790
+
791
+ if (owner && owner !== rootElement) {
792
+ const liveId = `${owner.getAttribute('data-live-component-value')}/${owner.getAttribute('data-live-id-value')}`
793
+
794
+ if (!(liveId in childResults)) {
795
+ return false
796
+ }
797
+ }
798
+ }
799
+
800
+ // defaultValue is what the server last rendered for the field
801
+ if (fromEl === focused) {
802
+ keepFocusedValue = !focusedAwaitsReply && isTextField(fromEl) && fromEl.defaultValue === toEl.defaultValue
803
+ }
804
+
805
+ return true
806
+ },
807
+ // Skipping its children skips morphdom's value sync, so the focused
808
+ // field keeps what the user typed
809
+ onBeforeElChildrenUpdated(fromEl, toEl) {
810
+ if (keepFocusedValue && fromEl === focused) {
811
+ return false
812
+ }
813
+
814
+ // morphdom updates a textarea's text node, which an empty one lacks
815
+ if (fromEl instanceof HTMLTextAreaElement && fromEl.defaultValue !== toEl.defaultValue) {
816
+ fromEl.defaultValue = toEl.defaultValue
817
+ }
818
+
564
819
  return true
565
820
  },
566
821
  // Use stable keys for better morphing performance and state preservation
@@ -611,24 +866,39 @@ class Subscription {
611
866
 
612
867
  // Replace Child Components
613
868
  const childResults = refresh?.c || {}
614
- rootNode.querySelectorAll('LiveCable').forEach(component => {
869
+ const placeholders = [...rootNode.querySelectorAll('LiveCable')]
870
+ const replaced = new Set()
871
+
872
+ while (placeholders.length > 0) {
873
+ const component = placeholders.shift()
615
874
  const liveId = component.getAttribute('child-live-id')
616
875
  const childResult = childResults[liveId]
617
876
 
877
+ if (replaced.has(liveId)) {
878
+ console.error(`[LiveCable] ${liveId} is rendered more than once in a single refresh`)
879
+ component.remove()
880
+ continue
881
+ }
882
+
883
+ replaced.add(liveId)
884
+
618
885
  const componentState = subscriptionManager.getComponentState(liveId)
886
+ let childElement
619
887
 
620
888
  if (componentState) {
621
889
  // ComponentState already exists (either in subscription or standalone)
622
- component.replaceWith(
623
- componentState.createRefresh(childResult)
624
- )
890
+ childElement = (childResult || componentState.hasRender)
891
+ ? componentState.createRefresh(childResult)
892
+ : document.createTextNode('')
625
893
  } else {
626
894
  // No state exists yet - create ComponentState
627
- const childElement = this.#buildChildElement(childResult)
895
+ childElement = this.#buildChildElement(childResult)
628
896
  subscriptionManager.registerComponent(liveId, childElement, childResult)
629
- component.replaceWith(childElement)
630
897
  }
631
- })
898
+
899
+ component.replaceWith(childElement)
900
+ placeholders.push(...(childElement.querySelectorAll?.('LiveCable') ?? []))
901
+ }
632
902
 
633
903
  return rootNode
634
904
  }
@@ -6,48 +6,70 @@ class LiveChannel < ActionCable::Channel::Base
6
6
 
7
7
  before_subscribe :ensure_live_connection
8
8
 
9
+ # Each entry point below runs as its own job on ActionCable's worker pool,
10
+ # so each holds the connection's lock for the whole of its work - see
11
+ # LiveCable::Connection#synchronize.
12
+
9
13
  # @component is assigned as soon as the component is known, so a failure
10
14
  # later in here still leaves it for unsubscribed to clean up
11
15
  def subscribed
12
- live_id = "#{params[:component]}/#{params[:id]}"
13
-
14
- @component = live_connection.get_component(live_id)
15
- rendered = component.present?
16
-
17
- unless component
18
- @component = LiveCable.instance_from_string(params[:component], params[:id])
19
- live_connection.add_component(component)
20
- component.defaults = params[:defaults]
21
- component.apply_defaults
22
- end
23
-
24
- component.connect(self)
25
-
26
- if rendered
27
- component.broadcast_subscribe
28
- else
29
- component.broadcast_render
16
+ live_connection.synchronize do
17
+ live_id = "#{params[:component]}/#{params[:id]}"
18
+
19
+ @component = live_connection.get_component(live_id)
20
+
21
+ # The client replaces a subscription, to send new defaults, by
22
+ # unsubscribing first, but ActionCable may run the two out of order
23
+ if component&.subscribed?
24
+ component.disconnect
25
+ @component = nil
26
+ end
27
+
28
+ rendered = component.present?
29
+
30
+ unless component
31
+ @component = LiveCable.instance_from_string(params[:component], params[:id])
32
+ live_connection.add_component(component)
33
+ # Defaults round-trip through the client, so verify the signed blob and
34
+ # bind it to this live_id before trusting it - otherwise a tampered value
35
+ # could set non-writable reactive variables at subscribe time.
36
+ component.defaults = LiveCable::DefaultsSigner.verify(params[:defaults], live_id)
37
+ component.apply_defaults
38
+ end
39
+
40
+ component.connect(self)
41
+
42
+ if rendered
43
+ component.broadcast_subscribe
44
+ else
45
+ component.broadcast_render(subscribed: true)
46
+ end
47
+ rescue StandardError => error
48
+ live_connection.handle_error(component, error, channel: self)
30
49
  end
31
- rescue StandardError => error
32
- live_connection.handle_error(component, error, channel: self)
33
50
  end
34
51
 
35
- # Every batch must be answered - the client holds its loading state until a
36
- # _refresh, _ack or _error arrives - so nothing raised here may escape to
37
- # ActionCable, which would only log it and leave the client hanging.
52
+ # Every batch must be answered - the client holds its loading state until
53
+ # its reply, an _ack or an _error arrives - so nothing raised here may escape
54
+ # to ActionCable, which would only log it and leave the client hanging.
38
55
  def receive(data)
39
- raise LiveCable::Error, 'No component was built, so this subscription cannot receive messages' unless component
56
+ live_connection.synchronize do
57
+ raise LiveCable::Error, 'No component was built, so this subscription cannot receive messages' unless component
40
58
 
41
- live_connection.receive(component, data)
42
- rescue StandardError => error
43
- live_connection.handle_error(component, error, channel: self)
59
+ live_connection.receive(component, data)
60
+ rescue StandardError => error
61
+ live_connection.handle_error(component, error, channel: self)
62
+ end
44
63
  end
45
64
 
46
65
  def unsubscribed
47
66
  return unless component
48
67
 
49
- component.disconnect
50
- @component = nil
68
+ live_connection.synchronize do
69
+ # Already disconnected by a newer subscription for the same component
70
+ component.disconnect if live_connection.get_component(component.live_id).equal?(component)
71
+ @component = nil
72
+ end
51
73
  end
52
74
 
53
75
  # Exposes #transmit, which ActionCable keeps private to the channel.
@@ -6,6 +6,9 @@ module LiveCableHelper
6
6
  # Add the current component to the parent context before making a new context
7
7
  render_context&.add_component(component)
8
8
 
9
+ # A prerendered root is the one component whose defaults are signed into the page
10
+ component.round_trip_defaults if render_context.nil? && !component.live_connection
11
+
9
12
  # If we had a parent with a live connection, we're connected, so apply defaults now, if not
10
13
  # then we apply them to the pre-render container
11
14
  component.apply_defaults
@@ -14,7 +17,14 @@ module LiveCableHelper
14
17
  context_stack.push(context)
15
18
 
16
19
  begin
17
- value = yield
20
+ value = nil
21
+
22
+ # A connected root runs its render callbacks in broadcast_render
23
+ if component.live_connection && !context.root?
24
+ component.run_callbacks(:render) { value = yield }
25
+ else
26
+ value = yield
27
+ end
18
28
  ensure
19
29
  context_stack.pop
20
30
  end
@@ -40,11 +50,30 @@ module LiveCableHelper
40
50
  live_id = "#{component}/#{id}"
41
51
 
42
52
  component = render_context&.get_component(live_id) || LiveCable.instance_from_string(component, id)
43
- component.defaults = defaults
53
+ component.defaults = render_context ? defaults.transform_values { |value| render_context.track(value) } : defaults
44
54
 
45
55
  render(component)
46
56
  end
47
57
 
58
+ # A meta tag carrying a digest of who this page was rendered for. When a
59
+ # Turbo visit brings a different one - after a sign-in, sign-out or
60
+ # impersonation - the client reopens its socket, so the new handshake
61
+ # identifies the new session. Pass what your connection's connect
62
+ # identifies by; the values are never sent in the clear.
63
+ #
64
+ # @param values [Array<Object>] e.g. current_user, or nil when signed out
65
+ def live_cable_identity_tag(*values)
66
+ tag.meta(name: 'live-cable-identity', content: LiveCable::IdentityDigest.digest(*values))
67
+ end
68
+
69
+ # Templates get the plain value inside a reactive one, so Rails helpers and
70
+ # `case` see a real Array or Hash.
71
+ def live_cable_unwrap(value)
72
+ return value unless value.is_a?(LiveCable::Delegator)
73
+
74
+ render_context.unwrap(value)
75
+ end
76
+
48
77
  private
49
78
 
50
79
  # @return [LiveCable::RenderContext, nil]
@@ -11,9 +11,12 @@ module LiveCable
11
11
  # there. Keeping LiveCable off the identifiers leaves that mechanism working
12
12
  # for the application's own, such as current_user.
13
13
  module ActionCableConnection
14
+ LOCK = Mutex.new
15
+
14
16
  # @return [LiveCable::Connection]
15
17
  def live_connection
16
- @live_connection ||= LiveCable::Connection.new(request)
18
+ # A socket's subscribes run as concurrent jobs on ActionCable's worker pool
19
+ @live_connection || LOCK.synchronize { @live_connection ||= LiveCable::Connection.new(request) }
17
20
  end
18
21
  end
19
22
  end