reactive_component 0.8.3 → 0.8.5

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: c0a2f38ec2a6ac3161f8f5e0f9592aaa5241dfcecfb7992ca0c65f29e7f6ea84
4
- data.tar.gz: 4a02ca05478a0675b8de4dde77336dab391fe397931ba7e952912455456b6915
3
+ metadata.gz: 5ec78650feb0396bbe85c2adc0994209dc4d1ab91a6cf0ca49d9692b17e0b316
4
+ data.tar.gz: c2d54ef039a0ea502c593a5eb1e173f509955e3e37b0f6eea1cd78c05ed365d5
5
5
  SHA512:
6
- metadata.gz: 5029099159c30a18cbb2b354d913df8aeaa2003481d5a70864503e6f9cbc50638ccb9a7d7bf5258a3b2a6e879806be9c02ed1f86bf7f972781cc51b1cb846413
7
- data.tar.gz: 87e761fb987f437b48787f5aa8e3b13009ef3025ba0ef9c080be39ce4b3e06a1b7d4ae63113729b98cd9c79391d34c91e8770b9623ba2998a3c73f3bea2ade83
6
+ metadata.gz: caf86cb2646eea037defb27405b486555e70377351c6fe3a61b7d1b00aa33cf330d6ace77a42936e9c93c2bbb992026d0a5a2138137ac6591ff6efd1a45f6c21
7
+ data.tar.gz: fc02fd8569b770ff6b6ed6583cdfbc4fb3dfb2630768eaa7d4f095ce755cc7ecf7617721fa542ca5f76a7a149984cbd5549e4f959911e67b739f96a4e2aa3a22
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.5] - 2026-09-16
4
+
5
+ ### Added
6
+ - `morphHooks`: register Idiomorph callbacks that run inside every component's
7
+ morph, for flashing changed values and similar page-wide behaviour, without
8
+ subclassing the controller. See Hooking into the morph.
9
+
10
+ ### Fixed
11
+ - An app that does not eager load (development, test) broadcast nothing from a
12
+ process that had not rendered the subscribing component yet, such as a job or
13
+ a turbo-stream request. The engine now loads the components under
14
+ `app/components` that include `ReactiveComponent` on boot and after each
15
+ reload, and only those, so the rest of the app keeps lazy loading. Set
16
+ `ReactiveComponent::SubscriberLoader.paths` to look elsewhere.
17
+
18
+ ## [0.8.4] - 2026-09-16
19
+
20
+ ### Added
21
+ - `key :company_id, :user_id` on a derived entity, for an entity keyed on a
22
+ tuple of values instead of one root record. It defines the readers, the
23
+ keyword initializer, a joined `id`, and `find` / `find_by(id:)`. A key of
24
+ the wrong arity resolves to nil instead of raising. The readers, the
25
+ initializer and `from_key` are defaults: an entity built from records
26
+ rather than ids replaces them, and `find` rebuilds it through `from_key`.
27
+ - `rebuilds_on ..., entities: ->(record) { ... }` fans one commit out to
28
+ every entity it affects, for when the entity is not reachable through a
29
+ single foreign key. Mutually exclusive with `via:`.
30
+ - Entities are `GlobalID::Identification`, so an entity used as a streamable
31
+ names its stream through `to_gid_param` like a model does.
32
+
33
+ ### Changed
34
+ - A notify component names its record to the server with a signed global id
35
+ instead of a raw one, and the channel resolves it with `locate_signed`
36
+ scoped to this gem. The stream check is unchanged. Upgrading re-renders
37
+ every page, so no client keeps the old payload.
38
+
39
+ ### Fixed
40
+ - The README linked to `.html` documentation URLs the site does not serve.
41
+
3
42
  ## [0.8.3] - 2026-09-15
4
43
 
5
44
  ### Added
@@ -21,7 +21,7 @@ module ReactiveComponent
21
21
 
22
22
  def request_update(data)
23
23
  params = data['params'] || {}
24
- component_class, record = subscribed_component_and_record(data, params)
24
+ component_class, record = subscribed_component_and_record(data)
25
25
  return unless record
26
26
 
27
27
  if record_matches?(record, params)
@@ -34,16 +34,15 @@ module ReactiveComponent
34
34
 
35
35
  private
