live_cable 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a08ab617cc974271c9f4b2784bb52ca53d1430942fd72e761fe52cd650a86141
4
- data.tar.gz: abd1b26ca97b149de015b9fb7b9b457ff6b6f21e224a61318788876115fcd9d9
3
+ metadata.gz: 9328496395172b7689b680c9fc54f222f26da2cc2d5a1cd25683fbcbdbe3c2bf
4
+ data.tar.gz: c824da0a8a7cc10f3ca84a2965d264d88b879944b108d57e37599b0865b07b9d
5
5
  SHA512:
6
- metadata.gz: e3b932fcbdfcf3fb5d6b939320798eb151c82d0e6bc85c61c2452ff729233855b477752e10465c8098f4601651d04f9e67449293b9635cde3eefa47bde08733c
7
- data.tar.gz: 6476ba238c1960655922da93588b175ed0468294364dd1252b05ad044eb26cb0d94360254581cb791ddc6def81155940c82d310835398b7c791b8cb3daf6884c
6
+ metadata.gz: ed7c3db367dea001f0d50cfd0d05dd5c7ba837800438924e944c7a92041dd0b252293682792b17bb8730934a659ba2839a1c6ad283469fdacc00bfa4771f4a79
7
+ data.tar.gz: a97bb8a0096edb1a4daa016b59051aef62a1d93eb1e8635d4a5462b5b6444ae5bdf28ae997b141f0e3ff0dfafde71fdcb08d98d322921c4e527e8e2c3ed416b6
data/CHANGELOG.md CHANGED
@@ -6,6 +6,42 @@ 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
+ ## 0.2.1 - 2026-08-13
10
+
11
+ ### Removed
12
+
13
+ - `Component#channel_name` and `Connection#channel_name`. A component no longer
14
+ subscribes to a stream of its own now that payloads are written straight to its
15
+ channel, so the name identified a stream nothing published to or read from.
16
+ Subscribing to external streams with `stream_from` is unaffected (gem).
17
+
18
+ ### Fixed
19
+
20
+ - **The opening payload from `LiveChannel#subscribed` could be dropped.**
21
+ Payloads were published through the pubsub adapter, but ActionCable registers
22
+ `stream_from` asynchronously — so the initial render could be published before
23
+ the subscription it targets existed, and pubsub delivers only to subscribers
24
+ present at that moment, with no buffering or retry. The component was left
25
+ showing `data-live-status-value="disconnected"`, with no cached render on the
26
+ client for a Turbo reattach to replay, until some later action happened to
27
+ produce a refresh. When a subscribe render raised, the `_error` payload was
28
+ lost outright and the failure never surfaced in the browser at all. A
29
+ component's stream belongs to exactly one connection, so payloads are now
30
+ written straight to that connection rather than published, which removes the
31
+ race along with a pubsub round trip (gem).
32
+ - **Components not reattaching after a Turbo Drive navigation.** Navigating to a
33
+ page containing a component that is already subscribed deliberately keeps the
34
+ existing subscription, so `LiveChannel#subscribed` does not run again and the
35
+ server sends nothing. The newly rendered element was left with its
36
+ server-rendered status of `disconnected` and never received the component's
37
+ current state. A reconnecting controller now re-syncs the subscription's status
38
+ and replays the last render into the new element (npm).
39
+ - Building DOM from a server render used iterator helpers
40
+ (`childNodes.values().find(...)`), an ES2025 feature, which raised
41
+ `TypeError: ... .find is not a function` on runtimes that do not implement them
42
+ — Node 20 and earlier, and browsers older than Chrome 122 / Firefox 131 /
43
+ Safari 18.4. Rewritten with `Array.from` (npm).
44
+
9
45
  ## 0.2.0
10
46
 
11
47
  The gem and npm package versions are realigned in this release. The npm package
