reactive_component 0.8.4 → 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: 77acde881234cd7b6013ab8618efd2cbeb1ffd1a4c6232199517a9b14f4aa9ce
4
- data.tar.gz: 1854830f220dd61bb49625a1a2a69e29d4e42ce96a40cf371586d3dd898bcbfc
3
+ metadata.gz: 111726d46e0cac07d669edebc52f2d7e5e2ee05f5b94f7b766d2b400d647f196
4
+ data.tar.gz: d0cbaadaddb9b4b2c362ba2036e1f8a9efc5a67665a7673d9694134b1a439f64
5
5
  SHA512:
6
- metadata.gz: 2721d5f6252603a600a4b297276a460cece0d4216303d40b6daa193b9d141a745c7024c347f6109463016f0f21d2beef01a083fa11d4fc3d294125973fa98630
7
- data.tar.gz: 78a474176edff971af3979ca7c0b02d667c5be59e89f19b2dda28b770184f651799f0220eb2c65f5dfb47e938c4307253b52ad0bdf3df09a16a9679039bb832a
6
+ metadata.gz: b7df34cf75a7a5b83a6ab9d7c98995dcb5bed86b2a67269ab10ca00580af0b769d6aaba0ad37aa861740c4a7e2d242ae851dda561866a76858ae9ad022795f8d
7
+ data.tar.gz: 32e7b0d49b496c2fff61011751c13d66de283c0c661b6de755da3b2a8919739b6f04861f1de49f9e66c049147a30a960e9436403012ac48f954de0b4a1830d7d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
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
+
30
+ ## [0.8.5] - 2026-09-16
31
+
32
+ ### Added
33
+ - `morphHooks`: register Idiomorph callbacks that run inside every component's
34
+ morph, for flashing changed values and similar page-wide behaviour, without
35
+ subclassing the controller. See Hooking into the morph.
36
+
37
+ ### Fixed
38
+ - An app that does not eager load (development, test) broadcast nothing from a
39
+ process that had not rendered the subscribing component yet, such as a job or
40
+ a turbo-stream request. The engine now loads the components under
41
+ `app/components` that include `ReactiveComponent` on boot and after each
42
+ reload, and only those, so the rest of the app keeps lazy loading. Set
43
+ `ReactiveComponent::SubscriberLoader.paths` to look elsewhere.
44
+
3
45
  ## [0.8.4] - 2026-09-16
4
46
 
5
47
  ### Added
@@ -34,6 +34,32 @@ export async function decompress(base64) {
34
34
  return new Response(stream).json()
35
35
  }
36
36
 
