reactive_component 0.8.5 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ec78650feb0396bbe85c2adc0994209dc4d1ab91a6cf0ca49d9692b17e0b316
4
- data.tar.gz: c2d54ef039a0ea502c593a5eb1e173f509955e3e37b0f6eea1cd78c05ed365d5
3
+ metadata.gz: 111726d46e0cac07d669edebc52f2d7e5e2ee05f5b94f7b766d2b400d647f196
4
+ data.tar.gz: d0cbaadaddb9b4b2c362ba2036e1f8a9efc5a67665a7673d9694134b1a439f64
5
5
  SHA512:
6
- metadata.gz: caf86cb2646eea037defb27405b486555e70377351c6fe3a61b7d1b00aa33cf330d6ace77a42936e9c93c2bbb992026d0a5a2138137ac6591ff6efd1a45f6c21
7
- data.tar.gz: fc02fd8569b770ff6b6ed6583cdfbc4fb3dfb2630768eaa7d4f095ce755cc7ecf7617721fa542ca5f76a7a149984cbd5549e4f959911e67b739f96a4e2aa3a22
6
+ metadata.gz: b7df34cf75a7a5b83a6ab9d7c98995dcb5bed86b2a67269ab10ca00580af0b769d6aaba0ad37aa861740c4a7e2d242ae851dda561866a76858ae9ad022795f8d
7
+ data.tar.gz: 32e7b0d49b496c2fff61011751c13d66de283c0c661b6de755da3b2a8919739b6f04861f1de49f9e66c049147a30a960e9436403012ac48f954de0b4a1830d7d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0] - 2026-09-17
4
+
5
+ ### Added
6
+ - `subscribes_to ..., strategy: :notify` declares notify mode for every
7
+ instance. A broadcast then sends the bare signal instead of building a
8
+ payload no notify client reads, and the wrapper needs no
9
+ `live_wrapper_options`. An instance cannot switch such a class back to push.
10
+
11
+ ### Changed
12
+ - A broadcast now runs in an Active Job (`ReactiveComponent::BroadcastJob`)
13
+ instead of inside the request that committed the record, so rendering a
14
+ payload no longer holds the response. `subscribes_to ..., later: false`
15
+ keeps it inline. A destroy always broadcasts inline. The request id rides
16
+ along, so `skip_own_broadcasts` keeps working.
17
+ - A morph leaves `[data-turbo-permanent]` elements alone, as Turbo's own morph
18
+ does. An open menu marks itself permanent while it is open, and an update
19
+ underneath it no longer closes it.
20
+ - A `key` entity's dom id joins its parts with `_` instead of `-`
21
+ (`due_count_1_2`) and its gid with `/`. A page rendered before the deploy
22
+ routes broadcasts by the old id until it reloads.
23
+
24
+ ### Fixed
25
+ - A `key` entity joined its id on `-`, so a part carrying a dash, a UUID for
26
+ instance, never split back: every lookup missed and a notify component
27
+ never re-rendered. The id is now the array of parts, the way Rails exposes
28
+ a composite primary key, and GlobalID and `dom_id` encode it themselves.
29
+
3
30
  ## [0.8.5] - 2026-09-16
4
31
 
5
32
  ### Added
@@ -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.0'
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.
@@ -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.0
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