36
36
 
37
- # The component must be a reactive component and the record must broadcast
38
- # to the stream this subscriber verified: the same stream the wrapper
39
- # signed into the page. Anything else is a guess at a record id the client
40
- # was never shown.
41
- def subscribed_component_and_record(data, params)
37
+ # The component must be a reactive component, the record must come from a
38
+ # signed id this gem minted, and it must broadcast to the stream this
39
+ # subscriber verified: the same stream the wrapper signed into the page.
40
+ def subscribed_component_and_record(data)
42
41
  component_class = data['component'].to_s.safe_constantize
43
42
  return unless component_class.is_a?(Class) && component_class.include?(ReactiveComponent)
44
43
 
45
- record = component_class.live_model_class.find_by(id: data['record_id'] || params.delete('record_id'))
46
- return unless record
44
+ record = locate_signed(data['sgid'])
45
+ return unless record.is_a?(component_class.live_model_class)
47
46
 
48
47
  stream = ReactiveComponent::Wrapper.find_stream_for(component_class, record)
49
48
  signed = Turbo::StreamsChannel.signed_stream_name(stream)
@@ -52,6 +51,14 @@ module ReactiveComponent
52
51
  [component_class, record]
53
52
  end
54
53
 
54
+ # An id we did not sign, one signed for another purpose, an expired one,
55
+ # or one pointing at a row that is gone: all of them are a miss.
56
+ def locate_signed(sgid)
57
+ GlobalID::Locator.locate_signed(sgid, for: ReactiveComponent::Wrapper::SGID_PURPOSE)
58
+ rescue ActiveRecord::RecordNotFound
59
+ nil
60
+ end
61
+
55
62
  def record_matches?(record, params)
56
63
  return true unless self.class.filter_callback
57
64
 
