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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9eab70af0621e69a52c7eaccce2d76a400a8627223c6aa91525230d262179782
4
- data.tar.gz: 1a6ca70d53e728fc4893607bc5de7ab12094bbdefb06d52cd2a83afefd653ac5
3
+ metadata.gz: e057aa97b7e7358e132b96ab748c410e60b4d60012ae12bbc8f1007e7e49465a
4
+ data.tar.gz: aa0da7bec89699efe1eb46ceae11a58fdd686db5f6e39d6d45b36e3304f52d57
5
5
  SHA512:
6
- metadata.gz: b43cf0bd548271aeae7534a26d1d950266078f3f571165fb99d5daec8021d1c8af16b29f2b71866aa6030594b5e829ecc59bcf4186b4209666472f81d3b7c9bf
7
- data.tar.gz: 3d7753820b0ff173ae72a210eb4ecfbf77704e1897a2ebf570c085e7bad6ec1a7531bb7138beb64cf0fadd2af39ee77cf6be0f0e493961d770cd11182e2d3a43
6
+ metadata.gz: e6327de430dba2dc315a2eb220f12f5ae4934630f0e18533aba25de624f963d68a19155eb9857282655978f363ec524ee850f57080aa738e0f308cda6946e7a9
7
+ data.tar.gz: 630e1deca5298f6c40b80b626b89a71c7848ddba520b4e635dfe3f7db105f6dd5469c0296789ad116d3718e518be33ad1f8d9ed11d8b5a94b7e66c6a591a34fb
data/CHANGELOG.md CHANGED
@@ -6,6 +6,573 @@ The Ruby gem (`live_cable`) and the npm package (`@isometriks/live_cable`) are
6
6
  released together and share a single version number. Entries below note which
7
7
  side of the pair a change affects when it isn't both.
8
8
 
