reactive_component 0.8.5 → 0.9.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: 5ec78650feb0396bbe85c2adc0994209dc4d1ab91a6cf0ca49d9692b17e0b316
4
- data.tar.gz: c2d54ef039a0ea502c593a5eb1e173f509955e3e37b0f6eea1cd78c05ed365d5
3
+ metadata.gz: b5392838b8ed189cab22600b11b26eaa10215f601dd18294a4070febd9d88fbc
4
+ data.tar.gz: ed5fc1b4fb9c8e41ca0e717481323655b04b31bd4c94eb7ae011d4e05452b881
5
5
  SHA512:
6
- metadata.gz: caf86cb2646eea037defb27405b486555e70377351c6fe3a61b7d1b00aa33cf330d6ace77a42936e9c93c2bbb992026d0a5a2138137ac6591ff6efd1a45f6c21
7
- data.tar.gz: fc02fd8569b770ff6b6ed6583cdfbc4fb3dfb2630768eaa7d4f095ce755cc7ecf7617721fa542ca5f76a7a149984cbd5549e4f959911e67b739f96a4e2aa3a22
6
+ metadata.gz: bc1903d993c6504bccd2235175c131287589437461991084dfd93f6124f806de5589a669581bc9e3a34feac4fd5ad5f251131cabe498e5ad4218ade2db225574
7
+ data.tar.gz: 84a9b85096b7838863837ed0fac03be89394ceef37a24484a1425a9643f707dc120204a8c8b9bf90f2f5645e5d18ce87f04e0d4603a6a6c218f31f16b85d1bc1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.1] - 2026-09-17
4
+
5
+ ### Changed
6
+ - The renderer logs every render and update only when `ReactiveComponent.debug`
7
+ is on, which it reads from the `data-reactive-debug` attribute already on the
8
+ wrapper. It used to log everywhere, production included, unless a
9
+ `devToolbar:debug` key in `localStorage` was set to the string `"false"`.
10
+ - A client-state change on a notify component that has no data yet asks the
11
+ server to render it instead of doing nothing, and an update request carries
12
+ the state the component is in now rather than the one the page was built
13
+ with.
14
+ - A notify wrapper with client state no longer embeds its initial data payload.
15
+ It asks the server to render it, so the attribute was the page a second time,
16
+ carrying every branch of every extracted expression. A push wrapper keeps it:
17
+ it renders from that payload until the first broadcast arrives.
18
+
19
+ ## [0.9.0] - 2026-09-17
20
+
21
+ ### Added
22
+ - `subscribes_to ..., strategy: :notify` declares notify mode for every
23
+ instance. A broadcast then sends the bare signal instead of building a
24
+ payload no notify client reads, and the wrapper needs no
25
+ `live_wrapper_options`. An instance cannot switch such a class back to push.
26
+
27
+ ### Changed
28
+ - A broadcast now runs in an Active Job (`ReactiveComponent::BroadcastJob`)
29
+ instead of inside the request that committed the record, so rendering a
30
+ payload no longer holds the response. `subscribes_to ..., later: false`
31
+ keeps it inline. A destroy always broadcasts inline. The request id rides
32
+ along, so `skip_own_broadcasts` keeps working.
33
+ - A morph leaves `[data-turbo-permanent]` elements alone, as Turbo's own morph
34
+ does. An open menu marks itself permanent while it is open, and an update
35
+ underneath it no longer closes it.
36
+ - A `key` entity's dom id joins its parts with `_` instead of `-`
37
+ (`due_count_1_2`) and its gid with `/`. A page rendered before the deploy
38
+ routes broadcasts by the old id until it reloads.
39
+
40
+ ### Fixed
41
+ - A `key` entity joined its id on `-`, so a part carrying a dash, a UUID for
42
+ instance, never split back: every lookup missed and a notify component
43
+ never re-rendered. The id is now the array of parts, the way Rails exposes
44
+ a composite primary key, and GlobalID and `dom_id` encode it themselves.
45
+
3
46
  ## [0.8.5] - 2026-09-16
4
47
 
