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 +4 -4
- data/CHANGELOG.md +34 -0
- data/app/channels/reactive_component/channel.rb +5 -9
- data/app/javascript/reactive_component/controllers/reactive_renderer_controller.js +18 -5
- data/app/javascript/reactive_component/lib/reactive_renderer_utils.js +8 -8
- data/lib/reactive_component/broadcastable.rb +12 -2
- data/lib/reactive_component/version.rb +1 -1
- data/lib/reactive_component/wrapper.rb +8 -1
- data/lib/reactive_component.rb +5 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0a2f38ec2a6ac3161f8f5e0f9592aaa5241dfcecfb7992ca0c65f29e7f6ea84
|
|
4
|
+
data.tar.gz: 4a02ca05478a0675b8de4dde77336dab391fe397931ba7e952912455456b6915
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
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 (
|
|
89
|
-
return { type: "
|
|
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
|
|
40
|
-
|
|
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
|
|
@@ -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
|
data/lib/reactive_component.rb
CHANGED
|
@@ -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
|
|