@@ -34,8 +34,7 @@ function createDOMFromHTML(html) {
34
34
  template.innerHTML = html
35
35
 
36
36
  // Find a node that isn't a comment
37
- const node = template.content.childNodes
38
- .values()
37
+ const node = Array.from(template.content.childNodes)
39
38
  .find(n => n.nodeName !== '#comment')
40
39
 
41
40
  if (node) {
@@ -230,6 +229,15 @@ class ComponentState {
230
229
  return this.#element
231
230
  }
232
231
 
232
+ /**
233
+ * Whether a render has been received and cached, and can therefore be
234
+ * replayed into a freshly attached element.
235
+ * @returns {boolean}
236
+ */
237
+ get hasRender() {
238
+ return this.#lastTemplate !== null && Boolean(this.#partsByTemplate[this.#lastTemplate])
239
+ }
240
+
233
241
  /**
234
242
  * Create a DOM element from stored state.
235
243
  * @param {Object} refresh - Optional refresh data to update state
@@ -345,12 +353,44 @@ class Subscription {
345
353
 
346
354
  /**
347
355
  * Update the controller reference.
348
- * Called when a Stimulus controller reconnects to an existing subscription.
356
+ * Called when a Stimulus controller reconnects to an existing subscription
357
+ * most importantly after a Turbo Drive navigation to a page that contains the
358
+ * same component, where prune() deliberately keeps the subscription alive.
359
+ *
360
+ * In that case the ActionCable subscription is never recreated, so
361
+ * LiveChannel#subscribed does not run again and the server sends nothing.
362
+ * Without re-syncing here, the newly rendered element keeps the
363
+ * server-rendered status of "disconnected" forever and never receives the
364
+ * component's current state.
349
365
  *
350
366
  * @param {Object} controller - Stimulus controller instance
351
367
  */
352
368
  set controller(controller) {
369
+ const previous = this.#controller
370
+
353
371
  this.#controller = controller
372
+ this.#componentState.element = controller.element
373
+
374
+ if (previous && previous !== controller) {
375
+ this.#reattach()
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Bring a freshly connected controller up to date with state this
381
+ * subscription already holds.
382
+ * @private
383
+ */
384
+ #reattach() {
385
+ if (this.#currentStatus) {
386
+ this.#controller.statusValue = this.#currentStatus
387
+ }
388
+
389
+ // Replay the last render into the new element. The server-side component
390
+ // is the same instance, so the cached parts are its current state.
391
+ if (this.#componentState.hasRender) {
392
+ this.#handleRefresh(null)
393
+ }
354
394
  }
355
395
 
356
396
  /**
@@ -41,6 +41,11 @@ class LiveChannel < ActionCable::Channel::Base
41
41
  @component = nil
42
42
  end
43
43
 
44
+ # Exposes #transmit, which ActionCable keeps private to the channel.
45
+ def broadcast(data)
46
+ transmit(data)
47
+ end
48
+
44
49
  private
45
50
 
46
51
  # @return [LiveCable::Component, nil]
@@ -5,8 +5,10 @@ module LiveCable
5
5
  module Broadcasting
6
6
  extend ActiveSupport::Concern
7
7
 
8
+ # Nil until the component's own controller subscribes; children rendered
9
+ # by a parent have no channel to deliver to yet.
8
10
  def broadcast(data)
9
- ActionCable.server.broadcast(channel_name, data)
11
+ channel&.broadcast(data)
10
12
  end
11
13
 
12
14
  def broadcast_subscribe
@@ -22,10 +22,6 @@ module LiveCable
22
22
  def live_id
23
23
  self.class.component_id(id)
24
24
  end
25
-
26
- def channel_name
27
- "#{live_connection.channel_name}/#{live_id}"
28
- end
29
25
  end
30
26
  end
31
27
  end
@@ -23,7 +23,6 @@ module LiveCable
23
23
  def connect(channel)
24
24
  run_callbacks :connect do
25
25
  @channel = channel
26
- start_stream
27
26
  @subscribed = true
28
27
  end
29
28
  end
@@ -7,14 +7,7 @@ module LiveCable
7
7
 
8
8
  private
9
9
 
10
- def start_stream
11
- channel.stream_from(channel_name)
12
- end
13
-
14
10
  def stop_stream
15
- channel.stop_stream_from(channel_name)
16
-
17
- # Stop all additional streams started via stream_from
18
11
  additional_streams.each do |stream_name|
19
12
  channel.stop_stream_from(stream_name)
20
13
  end
@@ -3,7 +3,6 @@
3
3
  module LiveCable
4
4
  class Connection
5
5
  include ComponentManagement
6
- include ChannelManagement
7
6
  include StateManagement
8
7
  include Messaging
9
8
  include Broadcasting
@@ -13,17 +12,12 @@ module LiveCable
13
12
 
14
13
  def initialize(request)
15
14
  @request = request
16
- @session_id = SecureRandom.uuid
17
15
  @containers = Hash.new { |hash, key| hash[key] = Container.new }
18
16
  @components = {}
19
- @channels = {}
20
17
  end
21
18
 
22
19
  private
23
20
 
24
- # @return [String]
25
- attr_reader :session_id
26
-
27
21
  # @return [ActionDispatch::Request]
28
22
  attr_reader :request
29
23
 
@@ -3,19 +3,28 @@
3
3
  module LiveCable
4
4
  module Testing
5
5
  # Stand-in for an ActionCable channel. Records streams started via
6
- # stream_from so tests can trigger their callbacks with receive_stream.
6
+ # stream_from so tests can trigger their callbacks with receive_stream,
7
+ # and payloads sent by components.
7
8
  class TestChannel
8
9
  # @return [Hash<String, Hash>] Stream name => { coder:, callback: }
9
10
  attr_reader :streams
10
11
 
12
+ # @return [Array<Hash>] Payloads sent by components through this channel
13
+ attr_reader :transmissions
14
+
11
15
  # @return [LiveCable::Testing::TestCableConnection]
12
16
  attr_reader :connection
13
17
 
14
18
  def initialize(identifiers = {})
15
19
  @streams = {}
20
+ @transmissions = []
16
21
  @connection = TestCableConnection.new(identifiers)
17
22
  end
18
23
 
24
+ def broadcast(data)
25
+ @transmissions << data
26
+ end
27
+
19
28
  def stream_from(name, coder: nil, &block)
20
29
  @streams[name] = { coder:, callback: block }
21
30
  end
@@ -18,9 +18,6 @@ module LiveCable
18
18
  super(component)
19
19
  @connection = connection
20
20
  @channel = channel
21
- @broadcasts = []
22
-
23
- capture_broadcasts(component)
24
21
  end
25
22
 
26
23
  # @return [LiveCable::Component] The underlying component instance
@@ -74,15 +71,15 @@ module LiveCable
74
71
  # (e.g. :_refresh, :_ack, :_error, :_status)
75
72
  # @return [Array<Hash>]
76
73
  def broadcasts(key = nil)
77
- return @broadcasts.dup unless key
74
+ return channel.transmissions.dup unless key
78
75
 
79
- @broadcasts.select { |broadcast| broadcast.key?(key) }
76
+ channel.transmissions.select { |broadcast| broadcast.key?(key) }
80
77
  end
81
78
 
82
79
  # Forget previously captured broadcasts. Useful after mounting, to
83
80
  # assert on the effects of a single action.
84
81
  def clear_broadcasts
85
- @broadcasts.clear
82
+ channel.transmissions.clear
86
83
  end
87
84
 
88
85
  # All events the component has dispatched via dispatch_event, in
@@ -134,14 +131,6 @@ module LiveCable
134
131
  def receive_message(message)
135
132
  connection.receive(component, { 'messages' => [message] })
136
133
  end
137
-
138
- def capture_broadcasts(component)
139
- captured = @broadcasts
140
-
141
- component.define_singleton_method(:broadcast) do |data|
142
- captured << data
143
- end
144
- end
145
134
  end
146
135
  end
147
136
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LiveCable
4
- VERSION = '0.2.0'
4
+ VERSION = '0.2.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: live_cable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Craig Blanchette
@@ -145,7 +145,6 @@ files:
145
145
  - lib/live_cable/configuration.rb
146
146
  - lib/live_cable/connection.rb
147
147
  - lib/live_cable/connection/broadcasting.rb
148
- - lib/live_cable/connection/channel_management.rb
149
148
  - lib/live_cable/connection/component_management.rb
150
149
  - lib/live_cable/connection/error_handling.rb
151
150
  - lib/live_cable/connection/messaging.rb
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LiveCable
4
- class Connection
5
- module ChannelManagement
6
- extend ActiveSupport::Concern
7
-
8
- def channel_name
9
- "live_#{session_id}"
10
- end
11
- end
12
- end
13
- end