9
+ ## Unreleased
10
+
11
+ ### Upgrading from 0.4
12
+
13
+ The gem and the npm package work with each other at 0.4 in either
14
+ direction, but upgrade both to get every fix. A few fixes change behaviour an
15
+ application could notice:
16
+
17
+ - A template that reads a local assigned inside an earlier block, with no
18
+ component method or view helper of that name, now raises `NoMethodError`
19
+ instead of rendering nothing.
20
+ - A component method named like a Kernel method (`open`, `test`, `select`, …)
21
+ now wins over Kernel in its template; call it on `Kernel`, as in
22
+ `Kernel.rand`, to reach Kernel's. Don't name a component method `format`:
23
+ ActionView reads it as the template's format.
24
+ - A tag that reassigns a local set by an earlier top-level `<% %>` tag now
25
+ re-runs on every update, so later tags see the right value. Keep expensive
26
+ work out of such tags.
27
+ - A loading state now ends only on the component's own reply. A message held
28
+ while the socket was down stays loading until it is answered.
29
+ - A focused text field keeps what the user typed through renders it didn't
30
+ ask for. To clear a field after a submit, bind it to a reactive variable and
31
+ reset that in the action.
32
+ - `live_mount(..., raise_errors: false)` no longer raises when the first
33
+ render fails; assert on `broadcasts(:_error)` instead.
34
+ - A template now gets the plain Array, Hash or model inside a reactive
35
+ variable, so Rails helpers and `case` see the real value. A template that
36
+ changes a reactive value in place no longer marks it dirty; change it in the
37
+ component. `component.x` in a template still returns the tracked wrapper.
38
+ - In component code, values reached through `fetch`, `dig`, `detect`,
39
+ `Hash#each` and `each_value` are tracked wrappers now, so writes through
40
+ them mark the variable dirty. Use `to_a`, `to_h` or `__getobj__` where code
41
+ needs the real Array or Hash.
42
+ - A model held in a reactive variable is watched on the record itself. A
43
+ write through any reference to it, a `reload`, or a validation that
44
+ normalises an attribute now marks the variable dirty and re-renders.
45
+ - An error raised by an initial-value lambda goes through normal error
46
+ handling instead of storing `nil`: a root's subscribe answers with an
47
+ `_error`, and a failing child replaces its parent with the error box. An
48
+ initial value that isn't a proc raises `ArgumentError` when the class loads.
49
+ - Defaults are signed in their `as_json` form, and a prerendered root
50
+ renders with that form too, so a Symbol default is a String on page load as
51
+ well as after subscribe. In development and test, a default that changes on
52
+ the way logs a warning.
53
+ - A child its parent renders now runs its render callbacks on each of those
54
+ renders. On page load they run before its connect callbacks, without
55
+ connection identifiers such as `current_user`.
56
+ - When a component re-renders, parts that read a non-reactive `shared`
57
+ variable render again too.
58
+ - A checkbox with `live-reactive` sends `true` or `false`, and a `<select
59
+ multiple>` sends an Array of values, instead of the element's `value`.
60
+ - A render that changes nothing on the page is no longer sent. An action
61
+ whose changes no part of the template shows is answered with an `_ack`, so
62
+ a test expecting a `_refresh` after one should expect `broadcasts(:_ack)`.
63
+ - Renders over the socket now share one controller per connection. A
64
+ `helper_method` on `ApplicationController` that memoizes into an instance
65
+ variable keeps its value for the connection rather than for one render.
66
+ These renders have no session or params, so this only matters for a value
67
+ that changes over time.
68
+
69
+ ### Fixed
70
+
71
+ - **A template local could render blank or stale.** Dependency
72
+ tracking parsed each part of a `.live.erb` template on its own and ignored
73
+ Ruby's block scopes. A local assigned inside a block leaked into later
74
+ parts, so a later `<%= css %>` rendered blank instead of calling the
75
+ component's `css`; a top-level local updated inside a block, as in
76
+ `<% counts.each { |c| total += c } %>`, kept its earlier value in later
77
+ parts; and a local reassigned in an `if` that didn't run became `nil`.
78
+ Block parameters also counted as dependencies, so a part re-rendered
79
+ whenever a reactive variable of the same name changed. Each part is now
80
+ parsed with the earlier parts' locals in scope, so names resolve as they
81
+ would in a plain ERB template. Two templates behave differently: one that
82
+ reads a block's local after the block, with no component method or view
83
+ helper of that name, now raises `NoMethodError` ("undefined method 'last'"
84
+ for a local named `last`) instead of rendering nothing, much as plain ERB
85
+ raises `NameError`; and a local reassigned in a branch that doesn't run
86
+ keeps its value instead of becoming `nil` (gem).
87
+ - **A block passed to a view helper that renders a partial landed above the
88
+ partial.** A helper such as `def card(&) = render(layout: 'shared/card', &)`,
89
+ used from a `.live.erb` template as `<%= card do %>`, wrote the block's
90
+ content before the card and left stray escaped markup where the card
91
+ yields. 0.4.0 fixed this for `render` called by the template itself; a
92
+ block the template passes to any view helper is now handled the same way.
93
+ Helpers that capture their block, such as `form_with` and `content_tag`,
94
+ are unaffected (gem).
95
+ - **The error for a `.live.erb` template rendered as a partial didn't say
96
+ which template.** The message began "A .live.erb template", and the
97
+ backtrace points into LiveCable rather than at the template. It now opens
98
+ with the template's path, such as
99
+ `app/views/shared/_live_card.html.live.erb` (gem).
100
+ - **Template names that Kernel or Object also define, like `open`, `select`
101
+ or `test`, ran the Kernel method instead of the component's or the view
102
+ helper's.** A `.live.erb` template resolves bare names to the component and
103
+ then the view context, but names the renderer already had from Kernel or
104
+ Object never got that far. A component with `reactive :open` and
105
+ `<% if open %>` raised an `ArgumentError` on the page and on mount, the
106
+ `select` form helper raised a `TypeError`, and `j` printed to standard output
107
+ instead of escaping. These names now go to the component or the view context
108
+ whenever either defines them. When neither does they still call the Kernel
109
+ method, so `format`, `rand` and `lambda` work as before. A component method
110
+ with such a name now wins in its template; call it on `Kernel`, as in
111
+ `Kernel.rand`, to reach the Kernel method (gem).
112
+ - **A child hidden after its parent re-rendered something else was never
113
+ destroyed.** A parent only remembered the inline children of the parts its
114
+ last render had run, so after a render that skipped a child's part it no
115
+ longer knew it had that child. That happened on any change the part doesn't
116
+ depend on, and whenever the parent was re-rendered inline by its own parent
117
+ with nothing changed. Hiding the child later, a render error in the parent,
118
+ or the parent's own destroy then left the child subscribed, with its
119
+ `stream_from` streams running, until the socket closed. A compound component
120
+ that switched to a variant with fewer parts leaked the children of the parts
121
+ that went away in the same way. A parent now keeps the children of the parts
122
+ a render skipped, and destroys any its new template no longer renders (gem).
123
+ - **A `stream_from` callback could still run after `stop_stream`.**
124
+ ActionCable runs each broadcast as its own job on its worker pool, and
125
+ stopping a stream can't take back a job already queued. A chat that
126
+ switched rooms with `stop_stream` and a new `stream_from` could still run a
127
+ callback for the old room after the switch. With a reactive array, the old
128
+ room's message was appended to the new room's list and stayed there. A
129
+ callback now checks, under the connection lock, that its stream is still
130
+ running, so once `stop_stream` returns none of its callbacks run. Stopping a
131
+ stream and starting it again under the same name still delivers what was
132
+ already queued. `stop_stream` also kept every name it had stopped, a list
133
+ that grew with each switch. The new `stop_stream_from(name)` stops one
134
+ stream and leaves the component's others running (gem).
135
+ - **Another user's broadcast re-enabled a `live-disable-with` button whose own
136
+ message was still in flight.** The client ended a component's loading state
137
+ on every `_refresh`, including renders it never asked for - a `stream_from`
138
+ callback's, or one caused by another component's action - and a parent's
139
+ re-render rebuilt a nested component's pending button, so someone else's
140
+ chat message could re-enable your Send button and invite a double submit.
141
+ A nested component re-rendered only inside its parent's render got no reply
142
+ at all, and stalled after 30 seconds. The server now
143
+ marks the render that answers a message with `_reply`, or sends an `_ack`
144
+ when the component sent no render of its own, and the client ends a loading
145
+ state only on a reply, an `_ack` or an `_error`; pending elements keep their
146
+ state through other renders and catch up when it comes. Frames captured by
147
+ `LiveCable::Testing` carry the new `_reply` and `_subscribed` keys. A message
148
+ held while the socket was down now stays loading until its own reply, and
149
+ one lost with the socket stops when it closes or the component
150
+ re-subscribes. Either side works with the other at 0.4.0 (gem + npm).
151
+ - **Typing in a focused field was undone by a render the user didn't ask
152
+ for.** Every refresh is morphed in from HTML rebuilt from the component's
153
+ stored parts, which set each field back to the last value the server
154
+ rendered. In a chat with a debounced `live-reactive` draft, each incoming
155
+ `stream_from` message, or a render caused by another component, dropped
156
+ what had been typed since the last send, and the debounced send that
157
+ followed sent what was left. A focused text field or textarea now keeps
158
+ what has been typed unless the server changed its value. A re-render that
159
+ answers the field's own form or action still applies the server's value,
160
+ so an input cleared after a submit is still cleared. Unfocused fields,
161
+ checkboxes, radios and selects behave as before. A focused field that
162
+ isn't bound to a reactive variable is no longer cleared by a render it
163
+ didn't ask for; to clear it after a submit, bind it and reset the variable
164
+ in the action (npm).
165
+ - **A Turbo Drive visit removed the inline children of a component that
166
+ stayed on the page.** Before Turbo renders a new page, LiveCable closes the
167
+ subscription of every component that isn't on it. The HTTP prerender gives
168
+ a live id only to the root component, so a child rendered inline by its
169
+ parent looked like it was leaving, even when the parent (a layout
170
+ component, say) was staying. The child was torn down on the server, and the
171
+ parent had nothing to rebuild it from, so it disappeared from the page. A
172
+ component that stays on the page now also keeps the children it renders
173
+ with `.live.erb` templates, and theirs, with their subscriptions and live
174
+ state (npm).
175
+ - **`live_mount(..., raise_errors: false)` still raised when the first render
176
+ or a `connect` callback failed.**
177
+ `live_mount` ran `connect` and the first render with no rescue, so an error
178
+ in either still raised, while production sends it through the connection's
179
+ error handling and transmits an `_error`. `live_mount` now handles those
180
+ errors the same way `LiveChannel#subscribed` does. With `raise_errors: false`
181
+ it returns the component with the `_error` in its broadcasts, so a spec that
182
+ expected `live_mount(..., raise_errors: false)` to raise now needs to assert
183
+ on `broadcasts(:_error)` instead. The default, `raise_errors: true`, still
184
+ raises (gem).
185
+ - **`component.x` and `self.x` in a template didn't re-render when `x`
186
+ changed.** Dependency tracking only recorded bare names, so a part that read
187
+ a reactive variable as `component.theme` or `self.theme` was skipped when
188
+ `theme` changed and kept showing the old value. `self.x` also ran Kernel's
189
+ method of that name when there was one, so `self.open` raised. Both forms
190
+ now count as reads of the component (gem).
191
+ - **A helper called with arguments rendered a template local of the same
192
+ name instead.** After `<% t = Time.current %>`, a later `<%= t('.title') %>`
193
+ printed the time: a call with arguments still found the stored local before
194
+ the helper. A local now only answers a bare name, as in plain ERB (gem).
195
+ - **`items.each.with_index` raised `LocalJumpError` on reactive arrays.** A
196
+ reactive array's `each` always yielded, so calling `each` without a block
197
+ failed, and so did `each.with_index`, `each.with_object` and `each.lazy`. In
198
+ a template this meant the prerender worked, because it sees the raw array,
199
+ but every socket render failed and the component never went live. `each`
200
+ without a block now returns an enumerator, and elements yielded through it
201
+ are tracked like those from `each` with a block (gem).
202
+ - **Some common ways of changing a reactive Array or Hash didn't re-render.**
203
+ Change tracking only knew the method names it listed, so aliases and
204
+ ActiveSupport bang methods went straight through untracked. That covered
205
+ `append`, `prepend`, `filter!`, `collect!`, `compact_blank!` and `extract!`,
206
+ and on hashes `store`, `replace`, `slice!`, `with_defaults!`,
207
+ `deep_symbolize_keys!` and others. Nested values read through `detect`,
208
+ `fetch`, `dig`, `at`, `min_by`, `each_with_index`, `each_with_object`,
209
+ `reverse_each`, `each_slice`, `values`, `each_value` or `Hash#each` came
210
+ back untracked. So `todos.detect { ... }[:done] = true` answered with an
211
+ `_ack` and left the page stale. All of these are now tracked, and a spec
212
+ fails if a mutating method is missed. Values yielded by these methods are
213
+ now tracked wrappers, like the ones `each` already yielded, so `is_a?(Hash)`
214
+ on them is false. The blocks of `map`/`select` and the results of
215
+ `to_a`/`to_h` are still untracked; see *Nested Structures* in the reactive
216
+ variables guide (gem).
217
+ - **`self.tags = tags.dup` stopped all later change tracking for `tags`.**
218
+ `dup` dropped the wrapper's tracking modules, so the assignment rendered
219
+ once and every later `tags << x` or nested change went unnoticed. A
220
+ duplicated reactive value now keeps its tracking. `clone(freeze: false)` on
221
+ a reactive value or an ActiveRecord model raised `ArgumentError` and now
222
+ works (gem).
223
+ - **Changing an element moved into another reactive collection marked the
224
+ wrong variable.** After `done << todos.find { ... }` (or `self.done =
225
+ [item]`, `done + [item]`, `selection[:item] = item`), changing the element
226
+ through `done` marked `todos` instead. That included a child component given
227
+ the element with `live(...)`, so the `done` part never re-rendered. Reactive
228
+ collections now store the plain element rather than the tracked wrapper it
229
+ was read through, and an element read back is tracked through the collection
230
+ it was read from. An Array or Hash you pass in is copied only when it holds
231
+ such a wrapper (gem).
232
+ - **ActiveRecord models read from a reactive variable never compared equal.**
233
+ Two reads of the same record, or a raw record and a wrapped one, compared
234
+ unequal. So `todos.delete(todos.find { ... })` left the record in the list,
235
+ `include?`, `index` and `todos - [found]` missed it, and `t == selected` in
236
+ a template never matched. Equality now compares the records themselves.
237
+ Unsaved records still compare unequal, as ActiveRecord intends (gem).
238
+ - **Many changes to ActiveRecord models in reactive variables didn't
239
+ re-render.** A model reached through a reactive Array or Hash
240
+ (`todos.first.title = x`, `todos.find { ... }.toggle!(:done)`, setters
241
+ inside `todos.each`) was only tracked for `update` and `assign_attributes`.
242
+ Even a model stored directly missed `[]=`, `write_attribute`, `increment!`,
243
+ `update_column(s)`, `reload` and saved in-place JSON changes, so the
244
+ streaming guide's `document.reload` example never refreshed the page. All of
245
+ these writes are now tracked, at any depth. A replaced record no longer
246
+ keeps marking the variable it was removed from. `reload`, and validations
247
+ that normalize attributes, now re-render even when nothing visible changed.
248
+ Association changes and unsaved in-place edits still need `dirty(:name)`
249
+ (gem).
250
+ - **After connecting, reactive Arrays and Hashes rendered wrongly in Rails tag
251
+ helpers, and `where(id: reactive_array)` matched nothing.** A tracked value
252
+ isn't an `Array` or `Hash` to `case` or `===`. On socket renders,
253
+ `tag.span(class: classes)` printed `class="[&quot;btn&quot;,
254
+ &quot;primary&quot;]"` where the prerender printed `class="btn primary"`,
255
+ and `where(id: ids)` compiled to `id = NULL`. Templates now see the plain
256
+ value, so helpers and `case` behave as in any Rails view, and `where(column:
257
+ value)` accepts reactive values. A child given one of these with `live(...)`
258
+ still shares its change tracking, so the parent re-renders when the child
259
+ changes it. That covers a reactive value, an element of one, and a
260
+ collection built from one in the template, such as `todos.reject { ... }`. A
261
+ template that mutates a reactive value no longer marks it dirty. A
262
+ variable's first read now returns the tracked value like later reads, so
263
+ changes made through it are tracked. In component code, `case`,
264
+ `where(hash)` and association assignment still need `to_a`, `to_h` or
265
+ `__getobj__` (gem).
266
+ - **A part that passed a reactive variable with hash shorthand (`render
267
+ 'badge', page:`) or called it with empty parentheses (`count()`) never
268
+ re-rendered.** The dependency analysis only recognised a bare `page`, so
269
+ these forms (`tag.span(data: { page: })`, `items_path(page:)`, a shorthand
270
+ of a template local set in an earlier part) were left out and the part kept
271
+ its first-render HTML. Any receiverless call with no arguments and no block
272
+ now counts as a read. (gem)
273
+ - **A component method's dependencies were lost when another def in the same
274
+ file had the same name.** The method analyzer parses the component's whole
275
+ file and the last def with a given name won, so a `def self.title`, a `class
276
+ << self` method, a method in a nested class or `Struct.new` block, or a
277
+ method of a second class in the file could leave every part calling `title`
278
+ stale. Same-named defs now merge their dependencies, and singleton methods
279
+ are ignored. Affected parts may now re-render where they were previously
280
+ skipped. (gem)
281
+ - **Parts reading a non-reactive `shared` variable in a `.live.erb` template
282
+ never re-rendered.** They kept their first-render value even when the
283
+ component re-rendered for its own reasons or wrote the variable itself, so
284
+ the guide's FilterPanel badge example showed a stale count. Whenever a
285
+ component re-renders, parts that read its `shared` variables, directly or
286
+ through a component method, now re-render too. A change to a `shared`
287
+ variable on its own still does not trigger a render. These parts are now
288
+ sent on every re-render of the component. (gem)
289
+ - **The guide's `reactive :user, ->(c) { User.find(c.defaults[:user_id]) }`
290
+ raised `NoMethodError`.** `Component#defaults` was private, and an initial
291
+ lambda gets the component as an explicit receiver. So the prerender failed,
292
+ `live_mount` raised, and a real subscribe sent an `_error` and rendered
293
+ without the default. `defaults` is now public. As a side effect, a bare
294
+ `defaults` in a `.live.erb` template now resolves to the component's hash
295
+ (gem).
296
+ - **A top-level component could render one way on page load and another once
297
+ connected.** Its defaults come back from the browser as JSON on subscribe,
298
+ but the prerender used the raw Ruby values. A Symbol came back as a String
299
+ and nested hash keys as Strings. Since signing in 0.4.0 used plain
300
+ `JSON.generate`, a `Time` came back as its `to_s` and a record as
301
+ `"#<User:0x…>"`. LiveCable now signs each default's `as_json` form, as 0.3's
302
+ unsigned attribute did, and prerenders a top-level component with that same
303
+ form, so both renders agree. In development and test it logs a warning for
304
+ any default that isn't JSON-native. **Never pass a record to a top-level
305
+ component: its JSON form is every one of its attributes, readable in the
306
+ page.** The defaults are signed, not encrypted. Pass an id and load the
307
+ record in the component. A top-level component now gets the JSON form on the
308
+ HTTP prerender too. So `status: :open` is now `"open"` there, and
309
+ `user.name` on a passed record now raises on page load (a 500 in production)
310
+ instead of after connect. Children rendered by a parent and `live_mount`
311
+ defaults are unchanged (gem).
312
+ - **An exception in a reactive variable's initial lambda was swallowed and the
313
+ variable stuck at `nil` (or `true`).** On a connected component the error
314
+ was reported, and then `handle_error`'s return value was stored as the
315
+ variable. That was `nil` for a child rendered inline and `true` for a
316
+ component with a channel, and the lambda never ran again. A child whose
317
+ lambda failed rendered blank, with nothing sent to the client, and a failed
318
+ shared variable stayed broken for every later sharer. The error now goes
319
+ through the normal handling for the render or action that read it, including
320
+ `rescue_from`, and nothing is stored, so the next read retries. A top-level
321
+ component's subscribe now sends just the `_error` (no trailing `_refresh`).
322
+ A child's failing initial value now replaces its parent with the error box.
323
+ A non-proc initial value now raises `ArgumentError` when the class loads
324
+ (gem).
325
+ - **Initial lambdas ran again on every read during the HTTP prerender.** With
326
+ no connection, the reactive getter never kept the value, so a `.live.erb`
327
+ template ran the lambda once per reference, plus once for the render locals.
328
+ A query read three times ran four times, and `-> { SecureRandom.hex }` gave
329
+ a label's `for` and its input's `id` different values on the same page. The
330
+ value is now kept for the rest of that prerender (gem).
331
+ - **`before_render` and `after_render` never ran for a child rendered by its
332
+ parent.** Render callbacks ran only when a component broadcast its own
333
+ render. A child rendered inside a connected parent was never rendered that
334
+ way: on page load its subscription found it already rendered and only
335
+ confirmed its status. So state a child derived in `before_render` showed its
336
+ initial value and went stale every time the parent rendered it again. A
337
+ child's render callbacks now run each time a connected parent renders it,
338
+ and an error in one replaces the parent with the error box. On page load the
339
+ parent renders the child before the child subscribes, so the child's render
340
+ callbacks run before its connect callbacks, without connection identifiers
341
+ such as `current_user`. A child whose `before_render` uses one will now
342
+ error its parent on page load. Render callbacks still don't run during the
343
+ HTTP prerender, which the lifecycle guide now says (gem).
344
+ - **A checkbox with `live-reactive` could never be unchecked, and a `<select
345
+ multiple>` kept only its first choice.** `live-reactive` always sent the
346
+ input's `value`. For a checkbox that is its `value` attribute ("on" by
347
+ default), whether the box is checked or not. For a multiple select it is
348
+ only the first selected option. A checkbox now sends `true` or `false`, and
349
+ a `<select multiple>` sends an Array of the selected values. Radios, single
350
+ selects and text inputs still send their value as a String. If you were
351
+ reading "on" (or a custom `value` attribute) from a checkbox, you now get a
352
+ boolean (npm).
353
+
354
+ ### Changed
355
+
356
+ - **A render that changed nothing on the page was still sent.** When a
357
+ reactive variable changed but no part of the template showed it - a shared
358
+ variable updated by another component, say - the component still sent a
359
+ `_refresh` with every part empty, and the client rebuilt and morphed it for
360
+ no visible change. Such a render is now skipped: its events still go out,
361
+ its render callbacks still run, and the message it answers gets an `_ack`.
362
+ - **Every render over the socket built a controller, request and response.**
363
+ `Component#render` went through `ApplicationController.renderer`, which
364
+ builds all three each time - about a fifth of the render time of a small
365
+ component. A connection now builds them once, from the same renderer
366
+ defaults, and each render takes a fresh view from that controller, so
367
+ `content_for`, instance variables a template sets and memoized view helpers
368
+ still don't carry over. The HTTP prerender is unchanged (gem).
369
+
370
+ ## 0.4.0 - 2026-09-29
371
+
372
+ ### Upgrading from 0.3
373
+
374
+ Upgrade the gem and the npm package together. Defaults are now signed, and
375
+ the client forwards them as an opaque string; a 0.3 client sends them as an
376
+ object, which the server treats as no defaults at all, so every component
377
+ would mount without them.
378
+
379
+ Pages rendered before the upgrade carry unsigned defaults too. They mount with
380
+ no defaults until reloaded, so deploy at a quiet moment, or expect components
381
+ on open tabs to come up empty.
382
+
383
+ A component already live on the page is now built again when a Turbo visit
384
+ renders it with different defaults. One whose defaults differ on every
385
+ render - a timestamp, a random token - is therefore rebuilt on every visit
386
+ instead of keeping its state; keep defaults to what the component is about,
387
+ such as a record or an account.
388
+
389
+ A shared variable is now writable from the client only if every component
390
+ class that shares the name declares it `writable: true`. An application that
391
+ shares a name as writable in one component and server-only in another will
392
+ see `live-reactive` writes to it refused.
393
+
394
+ ### Security
395
+
396
+ - **Defaults could set reactive variables that weren't writable.** Defaults
397
+ passed to `live(...)` are written into the page and sent back by the browser
398
+ when the component subscribes, and they were applied to any reactive
399
+ variable, writable or not. Anyone could edit the `live-defaults` attribute,
400
+ or send the subscribe frame by hand, and set a variable meant to be
401
+ server-only - a record id, a price, a tenant key - bypassing the check
402
+ `live-reactive` writes go through. Defaults are now signed with a key
403
+ derived from `secret_key_base` and bound to the component's `live_id`; an
404
+ edited, unsigned or borrowed blob applies no defaults
405
+ (`LiveCable::DefaultsSigner`). `LiveCable::Testing#live_mount` still takes a
406
+ plain hash.
407
+ - **A component could keep acting for the previous page's defaults after a
408
+ Turbo visit.** When the new page rendered a component with the same id as
409
+ one already live, the client kept the existing subscription and never sent
410
+ the new page's defaults. A component whose id didn't include the account
411
+ stayed bound to the account it was first rendered for, and its actions ran
412
+ against that account under a URL for another. The client now compares the
413
+ signed defaults and, when they differ, replaces the subscription so the
414
+ server builds the component from the new ones; equal defaults keep today's
415
+ behaviour, state included. A Turbo preview of a cached page is ignored.
416
+ Defaults are signed with their keys sorted, so the same defaults always
417
+ produce the same blob.
418
+ - **A client could write a shared variable that a component treats as
419
+ server-only.** Shared variables are one value per connection, but a
420
+ `live-reactive` write was checked only against the class it was sent
421
+ through. Because a client can subscribe any component, it could mount one
422
+ that shares `:account_id` as `writable: true` and set the value another
423
+ component shares without `writable:` (or with plain `shared`). A client may
424
+ now write a shared name only if every class that shares it declares it
425
+ `writable: true`. Other writes are refused with `Forbidden`. Apps that mix
426
+ writable and server-only declarations of the same shared name will see those
427
+ writes refused. In development with lazy loading, a class that hasn't loaded
428
+ yet doesn't count toward the rule (gem).
429
+
430
+ ### Fixed
431
+
432
+ - **An action and a `stream_from` callback could run at the same time on one
433
+ connection.** ActionCable runs each message and each stream broadcast as a
434
+ separate job on a worker pool shared by the whole server, with nothing
435
+ keeping two jobs for one socket apart, and LiveCable's per-connection state
436
+ was read and written from both. An action that saved a record and then
437
+ pushed it onto a reactive array could have the save's own broadcast reload
438
+ the array in between, and the record appeared twice. Each connection's work
439
+ is now serialised by a re-entrant lock, held across an action or callback
440
+ and its render. A job waiting on a busy connection holds a worker while it
441
+ waits; see *Concurrency* in the architecture guide (gem).
442
+ - **A click while the socket was down left its button disabled for good.**
443
+ ActionCable drops a message sent on a closed socket, and one sent after a
444
+ reconnect but before the re-subscribe is confirmed, and the loading state
445
+ had already started. Messages are now held until the subscription is
446
+ confirmed and sent in order, and the component reads
447
+ `data-live-status-value="disconnected"` meanwhile (npm).
448
+ - **A loading state waited for ever when no reply came** - the server
449
+ stopping mid-message, on a deploy. After `LoadingState.timeout` (30 seconds)
450
+ with nothing heard it gives up, restores the DOM, drops any messages still
451
+ held for a reconnect, sets the status to `stalled` and dispatches a
452
+ `live:stalled` event. Messages still held when a component's element is
453
+ replaced, by a Turbo navigation say, keep their loading state on the new
454
+ element (npm).
455
+ - **Events dispatched by a child rendered inline by its parent were dropped.**
456
+ Such a child has no channel of its own yet; its events were flushed and then
457
+ discarded. They now wait for the child's own subscription (gem).
458
+ - **Component names that collide with a top-level constant failed obscurely.**
459
+ `instance_from_string` used `const_defined?`, which inherits, so a name like
460
+ `"string"` slipped past the "not found" guard and raised `NoMethodError`
461
+ instead of `LiveCable::Error` (gem).
462
+ - **A `stream_from` callback re-sent whatever the last message had changed.**
463
+ Changesets were reset only when a client message arrived. Variables dirtied
464
+ by the last message, or by the defaults applied at subscribe, stayed dirty,
465
+ and every later stream callback on the socket re-rendered them, whichever
466
+ component of the connection they belonged to. In a chat whose input is a
467
+ component of its own, as in the streaming guide, each incoming message
468
+ re-rendered the input with its last-sent `live-reactive` value, and the
469
+ morph overwrote whatever had been typed since the last debounce. A stream
470
+ callback now resets the changesets before it runs, as a message does, so it
471
+ sends only what it changed (gem).
472
+ - **Grandchild components went missing from the page.** A refresh replaced
473
+ the `<LiveCable>` placeholders in its own markup but not the ones inside a
474
+ child it had just built or rebuilt, so a grandchild reached the morph as an
475
+ empty element: a tree nested three deep lost its third level as soon as the
476
+ page connected, and a grandchild added by its parent's own refresh was torn
477
+ out the next time its grandparent re-rendered. Placeholders are now
478
+ replaced until none remain. A component that turns up twice in one refresh
479
+ keeps its first appearance and logs a console error, and a child with
480
+ nothing to rebuild from is left empty instead of abandoning the refresh
481
+ (npm).
482
+ - **Rendering a partial with a block from a `.live.erb` template failed.**
483
+ The block was dropped on its way to the view, so
484
+ `<%= render layout: 'shared/card' do %>` raised `ArgumentError`, and
485
+ `<%= render 'shared/card' do %>` rendered the partial with nothing where it
486
+ yields. The block is now passed on and renders where the partial yields (gem).
487
+ - **Rendering a `.live.erb` template as a partial put
488
+ `#<LiveCable::Rendering::Partial:0x...>` in the page.** A `.live.erb`
489
+ template compiles to an object only a component knows how to render, so
490
+ `<%= render 'shared/card' %>` on a `_card.html.live.erb`, or
491
+ `render template:` on one, wrote that object's escaped name into the HTML.
492
+ It now raises `LiveCable::Error`, saying to render the component with
493
+ `live(...)` or `render(component)`, or to make the partial a `.html.erb`
494
+ template (gem).
495
+ - **A `stream_from` broadcast that arrived as its component disconnected
496
+ raised in ActionCable's worker.** ActionCable runs each broadcast as its own
497
+ job, so one queued before the stream was stopped still ran the callback
498
+ against the departed component; broadcasting its changes then raised
499
+ `NoMethodError` on the missing connection, and the rescue meant to report
500
+ that raised again, so the error escaped to ActionCable's log. Such a
501
+ callback is now ignored (gem).
502
+ - **The test harness's `rendered` forgot the initial render after
503
+ `clear_broadcasts`.** It was rebuilt from the `_refresh` payloads still in
504
+ the broadcast log, so after a clear and an action it held only the parts
505
+ that action changed, and any inline child rendered earlier was missing.
506
+ Renders are now kept apart from the broadcast log, so `rendered` and
507
+ `rendered_html` reflect every render whatever has been cleared.
508
+ `broadcasts` and `dispatched_events` still start over after a clear (gem).
509
+ - **The test harness's `rendered` left out grandchildren.** The results
510
+ for inline children at every depth arrive together in the root's refresh,
511
+ but a child's placeholders were resolved against a set of children of its
512
+ own, which was always empty. A component rendered inline by an inline child
513
+ came out blank. Placeholders now resolve at any depth (gem).
514
+ - **Two components that subscribed at the same moment could end up on
515
+ different connections.** A socket's `LiveCable::Connection` is created the
516
+ first time it is needed, and ActionCable runs each subscribe as its own job
517
+ on its worker pool, so two components on one page subscribing together could
518
+ each create one. A component left on the losing one had its actions answered
519
+ with an `_ack` and no re-render, and shared reactive variables weren't
520
+ shared. The connection is now created under a lock (gem).
521
+ - `MethodAnalyzer` no longer raises for a component class with no Ruby source
522
+ location, such as one built with `Class.new`. With no methods to analyze,
523
+ every part that calls one of the component's methods re-renders on each
524
+ change (gem).
525
+ - `insert_root_attributes` builds a new string instead of mutating the
526
+ rendered part, so a frozen part can't raise `FrozenError`, and its "no root
527
+ element" error now shows the start of the offending output (gem).
528
+ - **A component destroyed while a message was in flight stayed loading.**
529
+ `destroy` unsubscribes the component on the client, so the reply to the
530
+ message that caused it never arrived and its `live-disable-with` button
531
+ stayed disabled. The loading state is now cleared when the server destroys
532
+ the component (npm).
533
+
534
+ ### Added
535
+
536
+ - **`rescue_from` in components.** `ActiveSupport::Rescuable` was included but
537
+ never consulted. An error raised by an action, a `live-reactive` write or a
538
+ `stream_from` callback is now offered to the component's `rescue_from`
539
+ handlers first; a handled error still answers the message, so the loading
540
+ state clears, and anything the handler set is re-rendered. Failures while
541
+ subscribing or rendering still get the default error markup, as does a
542
+ message asking for an action the component doesn't expose or a write to a
543
+ variable that isn't `writable:` - those raise `LiveCable::Forbidden`, a
544
+ `LiveCable::Error`, and are never offered to `rescue_from` (gem).
545
+ - **`before_dispatch` callbacks.** Authorizing every client message meant
546
+ wrapping each action, keeping its arity, catching methods defined after
547
+ `actions`, and wrapping every writable variable's setter - which then also
548
+ ran for the component's own writes. `before_dispatch` runs before each
549
+ action call and `live-reactive` write the client sends, and before nothing
550
+ else: not server-side assignments, the defaults applied at subscribe, or
551
+ `stream_from` callbacks. `current_dispatch` says what is being dispatched
552
+ (`kind`, `name`, `params` or `value`). `throw :abort` skips the message and
553
+ still answers it, so the loading state clears; an error raised goes to
554
+ `rescue_from` like one raised by an action. Callbacks are inherited, so an
555
+ `ApplicationComponent` can declare one for every component (gem).
556
+ - **`live_cable_identity_tag`, so a sign-in or sign-out in a tab moves its
557
+ socket on.** A socket is identified once, at its handshake, and Turbo keeps
558
+ it open across sign-in, sign-out and impersonation, all ordinary form
559
+ submissions; after user A signed out and user B signed in, every component
560
+ in the tab kept acting as A. The helper renders a meta tag carrying a keyed
561
+ digest of whatever the application identifies sockets by; when a Turbo
562
+ visit brings a different one, the client closes the socket and opens a new
563
+ one before the new page's components subscribe, and they come back as the
564
+ new session. Other tabs, and access revoked by someone else, are the
565
+ application's to handle with ActionCable's
566
+ `remote_connections.where(...).disconnect`; the architecture guide's
567
+ *Sign-in, Sign-out and Revocation* shows the Devise wiring.
568
+
569
+ ### Changed
570
+
571
+ - Framework warnings - a component rendered without a `.live.erb` template, a
572
+ missing `app/live` directory - go through the Rails logger once per process,
573
+ instead of `Kernel#warn` to stderr on every render (gem).
574
+ - RuboCop now fails CI.
575
+
9
576
  ## 0.3.0 - 2026-09-26