37
+ // Idiomorph's callbacks, open for the app to hook into: push an object with any
38
+ // of them (a flash on changed numbers, a sound, a counter) and it runs for every
39
+ // component. A hook returning false cancels that node, as Idiomorph expects.
40
+ export const morphHooks = []
41
+
42
+ const HOOK_NAMES = [
43
+ 'beforeNodeAdded', 'afterNodeAdded', 'beforeNodeMorphed', 'afterNodeMorphed',
44
+ 'beforeNodeRemoved', 'afterNodeRemoved', 'beforeAttributeUpdated'
45
+ ]
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
+
58
+ const morphCallbacks = Object.fromEntries(HOOK_NAMES.map(name => [
59
+ name,
60
+ (...args) => [skipPermanent, ...morphHooks].reduce((ok, hook) => (hook[name]?.(...args) === false ? false : ok), true)
61
+ ]))
62
+
37
63
  export function morphElement(element, newHtml) {
38
64
  const parser = new DOMParser()
39
65
  const doc = parser.parseFromString(`<div>${newHtml}</div>`, "text/html")
@@ -42,7 +68,8 @@ export function morphElement(element, newHtml) {
42
68
  if (typeof Idiomorph !== "undefined") {
43
69
  Idiomorph.morph(element, newContent, {
44
70
  morphStyle: "innerHTML",
45
- ignoreActiveValue: true
71
+ ignoreActiveValue: true,
72
+ callbacks: morphCallbacks
46
73
  })
47
74
  } else {
48
75
  element.innerHTML = newContent.innerHTML
@@ -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
@@ -15,6 +16,16 @@ module ReactiveComponent
15
16
  ReactiveComponent::DataEvaluator.finalize!
16
17
  end
17
18
 
19
+ # A component wires its model up when the class loads, so an app that does
20
+ # not eager load (development, test) would broadcast nothing from a process
21
+ # that has not rendered that component yet: a job, or a turbo-stream
22
+ # request. Load the subscribing components on boot and after each reload.
23
+ initializer 'reactive_component.load_subscribers' do |app|
24
+ next if app.config.eager_load
25
+
26
+ app.config.to_prepare { ReactiveComponent::SubscriberLoader.load_all }
27
+ end
28
+
18
29
  initializer 'reactive_component.importmap', before: 'importmap' do |app|
19
30
  if defined?(Importmap)
20
31
  app.config.importmap.paths <<
@@ -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) }
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveComponent
4
+ # A component wires its model up when the class loads, so an app that does
5
+ # not eager load broadcasts nothing from a process that has not rendered that
6
+ # component yet. This loads the components that subscribe, and nothing else.
7
+ module SubscriberLoader
8
+ mattr_accessor :paths, default: ['app/components']
9
+
10
+ SUBSCRIBER = /include\s+ReactiveComponent\b/
11
+
12
+ module_function
13
+
14
+ def load_all
15
+ loader = Rails.autoloaders.main
16
+
17
+ files.each { |file| loader.cpath_expected_at(file)&.safe_constantize }
18
+ end
19
+
20
+ # The files under `paths` that include the concern, read rather than
21
+ # loaded, so an app keeps lazy loading for everything else.
22
+ def files
23
+ paths.flat_map do |path|
24
+ dir = Rails.root.join(path)
25
+ next [] unless Dir.exist?(dir)
26
+
27
+ Dir.glob(dir.join('**', '*.rb')).select { |file| File.read(file).match?(SUBSCRIBER) }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactiveComponent
4
- VERSION = '0.8.4'
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.
@@ -8,6 +8,7 @@ require_relative 'reactive_component/compiler'
8
8
  require_relative 'reactive_component/data_evaluator'
9
9
  require_relative 'reactive_component/wrapper'
10
10
  require_relative 'reactive_component/broadcastable'
11
+ require_relative 'reactive_component/subscriber_loader'
11
12
  require_relative 'reactive_component/entity'
12
13
  require_relative 'reactive_component/engine' if defined?(Rails::Engine)
13
14
 
@@ -37,6 +38,8 @@ module ReactiveComponent
37
38
  class_attribute :_client_state_fields, instance_writer: false, default: {}
38
39
  class_attribute :_subscribed_events, instance_writer: false, default: %i[create update destroy]
39
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
40
43
  end
41
44
 
42
45
  def render_in(view_context, &)
@@ -59,6 +62,7 @@ module ReactiveComponent
59
62
  end
60
63
 
61
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
62
66
 
63
67
  wrapped = ReactiveComponent::Wrapper.wrap(self.class, record, inner_html, stream: stream, client_state: client_state,
64
68
  **extra_opts)
@@ -115,6 +119,8 @@ module ReactiveComponent
115
119
  end
116
120
  private_class_method :raise_unsafe!
117
121
 
122
+ def self.signal_for(component_class, record) = { 'id' => record.id, 'dom_id' => component_class.dom_id_for(record) }
123
+
118
124
  def self.broadcast_for(component_class, record, action:)
119
125
  return unless component_class._subscribed_events.include?(action)
120
126
 
@@ -128,11 +134,11 @@ module ReactiveComponent
128
134
 
129
135
  case action
130
136
  when :update
131
- 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)
132
140
  when :destroy
133
- Channel.broadcast_data(stream, action: :destroy, data: {
134
- 'id' => record.id, 'dom_id' => component_class.dom_id_for(record)
135
- })
141
+ Channel.broadcast_data(stream, action: :destroy, data: signal_for(component_class, record))
136
142
  when :create
137
143
  target = config&.dig(:prepend_target)
138
144
  return unless target
@@ -144,11 +150,13 @@ module ReactiveComponent
144
150
  end
145
151
 
146
152
  class_methods do
147
- 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)
148
154
  self._live_model_attr = attr_name.to_sym
149
155
  self._live_model_class_name = class_name || attr_name.to_s.classify
150
156
  self._subscribed_events = Array(only).map(&:to_sym)
151
157
  self._subscribed_fields = fields && Array(fields).map(&:to_s)
158
+ self._strategy = strategy&.to_sym
159
+ self._broadcast_later = later
152
160
 
153
161
  component_class = self
154
162
 
@@ -173,6 +181,8 @@ module ReactiveComponent
173
181
  end
174
182
  end
175
183
 
184
+ def notify? = _strategy == :notify
185
+
176
186
  def live_model_class
177
187
  _live_model_class_name&.constantize
178
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.4
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
@@ -115,6 +116,7 @@ files:
115
116
  - lib/reactive_component/engine.rb
116
117
  - lib/reactive_component/entity.rb
117
118
  - lib/reactive_component/erubi.rb
119
+ - lib/reactive_component/subscriber_loader.rb
118
120
  - lib/reactive_component/transpiler.rb
119
121
  - lib/reactive_component/version.rb
120
122
  - lib/reactive_component/wrapper.rb