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
@@ -4,7 +4,9 @@ import LoadingState from "@isometriks/live_cable/loading"
4
4
 
5
5
  export default class extends Controller {
6
6
  static values = {
7
- defaults: Object,
7
+ // Opaque signed blob produced by the server; forwarded verbatim to the
8
+ // channel on subscribe, where it is verified and decoded.
9
+ defaults: String,
8
10
  status: String,
9
11
  component: String,
10
12
  actions: Array,
@@ -24,7 +26,7 @@ export default class extends Controller {
24
26
  }
25
27
 
26
28
  connect() {
27
- this.#loading = new LoadingState(this.element)
29
+ this.#loading = new LoadingState(this.element, { onStalled: () => this.#stalled() })
28
30
  this.element.addEventListener("call", this.#callActionCallback)
29
31
 
30
32
  this.#subscription = SubscriptionManager.subscribe(
@@ -34,6 +36,12 @@ export default class extends Controller {
34
36
  this
35
37
  )
36
38
 
39
+ // The subscription outlives a controller, so messages held from before a
40
+ // disconnect need this controller's watchdog
41
+ for (let i = this.#subscription.pendingCount; i > 0; i--) {
42
+ this.#loading.start()
43
+ }
44
+
37
45
  // Create callbacks for each action or form
38
46
  this.actionsValue.forEach((action) => {
39
47
  this[`action_$${action}`] = ({ params, currentTarget }) => {
@@ -48,6 +56,9 @@ export default class extends Controller {
48
56
 
49
57
  disconnect() {
50
58
  this.element.removeEventListener("call", this.#callActionCallback)
59
+ // A new controller brings its own loading state; don't leave this one's
60
+ // watchdog to fire on an element that has gone
61
+ this.#loading?.reset()
51
62
  }
52
63
 
53
64
  sendCall(action, params = {}, trigger = null) {
@@ -57,11 +68,12 @@ export default class extends Controller {
57
68
  )
58
69
  }
59
70
 
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.
71
+ // Called by the subscription when the server answers a message (its
72
+ // reply, an ack, or an error). Restores any live-loading / live-disable-with
73
+ // state once all in-flight messages have been answered, and returns true
74
+ // when it did.
63
75
  finishLoading() {
64
- this.#loading?.finish()
76
+ return this.#loading?.finish() ?? false
65
77
  }
66
78
 
67
79
  // Whether any message is still awaiting a server response
@@ -69,11 +81,31 @@ export default class extends Controller {
69
81
  return this.#loading?.active ?? false
70
82
  }
71
83
 
84
+ // How many messages are still awaiting a server response
85
+ get inFlight() {
86
+ return this.#loading?.inFlight ?? 0
87
+ }
88
+
72
89
  // Called by the subscription when the component is being torn down.
73
90
  resetLoading() {
74
91
  this.#loading?.reset()
75
92
  }
76
93
 
94
+ // The loading state gave up waiting for a reply. Anything still held for a
95
+ // reconnect is dropped with it, so a message the page has said didn't go
96
+ // through can't go through later - the user would click again, and it
97
+ // would run twice. A message already sent can't be recalled; the event
98
+ // says how many were dropped so the page can word its message.
99
+ #stalled() {
100
+ const discarded = this.#subscription?.discardPending() ?? 0
101
+
102
+ this.statusValue = 'stalled'
103
+ this.element.dispatchEvent(new CustomEvent('live:stalled', {
104
+ bubbles: true,
105
+ detail: { discarded },
106
+ }))
107
+ }
108
+
77
109
  #callMessage(params, action) {
78
110
  return {
79
111
  _action: action,
@@ -115,8 +147,20 @@ export default class extends Controller {
115
147
  return {
116
148
  _action: '_reactive',
117
149
  name: target.name,
118
- value: target.value,
150
+ value: this.#reactiveValue(target),
151
+ }
152
+ }
153
+
154
+ #reactiveValue(target) {
155
+ if (target.type === 'checkbox') {
156
+ return target.checked
119
157
  }
158
+
159
+ if (target.type === 'select-multiple') {
160
+ return Array.from(target.selectedOptions, (option) => option.value)
161
+ }
162
+
163
+ return target.value
120
164
  }
121
165
 
122
166
  #form(action, { currentTarget, params }) {
@@ -4,8 +4,10 @@ import DOM from '@isometriks/live_cable/dom'
4
4
 
5
5
  const observer = new LiveObserver()
6
6
  observer.start()
7
+ SubscriptionManager.syncIdentity()
7
8
 
8
9
  document.addEventListener('turbo:before-render', (event) => {
10
+ SubscriptionManager.syncIdentity()
9
11
  SubscriptionManager.prune(event.detail.newBody)
10
12
  })
11
13
 
@@ -12,12 +12,26 @@
12
12
  * message is in flight. If the attribute has a value, the element's label
13
13
  * (textContent, or value for inputs) is swapped for it.
14
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.
15
+ * The state is cleared when the server replies with the component's own
16
+ * re-render, an error, or an ack (sent when the action didn't re-render the
17
+ * component by itself). Multiple in-flight messages are counted; the DOM is
18
+ * only restored once all of them have been answered.
19
+ *
20
+ * If no reply comes for `LoadingState.timeout` milliseconds while a message
21
+ * is in flight, the state gives up: it resets and calls `onStalled`. That is
22
+ * the backstop for a reply that is never coming - the server stopped while
23
+ * running the message, say - which would otherwise leave a button disabled
24
+ * until the page is reloaded.
19
25
  */
20
26
  export default class LoadingState {
27
+ /**
28
+ * Milliseconds to wait for any reply before giving up. Long enough to
29
+ * cover a reconnect after a deploy, since messages held for one count too.
30
+ * Set it before components connect to change it everywhere.
31
+ * @type {number}
32
+ */
33
+ static timeout = 30_000
34
+
21
35
  /** @type {HTMLElement} */
22
36
  #root
23
37
  /** @type {number} */
@@ -26,12 +40,20 @@ export default class LoadingState {
26
40
  #markedElements = new Set()
27
41
  /** @type {Map<Element, Object>} - Original state of disabled elements */
28
42
  #disabledElements = new Map()
43
+ /** @type {Function|null} */
44
+ #onStalled
45
+ /** @type {number|null} */
46
+ #timer = null
29
47
 
30
48
  /**
31
49
  * @param {HTMLElement} root - The component's root element
50
+ * @param {Object} options
51
+ * @param {Function} [options.onStalled] - Called after the state gives up
52
+ * waiting and resets itself
32
53
  */
33
- constructor(root) {
54
+ constructor(root, { onStalled = null } = {}) {
34
55
  this.#root = root
56
+ this.#onStalled = onStalled
35
57
  }
36
58
 
37
59
  /**
@@ -42,6 +64,14 @@ export default class LoadingState {
42
64
  return this.#inFlight > 0
43
65
  }
44
66
 
67
+ /**
68
+ * How many messages are awaiting a server response.
69
+ * @returns {number}
70
+ */
71
+ get inFlight() {
72
+ return this.#inFlight
73
+ }
74
+
45
75
  /**
46
76
  * Mark the component as loading.
47
77
  * Called right before a message is sent to the server.
@@ -52,7 +82,9 @@ export default class LoadingState {
52
82
  * elements. Disabled for reactive inputs so typing doesn't lose focus.
53
83
  */
54
84
  start(trigger = null, { disable = true } = {}) {
55
- this.#inFlight++
85
+ if (this.#inFlight++ === 0) {
86
+ this.#arm()
87
+ }
56
88
 
57
89
  this.#mark(this.#root)
58
90
 
@@ -79,9 +111,12 @@ export default class LoadingState {
79
111
  this.#inFlight--
80
112
 
81
113
  if (this.#inFlight > 0) {
114
+ // Still waiting on others, but the server is evidently answering
115
+ this.#arm()
82
116
  return false
83
117
  }
84
118
 
119
+ this.#disarm()
85
120
  this.#restore()
86
121
  return true
87
122
  }
@@ -92,9 +127,26 @@ export default class LoadingState {
92
127
  */
93
128
  reset() {
94
129
  this.#inFlight = 0
130
+ this.#disarm()
95
131
  this.#restore()
96
132
  }
97
133
 
134
+ #arm() {
135
+ this.#disarm()
136
+ this.#timer = setTimeout(() => this.#stall(), LoadingState.timeout)
137
+ }
138
+
139
+ #disarm() {
140
+ clearTimeout(this.#timer)
141
+ this.#timer = null
142
+ }
143
+
144
+ #stall() {
145
+ this.#timer = null
146
+ this.reset()
147
+ this.#onStalled?.()
148
+ }
149
+
98
150
  #mark(element) {
99
151
  element.setAttribute('live-loading', '')
100
152
  this.#markedElements.add(element)