10
577
 
11
578
  ### Upgrading from 0.2
data/README.md CHANGED
@@ -589,7 +589,7 @@ self.count.+(1)
589
589
  self.name.concat("Doe")
590
590
  ```
591
591
 
592
- For more details on the change tracking architecture, see [ARCHITECTURE.md](ARCHITECTURE.md).
592
+ For more details on the change tracking architecture, see the [architecture guide](https://livecable.io/guide/architecture).
593
593
 
594
594
  ## Shared Variables
595
595
 
@@ -649,7 +649,7 @@ module Live
649
649
  end
650
650
  ```
651
651
 
652
- **Use case**: FilterPanel can read the cart to show item count in a badge, but doesn't need to re-render every time an item is added—only when the filter changes.
652
+ **Use case**: FilterPanel can read the cart to show item count in a badge, but doesn't need to re-render every time an item is added—only when the filter changes. Whenever FilterPanel re-renders for its own reasons, parts that read `cart_items` (directly or through a method) re-render too, so the badge picks up the latest count.
653
653
 
654
654
  ## Action Whitelisting
655
655
 
@@ -897,6 +897,18 @@ Updates a reactive variable when an input changes. The corresponding reactive va
897
897
  <input type="text" name="query" live-reactive="keydown keyup">
898
898
  ```
899
899
 
900
+ The value sent depends on the input: a checkbox sends `true` or `false` (whether it is checked, regardless of its `value` attribute), a `<select multiple>` sends an Array of the selected option values, and everything else, radio buttons included, sends its `value` as a String.
901
+
902
+ ```html
903
+ <input type="checkbox" name="notify" live-reactive <%= 'checked' if notify %>>
904
+
905
+ <select multiple name="tags" live-reactive="change">
906
+ <% %w[ruby rails].each do |tag| %>
907
+ <option value="<%= tag %>" <%= 'selected' if tags.include?(tag) %>><%= tag %></option>
908
+ <% end %>
909
+ </select>
910
+ ```
911
+
900
912
  **Transformation:** `live-reactive` becomes `data-action="live#reactive"`, and `live-reactive="keydown"` becomes `data-action="keydown->live#reactive"`
901
913
 
902
914
  ### `live-debounce`