reactive_component 0.8.2 → 0.8.4

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: 57187b540320b1d5dd751025492c59fb67e76f5afaed973f2a4fff05883ed395
4
- data.tar.gz: 3f0df40aa790788a6f4e45d56607bb9a3c2bbcb7185a45e5c31b536b06da5acf
3
+ metadata.gz: 77acde881234cd7b6013ab8618efd2cbeb1ffd1a4c6232199517a9b14f4aa9ce
4
+ data.tar.gz: 1854830f220dd61bb49625a1a2a69e29d4e42ce96a40cf371586d3dd898bcbfc
5
5
  SHA512:
6
- metadata.gz: 32eec25b4d7094e9568fa77fdea0618d2bcbb7f6bf0aa139cbc883f1ff8a95c9fab74c4a023ae2bdab6304aae7a0d503ade97e809c005a8df036fadc73290ac7
7
- data.tar.gz: 81e833202a96c9b6be055db18d12c5c89612c446edc30e58d271a8222817bb1c8a8351b52db90754f31ed4290e9d42be86e1c35bfa439508e1f1092f0bcf1bc4
6
+ metadata.gz: 2721d5f6252603a600a4b297276a460cece0d4216303d40b6daa193b9d141a745c7024c347f6109463016f0f21d2beef01a083fa11d4fc3d294125973fa98630
7
+ data.tar.gz: 78a474176edff971af3979ca7c0b02d667c5be59e89f19b2dda28b770184f651799f0220eb2c65f5dfb47e938c4307253b52ad0bdf3df09a16a9679039bb832a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.4] - 2026-09-16
4
+
5
+ ### Added
6
+ - `key :company_id, :user_id` on a derived entity, for an entity keyed on a
7
+ tuple of values instead of one root record. It defines the readers, the
8
+ keyword initializer, a joined `id`, and `find` / `find_by(id:)`. A key of
9
+ the wrong arity resolves to nil instead of raising. The readers, the
10
+ initializer and `from_key` are defaults: an entity built from records
11
+ rather than ids replaces them, and `find` rebuilds it through `from_key`.
12
+ - `rebuilds_on ..., entities: ->(record) { ... }` fans one commit out to
13
+ every entity it affects, for when the entity is not reachable through a
14
+ single foreign key. Mutually exclusive with `via:`.
15
+ - Entities are `GlobalID::Identification`, so an entity used as a streamable
16
+ names its stream through `to_gid_param` like a model does.
17
+
18
+ ### Changed
19
+ - A notify component names its record to the server with a signed global id
20
+ instead of a raw one, and the channel resolves it with `locate_signed`
21
+ scoped to this gem. The stream check is unchanged. Upgrading re-renders
22
+ every page, so no client keeps the old payload.
23
+
24
+ ### Fixed
25
+ - The README linked to `.html` documentation URLs the site does not serve.
26
+
27
+ ## [0.8.3] - 2026-09-15
28
+
29
+ ### Added
30
+ - Notify mode guide. A notify component re-renders from the server instead of
31
+ the broadcast payload, and `filter_callback` can remove it from a filtered
32
+ list. See Notify Mode.
33
+ - Notify wrappers fill in the component name and record id, so
34
+ `live_wrapper_options` only needs `strategy: :notify`.
35
+ - `ReactiveComponent.skip_own_broadcasts`: the component that ran a
36
+ `live_action` ignores the broadcast it caused and keeps its optimistic
37
+ update. Off by default; override per component with `live_wrapper_options`.
38
+ - `subscribes_to ..., fields:` limits a component's update broadcasts to
39
+ changes in the listed columns, like `rebuilds_on ..., fields:` on entities.
40
+
41
+ ### Fixed
42
+ - Notify components only react to changes to their own record. Before, every
43
+ notify component on a shared stream requested a re-render on any change.
44
+ - A destroyed record's notify component is removed instead of staying on the
45
+ page.
46
+ - `filter_callback` runs on every notify request, not only for components
47
+ that declare client state.
48
+ - The configuration docs set `Channel` options inside `to_prepare`; the
49
+ previous initializer example raised `NameError`.
50
+
3
51
  ## [0.8.2] - 2026-09-15
4
52
 
5
53
  ### Added
@@ -21,34 +21,28 @@ 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
- if data['record_id'].present?
28
- if record_matches?(record, params)
29
- transmit({ 'action' => 'render', 'data' => component_class.build_data(record) })
30
- else
31
- transmit({ 'action' => 'remove', 'dom_id' => data['dom_id'] })
32
- end
33
- else
27
+ if record_matches?(record, params)
34
28
  client_state = params.slice(*component_class._client_state_fields.keys.map(&:to_s))