5
48
  ### Added
@@ -3,11 +3,6 @@ import { createConsumer } from "@rails/actioncable"
3
3
  import { compileTemplate, decompress, morphElement, buildActionBody, routeMessage, duplicateIds, strictData } from "reactive_component/lib/reactive_renderer_utils"
4
4
 
5
5
  const consumer = createConsumer()
6
- const log = (...args) => {
7
- if (localStorage.getItem("devToolbar:debug") !== "false") {
8
- console.log("[reactive-renderer]", ...args)
9
- }
10
- }
11
6
 
12
7
  function findSubscription(streamValue) {
13
8
  const identifier = JSON.stringify({ channel: "ReactiveComponent::Channel", signed_stream_name: streamValue })
@@ -113,6 +108,14 @@ export default class extends Controller {
113
108
  this.element.removeAttribute("data-reactive-renderer-connected")
114
109
  }
115
110
 
111
+ // ReactiveComponent.debug marks the wrapper, so the gem is quiet unless the
112
+ // app turns debugging on server-side.
113
+ log(...args) {
114
+ if (this.element.hasAttribute("data-reactive-debug")) {
115
+ console.log("[reactive-renderer]", ...args)
116
+ }
117
+ }
118
+
116
119
  resolveTemplate() {
117
120
  if (this.hasTemplateValue) return this.templateValue
118
121
 
@@ -129,18 +132,18 @@ export default class extends Controller {
129
132
 
130
133
  switch (route.type) {
131
134
  case "render":
132
- log("render", this.element.id, route.data)
135
+ this.log("render", this.element.id, route.data)
133
136
  this.lastServerData = route.data
134
137
  if (this.renderFn) this.render({ ...route.data, ...this.clientState })
135
138
  break
136
139
 
137
140
  case "request_update":
138
- log("update", this.element.id, { action: message.action, strategy: "notify" })
141
+ this.log("update", this.element.id, { action: message.action, strategy: "notify" })
139
142
  this.requestUpdate()
140
143
  break
141
144
 
142
145
  case "update":
143
- log("update", this.element.id, route.data)
146
+ this.log("update", this.element.id, route.data)
144
147
  this.lastServerData = route.data
145
148
  if (this.renderFn) this.render({ ...route.data, ...this.clientState })
146
149
  this.element.dispatchEvent(new CustomEvent("reactive-renderer:updated", {
@@ -168,7 +171,9 @@ export default class extends Controller {
168
171
  component: this.componentValue,
169
172
  sgid: this.sgidValue,
170
173
  dom_id: this.element.id,
171
- params: this.paramsValue
174
+ // The channel reads client state off params, so the server renders the
175
+ // state the component is in now, not the one the page was built with.
176
+ params: { ...this.paramsValue, ...this.clientState }
172
177
  })
173
178
  }, 50)
174
179
  }
@@ -262,16 +267,23 @@ export default class extends Controller {
262
267
  changed = true
263
268
  }
264
269
  }
265
- if (changed && ctrl.lastServerData && ctrl.renderFn) {
266
- requestAnimationFrame(() => ctrl.render({ ...ctrl.lastServerData, ...ctrl.clientState }))
267
- }
270
+ if (changed) ctrl.rerender()
268
271
  })
269
272
  }
270
273
  }
271
274
 
272
275
  Object.assign(this.clientState, updates)
276
+ this.rerender()
277
+ }
278
+
279
+ // A push component holds the data it needs. A notify one may not have any
280
+ // yet, its payload arrives on request, so a state change asks for it rather
281
+ // than leaving the click with nothing to show.
282
+ rerender() {
273
283
  if (this.lastServerData && this.renderFn) {
274
284
  requestAnimationFrame(() => this.render({ ...this.lastServerData, ...this.clientState }))
285
+ } else if (this.strategyValue === "notify") {
286
+ this.requestUpdate()
275
287
  }
276
288
  }
277
289
 
@@ -44,9 +44,20 @@ const HOOK_NAMES = [
44
44
  'beforeNodeRemoved', 'afterNodeRemoved', 'beforeAttributeUpdated'
45
45
  ]
