reactive_component 0.8.5 → 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 +4 -4
- data/CHANGELOG.md +27 -0
- data/app/javascript/reactive_component/lib/reactive_renderer_utils.js +12 -1
- data/app/jobs/reactive_component/broadcast_job.rb +17 -0
- data/lib/reactive_component/broadcastable.rb +12 -2
- data/lib/reactive_component/engine.rb +1 -0
- data/lib/reactive_component/entity.rb +9 -4
- data/lib/reactive_component/version.rb +1 -1
- data/lib/reactive_component/wrapper.rb +6 -0
- data/lib/reactive_component.rb +14 -5
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 111726d46e0cac07d669edebc52f2d7e5e2ee05f5b94f7b766d2b400d647f196
|
|
4
|
+
data.tar.gz: d0cbaadaddb9b4b2c362ba2036e1f8a9efc5a67665a7673d9694134b1a439f64
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b7df34cf75a7a5b83a6ab9d7c98995dcb5bed86b2a67269ab10ca00580af0b769d6aaba0ad37aa861740c4a7e2d242ae851dda561866a76858ae9ad022795f8d
|
|
7
|
+
data.tar.gz: 32e7b0d49b496c2fff61011751c13d66de283c0c661b6de755da3b2a8919739b6f04861f1de49f9e66c049147a30a960e9436403012ac48f954de0b4a1830d7d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
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
|
+
|
|
3
30
|
## [0.8.5] - 2026-09-16
|
|
4
31
|
|
|
5
32
|
### Added
|
|
@@ -44,9 +44,20 @@ const HOOK_NAMES = [
|
|
|
44
44
|
'beforeNodeRemoved', 'afterNodeRemoved', 'beforeAttributeUpdated'
|
|
45
45
|
]
|
|
46
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
|
+
|
|
47
58
|
const morphCallbacks = Object.fromEntries(HOOK_NAMES.map(name => [
|
|
48
59
|
name,
|
|
49
|
-
(...args) => morphHooks.reduce((ok, hook) => (hook[name]?.(...args) === false ? false : ok), true)
|
|
60
|
+
(...args) => [skipPermanent, ...morphHooks].reduce((ok, hook) => (hook[name]?.(...args) === false ? false : ok), true)
|
|
50
61
|
]))
|
|
51
62
|
|
|
52
63
|
export function morphElement(element, newHtml) {
|
|
@@ -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|
|
|
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
|
-
|
|
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
|
|
@@ -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}/#{
|
|
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
|
|
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) }
|
|
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
|
|
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) }
|
|
@@ -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.
|
data/lib/reactive_component.rb
CHANGED
|
@@ -38,6 +38,8 @@ module ReactiveComponent
|
|
|
38
38
|
class_attribute :_client_state_fields, instance_writer: false, default: {}
|
|
39
39
|
class_attribute :_subscribed_events, instance_writer: false, default: %i[create update destroy]
|
|
40
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
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
def render_in(view_context, &)
|
|
@@ -60,6 +62,7 @@ module ReactiveComponent
|
|
|
60
62
|
end
|
|
61
63
|
|
|
62
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
|
|
63
66
|
|
|
64
67
|
wrapped = ReactiveComponent::Wrapper.wrap(self.class, record, inner_html, stream: stream, client_state: client_state,
|
|
65
68
|
**extra_opts)
|
|
@@ -116,6 +119,8 @@ module ReactiveComponent
|
|
|
116
119
|
end
|
|
117
120
|
private_class_method :raise_unsafe!
|
|
118
121
|
|
|
122
|
+
def self.signal_for(component_class, record) = { 'id' => record.id, 'dom_id' => component_class.dom_id_for(record) }
|
|
123
|
+
|
|
119
124
|
def self.broadcast_for(component_class, record, action:)
|
|
120
125
|
return unless component_class._subscribed_events.include?(action)
|
|
121
126
|
|
|
@@ -129,11 +134,11 @@ module ReactiveComponent
|
|
|
129
134
|
|
|
130
135
|
case action
|
|
131
136
|
when :update
|
|
132
|
-
|
|
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)
|
|
133
140
|
when :destroy
|
|
134
|
-
Channel.broadcast_data(stream, action: :destroy, data:
|
|
135
|
-
'id' => record.id, 'dom_id' => component_class.dom_id_for(record)
|
|
136
|
-
})
|
|
141
|
+
Channel.broadcast_data(stream, action: :destroy, data: signal_for(component_class, record))
|
|
137
142
|
when :create
|
|
138
143
|
target = config&.dig(:prepend_target)
|
|
139
144
|
return unless target
|
|
@@ -145,11 +150,13 @@ module ReactiveComponent
|
|
|
145
150
|
end
|
|
146
151
|
|
|
147
152
|
class_methods do
|
|
148
|
-
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)
|
|
149
154
|
self._live_model_attr = attr_name.to_sym
|
|
150
155
|
self._live_model_class_name = class_name || attr_name.to_s.classify
|
|
151
156
|
self._subscribed_events = Array(only).map(&:to_sym)
|
|
152
157
|
self._subscribed_fields = fields && Array(fields).map(&:to_s)
|
|
158
|
+
self._strategy = strategy&.to_sym
|
|
159
|
+
self._broadcast_later = later
|
|
153
160
|
|
|
154
161
|
component_class = self
|
|
155
162
|
|
|
@@ -174,6 +181,8 @@ module ReactiveComponent
|
|
|
174
181
|
end
|
|
175
182
|
end
|
|
176
183
|
|
|
184
|
+
def notify? = _strategy == :notify
|
|
185
|
+
|
|
177
186
|
def live_model_class
|
|
178
187
|
_live_model_class_name&.constantize
|
|
179
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.
|
|
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
|