35
- result = component_class.build_data(record, **client_state.symbolize_keys)
36
- transmit({ 'action' => 'render', 'data' => result })
29
+ transmit({ 'action' => 'render', 'data' => component_class.build_data(record, **client_state.symbolize_keys) })
30
+ else
31
+ transmit({ 'action' => 'remove', 'dom_id' => data['dom_id'] })
37
32
  end
38
33
  end
39
34
 
40
35
  private
41
36
 
42
- # The component must be a reactive component and the record must broadcast
43
- # to the stream this subscriber verified: the same stream the wrapper
44
- # signed into the page. Anything else is a guess at a record id the client
45
- # was never shown.
46
- 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)
47
41
  component_class = data['component'].to_s.safe_constantize
48
42
  return unless component_class.is_a?(Class) && component_class.include?(ReactiveComponent)
49
43
 
50
- record = component_class.live_model_class.find_by(id: data['record_id'] || params.delete('record_id'))
51
- return unless record
44
+ record = locate_signed(data['sgid'])
45
+ return unless record.is_a?(component_class.live_model_class)
52
46
 
53
47
  stream = ReactiveComponent::Wrapper.find_stream_for(component_class, record)
54
48
  signed = Turbo::StreamsChannel.signed_stream_name(stream)
@@ -57,6 +51,14 @@ module ReactiveComponent
57
51
  [component_class, record]
58
52
  end
59
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
+
60
62
  def record_matches?(record, params)
61
63
  return true unless self.class.filter_callback
62
64
 
@@ -75,6 +77,7 @@ module ReactiveComponent
75
77
  stream_name = Turbo::StreamsChannel.verified_stream_name(signed)
76
78
 
77
79
  payload = { action: action, data: data }
80
+ payload[:request_id] = Turbo.current_request_id if Turbo.current_request_id
78
81
 
79
82
  if compress
80
83
  json = ActiveSupport::JSON.encode(payload)
@@ -68,8 +68,10 @@ 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
- fieldMap: { type: Object, default: {} }
73
+ fieldMap: { type: Object, default: {} },
74
+ skipOwnBroadcasts: { type: Boolean, default: false }
73
75
  }
74
76
 