@@ -68,6 +68,7 @@ export default class extends Controller {
68
68
  data: { type: Object, default: {} },
69
69
  strategy: { type: String, default: "push" },
70
70
  component: { type: String, default: "" },
71
+ sgid: { type: String, default: "" },
71
72
  params: { type: Object, default: {} },
72
73
  fieldMap: { type: Object, default: {} },
73
74
  skipOwnBroadcasts: { type: Boolean, default: false }
@@ -165,7 +166,7 @@ export default class extends Controller {
165
166
 
166
167
  sub.perform("request_update", {
167
168
  component: this.componentValue,
168
- record_id: this.dataValue?.id,
169
+ sgid: this.sgidValue,
169
170
  dom_id: this.element.id,
170
171
  params: this.paramsValue
171
172
  })
@@ -34,6 +34,21 @@ 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
+ const morphCallbacks = Object.fromEntries(HOOK_NAMES.map(name => [
48
+ name,
49
+ (...args) => morphHooks.reduce((ok, hook) => (hook[name]?.(...args) === false ? false : ok), true)
50
+ ]))
51
+
37
52
  export function morphElement(element, newHtml) {
38
53
  const parser = new DOMParser()
39
54
  const doc = parser.parseFromString(`<div>${newHtml}</div>`, "text/html")
@@ -42,7 +57,8 @@ export function morphElement(element, newHtml) {
42
57
  if (typeof Idiomorph !== "undefined") {
43
58
  Idiomorph.morph(element, newContent, {
44
59
  morphStyle: "innerHTML",
45
- ignoreActiveValue: true
60
+ ignoreActiveValue: true,
61
+ callbacks: morphCallbacks
46
62
  })
47
63
  } else {
48
64
  element.innerHTML = newContent.innerHTML
@@ -2,6 +2,11 @@
2
2
 
3
3
  # This file should be required from the main lib/reactive_component.rb module file.
4
4
 
5
+ # Entities and signed stream ids are GlobalIDs. ActiveJob pulls this railtie in
6
+ # for its own arguments; an app without ActiveJob would otherwise have no
7
+ # `GlobalID.app` and could not create one.
8
+ require 'global_id/railtie'
9
+
5
10
  module ReactiveComponent
6
11
  class Engine < ::Rails::Engine
7
12
  isolate_namespace ReactiveComponent
@@ -10,6 +15,16 @@ module ReactiveComponent
10
15
  ReactiveComponent::DataEvaluator.finalize!
11
16
  end
12
17
 
18
+ # A component wires its model up when the class loads, so an app that does
19
+ # not eager load (development, test) would broadcast nothing from a process
20
+ # that has not rendered that component yet: a job, or a turbo-stream
21
+ # request. Load the subscribing components on boot and after each reload.
22
+ initializer 'reactive_component.load_subscribers' do |app|
23
+ next if app.config.eager_load
24
+
25
+ app.config.to_prepare { ReactiveComponent::SubscriberLoader.load_all }
26
+ end
27
+
13
28
  initializer 'reactive_component.importmap', before: 'importmap' do |app|
14
29
  if defined?(Importmap)
15
30
  app.config.importmap.paths <<
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_model'
4
+ require 'global_id'
4
5
 
5
6
  module ReactiveComponent
6
7
  # A derived entity: a plain object built on top of several ActiveRecord
@@ -19,6 +20,16 @@ module ReactiveComponent
19
20
  # def total = order.payments.sum(:amount)
20
21
  # end
21
22
  #
23
+ # An entity that is not keyed on one record uses `key` instead of `root`:
24
+ #
25
+ # class DueCount
26
+ # include ReactiveComponent::Entity
27
+ #
28
+ # key :company_id, :user_id
29
+ # rebuilds_on Task, fields: %i[due_on],
30
+ # entities: ->(task) { new(company_id: task.company_id, user_id: task.assignee_id) }
31
+ # end
32
+ #
22
33
  # class OrderSummaryComponent < ApplicationComponent
23
34
  # include ReactiveComponent
24
35
  # subscribes_to :summary, class_name: "OrderSummary"
@@ -27,6 +38,7 @@ module ReactiveComponent
27
38
  extend ActiveSupport::Concern
28
39
  include ActiveModel::Model
29
40
  include Broadcastable
41
+ include GlobalID::Identification
30
42
 
31
43
  included do
32
44
  class_attribute :root_name, instance_writer: false
@@ -34,8 +46,9 @@ module ReactiveComponent
34
46
 
35
47
  def persisted? = true
36
48
 
37
- # Default stream when the component declares no `broadcasts stream:`.
38
- # A bare id would collide with every other entity sharing it.
49
+ # Turbo's `stream_name_from` prefers `to_gid_param`, so an entity names its
50
+ # own stream. `to_param` stays as the fallback for when `GlobalID.app`
51
+ # isn't set: a bare id would collide with every other entity sharing it.
39
52
  def to_param = "#{self.class.model_name.param_key}/#{id}"
40
53
 
41
54
  class_methods do
@@ -53,20 +66,65 @@ module ReactiveComponent
53
66
  define_singleton_method(:find_by) { |id:| (record = class_name.constantize.find_by(id: id)) && new(name => record) }
54
67
  end
55
68
 
69
+ # 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
+ # `find` / `find_by(id:)` the channel and actions controller need, and,
72
+ # as defaults you can replace, the readers, `initialize(company_id:,
73
+ # user_id:)` and `from_key`. A key with the wrong arity resolves to nil
74
+ # rather than raising.
75
+ #
76
+ # `find` turns an id back into an entity through `from_key`, so an
77
+ # entity that would rather be built from records than from ids defines
78
+ # its own `initialize` and its own `from_key` to match:
79
+ #
80
+ # key :company_id, :user_id
81
+ #
82
+ # def initialize(company, user)
83
+ # @company = company
84
+ # @user = user
85
+ # end
86
+ #
87
+ # delegate :id, to: :company, prefix: true
88
+ # delegate :id, to: :user, prefix: true
89
+ #
90
+ # def self.from_key(company_id:, user_id:)
91
+ # new(Company.find(company_id), User.find(user_id))
92
+ # end
93
+ def key(*names)
94
+ names = names.map(&:to_sym)
95
+ attr_reader(*names)
96
+
97
+ 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('-') }
99
+
100
+ define_singleton_method(:from_key) { |**values| new(**values) }
101
+ define_singleton_method(:find_by) do |id:|
102
+ values = id.to_s.split('-')
103
+ from_key(**names.zip(values).to_h) if values.size == names.size
104
+ end
105
+ define_singleton_method(:find) { |id| find_by(id: id) }
106
+ end
107
+
56
108
  # Rebroadcast the entity after `model` commits. `via:` is the foreign key
57
109
  # on `model` pointing at the root; omit it when `model` is the root.
58
110
  # `fields:` narrows updates to the listed columns; create and destroy
59
111
  # always count.
60
- def rebuilds_on(model, via: nil, fields: nil)
112
+ # `entities:` is the alternative to `via:` when one commit touches more
113
+ # than one entity, or when the entity is not reachable through a single
114
+ # foreign key: it takes the record and returns the entities to rebuild.
115
+ def rebuilds_on(model, via: nil, fields: nil, entities: nil)
116
+ raise ArgumentError, 'rebuilds_on takes either via: or entities:, not both' if via && entities
117
+
61
118
  entity = self
62
119
  fields = fields&.map(&:to_s)
63
120
 
64
- model.after_commit { entity.rebuild_from(self, via: via, fields: fields) }
121
+ model.after_commit { entity.rebuild_from(self, via: via, fields: fields, entities: entities) }
65
122
  end
66
123
 
67
- def rebuild_from(record, via:, fields:)
124
+ def rebuild_from(record, via:, fields:, entities: nil)
68
125
  action = commit_action(record)
69
- return if action == :update && fields && !record.saved_changes.keys.intersect?(fields)
126
+ return if unlisted_change?(record, action, fields)
127
+ return Array(entities.call(record)).each { |e| e.broadcast_reactive(:update) } if entities
70
128
  return find_by(id: record.public_send(via))&.broadcast_reactive(:update) if via
71
129
 
72
130
  new(root_name => record).broadcast_reactive(action)
@@ -74,6 +132,10 @@ module ReactiveComponent
74
132
 
75
133
  private
76
134
 
135
+ def unlisted_change?(record, action, fields)
136
+ action == :update && fields && !record.saved_changes.keys.intersect?(fields)
137
+ end
138
+
77
139
  def commit_action(record)
78
140
  return :destroy if record.destroyed?
79
141
  return :create if record.previously_new_record?
@@ -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.3'
4
+ VERSION = '0.8.5'
5
5
  end
@@ -2,15 +2,22 @@
2
2
 
3
3
  module ReactiveComponent
4
4
  module Wrapper
5
+ # Scopes the signed ids a notify wrapper puts on the page: a signed id
6
+ # minted elsewhere in the app cannot be replayed at our channel.
7
+ SGID_PURPOSE = 'reactive_component'
8
+
5
9
  module_function
6
10
 
7
11
  def wrap(component_class, record, inner_html, stream: nil, client_state: nil, strategy: nil, component_name: nil,
8
12
  params: nil, template_id: nil, skip_own_broadcasts: ReactiveComponent.skip_own_broadcasts)
9
13
  dom_id_val = component_class.dom_id_for(record)
10
14
 
15
+ # A notify component asks the server to re-render it, so it carries a
16
+ # signed id of the record it is allowed to ask about. The raw id stays
17
+ # out of it: the client already has one in its data.
11
18
  if strategy.to_s == 'notify'
12
19
  component_name ||= component_class.name
13
- params = { record_id: record.id }.merge(params || {})
20
+ sgid = record.to_sgid_param(for: SGID_PURPOSE)
14
21
  end
15
22
 
16
23
  attrs = [
@@ -42,6 +49,8 @@ module ReactiveComponent
42
49
 
43
50
  attrs << %(data-reactive-renderer-component-value="#{component_name}") if component_name
44
51
 
52
+ attrs << %(data-reactive-renderer-sgid-value="#{sgid}") if sgid
53
+
45
54
  attrs << %(data-reactive-renderer-params-value="#{ERB::Util.html_escape(params.to_json)}") if params
46
55
 
47
56
  if ReactiveComponent.debug
@@ -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
 
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.3
4
+ version: 0.8.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Przemyslaw Lusar
@@ -115,6 +115,7 @@ files:
115
115
  - lib/reactive_component/engine.rb
116
116
  - lib/reactive_component/entity.rb
117
117
  - lib/reactive_component/erubi.rb
118
+ - lib/reactive_component/subscriber_loader.rb
118
119
  - lib/reactive_component/transpiler.rb
119
120
  - lib/reactive_component/version.rb
120
121
  - lib/reactive_component/wrapper.rb