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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +567 -0
- data/README.md +14 -2
- data/app/assets/javascript/controllers/live_controller.js +51 -7
- data/app/assets/javascript/live_cable.js +2 -0
- data/app/assets/javascript/loading.js +58 -6
- data/app/assets/javascript/subscriptions.js +292 -22
- data/app/channels/live_channel.rb +51 -29
- data/app/helpers/live_cable_helper.rb +31 -2
- data/lib/live_cable/action_cable_connection.rb +4 -1
- data/lib/live_cable/component/broadcasting.rb +37 -6
- data/lib/live_cable/component/dispatching.rb +36 -0
- data/lib/live_cable/component/lifecycle.rb +1 -1
- data/lib/live_cable/component/reactive_variables.rb +33 -5
- data/lib/live_cable/component/rendering.rb +30 -14
- data/lib/live_cable/component/streaming.rb +35 -4
- data/lib/live_cable/component.rb +4 -3
- data/lib/live_cable/connection/broadcasting.rb +15 -3
- data/lib/live_cable/connection/component_management.rb +12 -8
- data/lib/live_cable/connection/error_handling.rb +28 -7
- data/lib/live_cable/connection/messaging.rb +40 -52
- data/lib/live_cable/connection/state_management.rb +13 -16
- data/lib/live_cable/connection.rb +33 -0
- data/lib/live_cable/container.rb +17 -11
- data/lib/live_cable/defaults_signer.rb +95 -0
- data/lib/live_cable/delegation/array.rb +27 -1
- data/lib/live_cable/delegation/hash.rb +55 -1
- data/lib/live_cable/delegation/methods.rb +25 -1
- data/lib/live_cable/delegation/model.rb +12 -0
- data/lib/live_cable/delegator.rb +54 -6
- data/lib/live_cable/dispatch.rb +54 -0
- data/lib/live_cable/engine.rb +3 -1
- data/lib/live_cable/forbidden.rb +7 -0
- data/lib/live_cable/identity_digest.rb +27 -0
- data/lib/live_cable/model_observer.rb +37 -0
- data/lib/live_cable/observer_tracking.rb +10 -1
- data/lib/live_cable/predicate_builder.rb +13 -0
- data/lib/live_cable/render_context.rb +82 -8
- data/lib/live_cable/rendering/controller_renderer.rb +26 -0
- data/lib/live_cable/rendering/dependency_visitor.rb +68 -24
- data/lib/live_cable/rendering/handler.rb +1 -1
- data/lib/live_cable/rendering/method_analyzer.rb +18 -4
- data/lib/live_cable/rendering/method_collector.rb +13 -5
- data/lib/live_cable/rendering/partial.rb +13 -1
- data/lib/live_cable/rendering/partial_renderer.rb +59 -7
- data/lib/live_cable/rendering/render_result.rb +5 -0
- data/lib/live_cable/rendering/renderer.rb +18 -14
- data/lib/live_cable/rendering/view_buffer.rb +21 -0
- data/lib/live_cable/testing/render_state.rb +5 -3
- data/lib/live_cable/testing/test_channel.rb +6 -0
- data/lib/live_cable/testing/test_component.rb +3 -3
- data/lib/live_cable/testing.rb +9 -5
- data/lib/live_cable/version.rb +1 -1
- data/lib/live_cable.rb +32 -3
- 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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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.#
|
|
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.
|
|
440
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
534
|
-
//
|
|
535
|
-
// elements so live-disable-with buttons can't be
|
|
536
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
36
|
-
#
|
|
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
|
-
|
|
56
|
+
live_connection.synchronize do
|
|
57
|
+
raise LiveCable::Error, 'No component was built, so this subscription cannot receive messages' unless component
|
|
40
58
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|