75
77
  connect() {
@@ -123,7 +125,7 @@ export default class extends Controller {
123
125
  }
124
126
 
125
127
  handleMessage(message) {
126
- const route = routeMessage(message, this.element.id, this.strategyValue)
128
+ const route = routeMessage(message, this.element.id, this.strategyValue, this.ownRequestIds)
127
129
 
128
130
  switch (route.type) {
129
131
  case "render":
@@ -164,7 +166,7 @@ export default class extends Controller {
164
166
 
165
167
  sub.perform("request_update", {
166
168
  component: this.componentValue,
167
- record_id: this.dataValue?.id,
169
+ sgid: this.sgidValue,
168
170
  dom_id: this.element.id,
169
171
  params: this.paramsValue
170
172
  })
@@ -205,9 +207,7 @@ export default class extends Controller {
205
207
 
206
208
  fetch(this.actionUrlValue, {
207
209
  method: "POST",
208
- headers: {
209
- "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
210
- },
210
+ headers: this.actionHeaders(),
211
211
  body
212
212
  }).then(response => {
213
213
  if (!response.ok && rollbackData) {
@@ -229,6 +229,20 @@ export default class extends Controller {
229
229
  })
230
230
  }
231
231
 
232
+ // With skipOwnBroadcasts, tags the request so the broadcast it causes can be
233
+ // recognised and ignored. Turbo's header name, so turbo-rails tracks the id.
234
+ actionHeaders() {
235
+ const headers = { "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content }
236
+ if (!this.skipOwnBroadcastsValue) return headers
237
+
238
+ const requestId = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)
239
+ this.ownRequestIds ??= new Set()
240
+ this.ownRequestIds.add(requestId)
241
+ if (this.ownRequestIds.size > 20) this.ownRequestIds.delete(this.ownRequestIds.values().next().value)
242
+ headers["X-Turbo-Request-Id"] = requestId
243
+ return headers
244
+ }
245
+
232
246
  setState(event) {
233
247
  const updates = { ...event.params }
234
248
  delete updates.action
@@ -78,29 +78,29 @@ export function buildActionBody(actionName, actionToken, stimulusParams, formDat
78
78
  return { body, redirect }
79
79
  }
80
80
 
81
- export function routeMessage(message, elementId, strategy) {
81
+ export function routeMessage(message, elementId, strategy, ownRequestIds) {
82
82
  const { action, data } = message
83
83
 
84
+ if (action === "update" && ownRequestIds?.has(message.request_id)) {
85
+ return { type: "ignore" }
86
+ }
87
+
84
88
  if (action === "render" && data?.dom_id === elementId) {
85
89
  return { type: "render", data }
86
90
  }
87
91
 
88
- if (strategy === "notify" && (action === "update" || action === "destroy")) {
89
- return { type: "request_update" }
92
+ if (action === "destroy" && data?.dom_id === elementId) {
93
+ return { type: "destroy" }
90
94
  }
91
95
 
92
96
  if (action === "update" && data?.dom_id === elementId) {
93
- return { type: "update", data }
97
+ return strategy === "notify" ? { type: "request_update" } : { type: "update", data }
94
98
  }
95
99
 
96
100
  if (action === "remove" && (message.dom_id || data?.dom_id) === elementId) {
97
101
  return { type: "remove" }
98
102
  }
99
103
 
100
- if (action === "destroy" && data?.dom_id === elementId) {
101
- return { type: "destroy" }
102
- }
103
-
104
104
  return { type: "ignore" }
105
105
  }
106
106
 
@@ -36,8 +36,18 @@ module ReactiveComponent
36
36
 
37
37
  private
38
38
 
39
- def _broadcast_reactive_create = broadcast_reactive(:create)
40
- def _broadcast_reactive_update = broadcast_reactive(:update)
39
+ def _broadcast_reactive_create = broadcast_reactive(:create)
40
+
41
+ # Skips components subscribed to `fields:` when none of them changed.
42
+ def _broadcast_reactive_update
43
+ reactive_component_classes.each do |klass|
44
+ fields = klass._subscribed_fields
45
+ next if fields && !saved_changes.keys.intersect?(fields)
46
+
47
+ ReactiveComponent.broadcast_for(klass, self, action: :update)
48
+ end
49
+ end
50
+
41
51
  def _broadcast_reactive_destroy = broadcast_reactive(:destroy)
42
52
  end
43
53
  end
@@ -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
@@ -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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactiveComponent
4
- VERSION = '0.8.2'
4
+ VERSION = '0.8.4'
5
5
  end
@@ -2,12 +2,24 @@
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
- params: nil, template_id: nil)
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.
18
+ if strategy.to_s == 'notify'
19
+ component_name ||= component_class.name
20
+ sgid = record.to_sgid_param(for: SGID_PURPOSE)
21
+ end
22
+
11
23
  attrs = [
12
24
  %(id="#{dom_id_val}"),
13
25
  %(data-controller="reactive-renderer"),
@@ -33,8 +45,12 @@ module ReactiveComponent
33
45
 
34
46
  attrs << %(data-reactive-renderer-strategy-value="#{strategy}") if strategy
35
47
 
48
+ attrs << %(data-reactive-renderer-skip-own-broadcasts-value="true") if skip_own_broadcasts
49
+
36
50
  attrs << %(data-reactive-renderer-component-value="#{component_name}") if component_name
37
51
 
52
+ attrs << %(data-reactive-renderer-sgid-value="#{sgid}") if sgid
53
+
38
54
  attrs << %(data-reactive-renderer-params-value="#{ERB::Util.html_escape(params.to_json)}") if params
39
55
 
40
56
  if ReactiveComponent.debug
@@ -18,6 +18,8 @@ module ReactiveComponent
18
18
  mattr_accessor :renderer, default: nil
19
19
  # How long a live_action token minted into a page stays valid.
20
20
  mattr_accessor :action_token_ttl, default: 1.day
21
+ # Whether a component ignores the broadcast caused by its own live_action.
22
+ mattr_accessor :skip_own_broadcasts, default: false
21
23
 
22
24
  class Error < StandardError; end
23
25
 
@@ -34,6 +36,7 @@ module ReactiveComponent
34
36
  class_attribute :_broadcast_config, instance_writer: false
35
37
  class_attribute :_client_state_fields, instance_writer: false, default: {}
36
38
  class_attribute :_subscribed_events, instance_writer: false, default: %i[create update destroy]
39
+ class_attribute :_subscribed_fields, instance_writer: false, default: nil
37
40
  end
38
41
 
39
42
  def render_in(view_context, &)
@@ -141,10 +144,11 @@ module ReactiveComponent
141
144
  end
142
145
 
143
146
  class_methods do
144
- def subscribes_to(attr_name, class_name: nil, only: %i[create update destroy])
147
+ def subscribes_to(attr_name, class_name: nil, only: %i[create update destroy], fields: nil)
145
148
  self._live_model_attr = attr_name.to_sym
146
149
  self._live_model_class_name = class_name || attr_name.to_s.classify
147
150
  self._subscribed_events = Array(only).map(&:to_sym)
151
+ self._subscribed_fields = fields && Array(fields).map(&:to_s)
148
152
 
149
153
  component_class = self
150
154
 
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.2
4
+ version: 0.8.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Przemyslaw Lusar