reactive_component 0.8.1 → 0.8.3

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: 7340ef31f70f617e46d0793d44813938ffe8ed4d59b7f5c43f1623248266f57b
4
- data.tar.gz: 2b653233060ad294e0f5cd472c95d48fecb4d7ccc869a49f7fcb255dffe38ce2
3
+ metadata.gz: c0a2f38ec2a6ac3161f8f5e0f9592aaa5241dfcecfb7992ca0c65f29e7f6ea84
4
+ data.tar.gz: 4a02ca05478a0675b8de4dde77336dab391fe397931ba7e952912455456b6915
5
5
  SHA512:
6
- metadata.gz: 0b4bada796415fca513b5826e53d6942b20f5dc1e87c5b9bd243ff6fe80bfa6b6d018e1a2cfefd4692d24ec8cb02f61ff23cc360cf4d83098a6142936d59885e
7
- data.tar.gz: d07195fc8256d20daa9d658ee184ebcb99ce3243e38936bc27ed9b63a0139b50a058a91d3dbed9d0d1ffa5fa2c40f51919b2ec24ddce1f475a934d1e2db94ba5
6
+ metadata.gz: 5029099159c30a18cbb2b354d913df8aeaa2003481d5a70864503e6f9cbc50638ccb9a7d7bf5258a3b2a6e879806be9c02ed1f86bf7f972781cc51b1cb846413
7
+ data.tar.gz: 87e761fb987f437b48787f5aa8e3b13009ef3025ba0ef9c080be39ce4b3e06a1b7d4ae63113729b98cd9c79391d34c91e8770b9623ba2998a3c73f3bea2ade83
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.3] - 2026-09-15
4
+
5
+ ### Added
6
+ - Notify mode guide. A notify component re-renders from the server instead of
7
+ the broadcast payload, and `filter_callback` can remove it from a filtered
8
+ list. See Notify Mode.
9
+ - Notify wrappers fill in the component name and record id, so
10
+ `live_wrapper_options` only needs `strategy: :notify`.
11
+ - `ReactiveComponent.skip_own_broadcasts`: the component that ran a
12
+ `live_action` ignores the broadcast it caused and keeps its optimistic
13
+ update. Off by default; override per component with `live_wrapper_options`.
14
+ - `subscribes_to ..., fields:` limits a component's update broadcasts to
15
+ changes in the listed columns, like `rebuilds_on ..., fields:` on entities.
16
+
17
+ ### Fixed
18
+ - Notify components only react to changes to their own record. Before, every
19
+ notify component on a shared stream requested a re-render on any change.
20
+ - A destroyed record's notify component is removed instead of staying on the
21
+ page.
22
+ - `filter_callback` runs on every notify request, not only for components
23
+ that declare client state.
24
+ - The configuration docs set `Channel` options inside `to_prepare`; the
25
+ previous initializer example raised `NameError`.
26
+
27
+ ## [0.8.2] - 2026-09-15
28
+
29
+ ### Added
30
+ - The Stimulus controller is published to npm as
31
+ `@lluzak/reactive_component` for apps using esbuild, Vite, Rspack, or
32
+ another bundler. Install it under the `reactive_component` alias. See
33
+ Other bundlers in the Installation guide.
34
+ - Pushing a `vX.Y.Z` tag builds the gem, publishes the JS package, and
35
+ creates the GitHub release.
36
+
3
37
  ## [0.8.1] - 2026-09-11
4
38
 
5
39
  ### Added
@@ -24,16 +24,11 @@ module ReactiveComponent
24
24
  component_class, record = subscribed_component_and_record(data, params)
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
 
@@ -75,6 +70,7 @@ module ReactiveComponent
75
70
  stream_name = Turbo::StreamsChannel.verified_stream_name(signed)
76
71
 
77
72
  payload = { action: action, data: data }
73
+ payload[:request_id] = Turbo.current_request_id if Turbo.current_request_id
78
74
 
79
75
  if compress
80
76
  json = ActiveSupport::JSON.encode(payload)
@@ -69,7 +69,8 @@ export default class extends Controller {
69
69
  strategy: { type: String, default: "push" },
70
70
  component: { type: String, default: "" },
71
71
  params: { type: Object, default: {} },
72
- fieldMap: { type: Object, default: {} }
72
+ fieldMap: { type: Object, default: {} },
73
+ skipOwnBroadcasts: { type: Boolean, default: false }
73
74
  }
74
75
 
75
76
  connect() {
@@ -123,7 +124,7 @@ export default class extends Controller {
123
124
  }
124
125
 
125
126
  handleMessage(message) {
126
- const route = routeMessage(message, this.element.id, this.strategyValue)
127
+ const route = routeMessage(message, this.element.id, this.strategyValue, this.ownRequestIds)
127
128
 
128
129
  switch (route.type) {
129
130
  case "render":
@@ -205,9 +206,7 @@ export default class extends Controller {
205
206
 
206
207
  fetch(this.actionUrlValue, {
207
208
  method: "POST",
208
- headers: {
209
- "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
210
- },
209
+ headers: this.actionHeaders(),
211
210
  body
212
211
  }).then(response => {
213
212
  if (!response.ok && rollbackData) {
@@ -229,6 +228,20 @@ export default class extends Controller {
229
228
  })
230
229
  }
231
230
 
231
+ // With skipOwnBroadcasts, tags the request so the broadcast it causes can be
232
+ // recognised and ignored. Turbo's header name, so turbo-rails tracks the id.
233
+ actionHeaders() {
234
+ const headers = { "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content }
235
+ if (!this.skipOwnBroadcastsValue) return headers
236
+
237
+ const requestId = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)
238
+ this.ownRequestIds ??= new Set()
239
+ this.ownRequestIds.add(requestId)
240
+ if (this.ownRequestIds.size > 20) this.ownRequestIds.delete(this.ownRequestIds.values().next().value)
241
+ headers["X-Turbo-Request-Id"] = requestId
242
+ return headers
243
+ }
244
+
232
245
  setState(event) {
233
246
  const updates = { ...event.params }
234
247
  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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactiveComponent
4
- VERSION = '0.8.1'
4
+ VERSION = '0.8.3'
5
5
  end
@@ -5,9 +5,14 @@ module ReactiveComponent
5
5
  module_function
6
6
 
7
7
  def wrap(component_class, record, inner_html, stream: nil, client_state: nil, strategy: nil, component_name: nil,
8
- params: nil, template_id: nil)
8
+ params: nil, template_id: nil, skip_own_broadcasts: ReactiveComponent.skip_own_broadcasts)
9
9
  dom_id_val = component_class.dom_id_for(record)
10
10
 
11
+ if strategy.to_s == 'notify'
12
+ component_name ||= component_class.name
13
+ params = { record_id: record.id }.merge(params || {})
14
+ end
15
+
11
16
  attrs = [
12
17
  %(id="#{dom_id_val}"),
13
18
  %(data-controller="reactive-renderer"),
@@ -33,6 +38,8 @@ module ReactiveComponent
33
38
 
34
39
  attrs << %(data-reactive-renderer-strategy-value="#{strategy}") if strategy
35
40
 
41
+ attrs << %(data-reactive-renderer-skip-own-broadcasts-value="true") if skip_own_broadcasts
42
+
36
43
  attrs << %(data-reactive-renderer-component-value="#{component_name}") if component_name
37
44
 
38
45
  attrs << %(data-reactive-renderer-params-value="#{ERB::Util.html_escape(params.to_json)}") if params
@@ -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.1
4
+ version: 0.8.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Przemyslaw Lusar