46
46
 
47
+ // Turbo's convention: an element marked permanent is left as it is. A menu
48
+ // that is open, or anything else the page is in the middle of, says so with
49
+ // the attribute and the morph steps over it. Like Turbo's, it needs an id, so
50
+ // the morph pairs it with its counterpart instead of adding a second copy.
51
+ const isPermanent = (node) => node.nodeType === Node.ELEMENT_NODE && node.id !== "" && node.hasAttribute("data-turbo-permanent")
52
+
53
+ const skipPermanent = {
54
+ beforeNodeMorphed: (currentNode) => !isPermanent(currentNode),
55
+ beforeNodeRemoved: (node) => !isPermanent(node)
56
+ }
57
+
47
58
  const morphCallbacks = Object.fromEntries(HOOK_NAMES.map(name => [
48
59
  name,
49
- (...args) => morphHooks.reduce((ok, hook) => (hook[name]?.(...args) === false ? false : ok), true)
60
+ (...args) => [skipPermanent, ...morphHooks].reduce((ok, hook) => (hook[name]?.(...args) === false ? false : ok), true)
50
61
  ]))
51
62
 
52
63
  export function morphElement(element, newHtml) {
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveComponent
4
+ # Carries a broadcast out of the request that committed the record. The
5
+ # request id travels with it so a client can still skip its own broadcast.
6
+ # A record deleted before the job runs has nothing left to render, so the
7
+ # job is dropped rather than retried.
8
+ class BroadcastJob < ActiveJob::Base
9
+ discard_on ActiveJob::DeserializationError
10
+
11
+ def perform(component_class_name, record, action, request_id = nil)
12
+ Turbo.with_request_id(request_id) do
13
+ ReactiveComponent.broadcast_for(component_class_name.constantize, record, action: action.to_sym)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -28,7 +28,7 @@ module ReactiveComponent
28
28
  end
29
29
 
30
30
  def broadcast_reactive(action)
31
- reactive_component_classes.each { |klass| ReactiveComponent.broadcast_for(klass, self, action: action) }
31
+ reactive_component_classes.each { |klass| _broadcast_reactive_for(klass, action) }
32
32
  end
33
33
 
34
34
  def broadcast_reactive_update = broadcast_reactive(:update)
@@ -44,10 +44,20 @@ module ReactiveComponent
44
44
  fields = klass._subscribed_fields
45
45
  next if fields && !saved_changes.keys.intersect?(fields)
46
46
 
47
- ReactiveComponent.broadcast_for(klass, self, action: :update)
47
+ _broadcast_reactive_for(klass, :update)
48
48
  end
49
49
  end
50
50
 
51
51
  def _broadcast_reactive_destroy = broadcast_reactive(:destroy)
52
+
53
+ # A destroyed record cannot ride a job: it is gone by the time the job
54
+ # looks it up. Its signal is cheap, so it goes out inline. So does a
55
+ # component the job could not find by name, an anonymous class in a test.
56
+ def _broadcast_reactive_for(klass, action)
57
+ later = action != :destroy && klass.name && klass.try(:_broadcast_later)
58
+ return ReactiveComponent.broadcast_for(klass, self, action: action) unless later
59
+
60
+ ReactiveComponent::BroadcastJob.perform_later(klass.name, self, action.to_s, Turbo.current_request_id)
61
+ end
52
62
  end
53
63
  end
@@ -5,6 +5,7 @@
5
5
  # Entities and signed stream ids are GlobalIDs. ActiveJob pulls this railtie in
6
6
  # for its own arguments; an app without ActiveJob would otherwise have no
7
7
  # `GlobalID.app` and could not create one.
8
+ require 'active_job/railtie'
8
9
  require 'global_id/railtie'
9
10
 
10
11
  module ReactiveComponent
@@ -36,6 +36,7 @@ module ReactiveComponent
36
36
  # end
37
37
  module Entity
38
38
  extend ActiveSupport::Concern
39
+
39
40
  include ActiveModel::Model
40
41
  include Broadcastable
41
42
  include GlobalID::Identification
@@ -49,7 +50,7 @@ module ReactiveComponent
49
50
  # Turbo's `stream_name_from` prefers `to_gid_param`, so an entity names its
50
51
  # own stream. `to_param` stays as the fallback for when `GlobalID.app`
51
52
  # isn't set: a bare id would collide with every other entity sharing it.
52
- def to_param = "#{self.class.model_name.param_key}/#{id}"
53
+ def to_param = "#{self.class.model_name.param_key}/#{to_key.join('/')}"
53
54
 
54
55
  class_methods do
55
56
  # The record the entity is keyed on. Defines `initialize(<name>:)`, the
@@ -67,7 +68,10 @@ module ReactiveComponent
67
68
  end
68
69
 
69
70
  # An entity keyed on plain values instead of a record. Defines an `id`
70
- # that joins the values the way Rails joins a composite primary key, the
71
+ # that is the array of values, the way Rails exposes a composite primary
72
+ # key: `dom_id` joins them with `_` and GlobalID escapes each on its own,
73
+ # so a part may carry a dash or a colon. `primary_key` names the parts
74
+ # for GlobalID's arity check. Also defines the
71
75
  # `find` / `find_by(id:)` the channel and actions controller need, and,
72
76
  # as defaults you can replace, the readers, `initialize(company_id:,
73
77
  # user_id:)` and `from_key`. A key with the wrong arity resolves to nil
@@ -95,11 +99,12 @@ module ReactiveComponent
95
99
  attr_reader(*names)
96
100
 
97
101
  define_method(:initialize) { |**kwargs| names.each { |n| instance_variable_set(:"@#{n}", kwargs.fetch(n)) } }
98
- define_method(:id) { names.map { |n| public_send(n) }.join('-') }
102
+ define_method(:id) { names.map { |n| public_send(n) } }
103
+ define_singleton_method(:primary_key) { names }
99
104
 
100
105
  define_singleton_method(:from_key) { |**values| new(**values) }
101
106
  define_singleton_method(:find_by) do |id:|
102
- values = id.to_s.split('-')
107
+ values = Array(id)
103
108
  from_key(**names.zip(values).to_h) if values.size == names.size
104
109
  end
105
110
  define_singleton_method(:find) { |id| find_by(id: id) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactiveComponent
4
- VERSION = '0.8.5'
4
+ VERSION = '0.9.1'
5
5
  end
@@ -12,6 +12,12 @@ module ReactiveComponent
12
12
  params: nil, template_id: nil, skip_own_broadcasts: ReactiveComponent.skip_own_broadcasts)
13
13
  dom_id_val = component_class.dom_id_for(record)
14
14
 
15
+ # A notify class broadcasts no data, so an instance cannot go back to push.
16
+ if component_class.notify? && strategy.to_s != 'notify'
17
+ raise ArgumentError, "#{component_class.name} declares strategy: :notify; " \
18
+ "the wrapper cannot switch it to #{strategy.inspect}"
19
+ end
20
+
15
21
  # A notify component asks the server to re-render it, so it carries a
16
22
  # signed id of the record it is allowed to ask about. The raw id stays
17
23
  # out of it: the client already has one in its data.
@@ -39,8 +45,15 @@ module ReactiveComponent
39
45
 
40
46
  if client_state
41
47
  attrs << %(data-reactive-renderer-state-value="#{ERB::Util.html_escape(client_state.to_json)}")
42
- initial_data = component_class.build_data(record, **client_state.symbolize_keys)
43
- attrs << %(data-reactive-renderer-data-value="#{ERB::Util.html_escape(initial_data.to_json)}")
48
+
49
+ # A push wrapper renders from this payload until the first broadcast
50
+ # arrives. A notify one asks the server for its data instead, so the
51
+ # attribute would be the page a second time, both branches of every
52
+ # extracted expression included.
53
+ unless strategy.to_s == 'notify'
54
+ initial_data = component_class.build_data(record, **client_state.symbolize_keys)
55
+ attrs << %(data-reactive-renderer-data-value="#{ERB::Util.html_escape(initial_data.to_json)}")
56
+ end
44
57
  end
45
58
 
46
59
  attrs << %(data-reactive-renderer-strategy-value="#{strategy}") if strategy
@@ -38,6 +38,8 @@ module ReactiveComponent
38
38
  class_attribute :_client_state_fields, instance_writer: false, default: {}
39
39
  class_attribute :_subscribed_events, instance_writer: false, default: %i[create update destroy]
40
40
  class_attribute :_subscribed_fields, instance_writer: false, default: nil
41
+ class_attribute :_strategy, instance_writer: false, default: nil
42
+ class_attribute :_broadcast_later, instance_writer: false, default: true
41
43
  end
42
44
 
43
45
  def render_in(view_context, &)
@@ -60,6 +62,7 @@ module ReactiveComponent
60
62
  end
61
63
 
62
64
  extra_opts = respond_to?(:live_wrapper_options, true) ? live_wrapper_options : {}
65
+ extra_opts = { strategy: self.class._strategy }.merge(extra_opts) if self.class._strategy
63
66
 
64
67
  wrapped = ReactiveComponent::Wrapper.wrap(self.class, record, inner_html, stream: stream, client_state: client_state,
65
68
  **extra_opts)
@@ -116,6 +119,8 @@ module ReactiveComponent
116
119
  end
117
120
  private_class_method :raise_unsafe!
118
121
 
122
+ def self.signal_for(component_class, record) = { 'id' => record.id, 'dom_id' => component_class.dom_id_for(record) }
123
+
119
124
  def self.broadcast_for(component_class, record, action:)
120
125
  return unless component_class._subscribed_events.include?(action)
121
126
 
@@ -129,11 +134,11 @@ module ReactiveComponent
129
134
 
130
135
  case action
131
136
  when :update
132
- Channel.broadcast_data(stream, action: :update, data: component_class.build_data(record))
137
+ data = component_class.notify? ? signal_for(component_class, record) : component_class.build_data(record)
138
+
139
+ Channel.broadcast_data(stream, action: :update, data: data)
133
140
  when :destroy
134
- Channel.broadcast_data(stream, action: :destroy, data: {
135
- 'id' => record.id, 'dom_id' => component_class.dom_id_for(record)
136
- })
141
+ Channel.broadcast_data(stream, action: :destroy, data: signal_for(component_class, record))
137
142
  when :create
138
143
  target = config&.dig(:prepend_target)
139
144
  return unless target
@@ -145,11 +150,13 @@ module ReactiveComponent
145
150
  end
146
151
 
147
152
  class_methods do
148
- def subscribes_to(attr_name, class_name: nil, only: %i[create update destroy], fields: nil)
153
+ def subscribes_to(attr_name, class_name: nil, only: %i[create update destroy], fields: nil, strategy: nil, later: true)
149
154
  self._live_model_attr = attr_name.to_sym
150
155
  self._live_model_class_name = class_name || attr_name.to_s.classify
151
156
  self._subscribed_events = Array(only).map(&:to_sym)
152
157
  self._subscribed_fields = fields && Array(fields).map(&:to_s)
158
+ self._strategy = strategy&.to_sym
159
+ self._broadcast_later = later
153
160
 
154
161
  component_class = self
155
162
 
@@ -174,6 +181,8 @@ module ReactiveComponent
174
181
  end
175
182
  end
176
183
 
184
+ def notify? = _strategy == :notify
185
+
177
186
  def live_model_class
178
187
  _live_model_class_name&.constantize
179
188
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reactive_component
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.5
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Przemyslaw Lusar
@@ -106,6 +106,7 @@ files:
106
106
  - app/controllers/reactive_component/actions_controller.rb
107
107
  - app/javascript/reactive_component/controllers/reactive_renderer_controller.js
108
108
  - app/javascript/reactive_component/lib/reactive_renderer_utils.js
109
+ - app/jobs/reactive_component/broadcast_job.rb
109
110
  - config/importmap.rb
110
111
  - config/routes.rb
111
112
  - lib/reactive_component.rb