reactive_component 0.8.4 → 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 +42 -0
- data/app/javascript/reactive_component/lib/reactive_renderer_utils.js +28 -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 +11 -0
- data/lib/reactive_component/entity.rb +9 -4
- data/lib/reactive_component/subscriber_loader.rb +31 -0
- data/lib/reactive_component/version.rb +1 -1
- data/lib/reactive_component/wrapper.rb +6 -0
- data/lib/reactive_component.rb +15 -5
- metadata +3 -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,47 @@
|
|
|
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
|
+
|
|
30
|
+
## [0.8.5] - 2026-09-16
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- `morphHooks`: register Idiomorph callbacks that run inside every component's
|
|
34
|
+
morph, for flashing changed values and similar page-wide behaviour, without
|
|
35
|
+
subclassing the controller. See Hooking into the morph.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- An app that does not eager load (development, test) broadcast nothing from a
|
|
39
|
+
process that had not rendered the subscribing component yet, such as a job or
|
|
40
|
+
a turbo-stream request. The engine now loads the components under
|
|
41
|
+
`app/components` that include `ReactiveComponent` on boot and after each
|
|
42
|
+
reload, and only those, so the rest of the app keeps lazy loading. Set
|
|
43
|
+
`ReactiveComponent::SubscriberLoader.paths` to look elsewhere.
|
|
44
|
+
|
|
3
45
|
## [0.8.4] - 2026-09-16
|
|
4
46
|
|
|
5
47
|
### Added
|
|
@@ -34,6 +34,32 @@ 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
|
+
// 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
|
+
|
|
58
|
+
const morphCallbacks = Object.fromEntries(HOOK_NAMES.map(name => [
|
|
59
|
+
name,
|
|
60
|
+
(...args) => [skipPermanent, ...morphHooks].reduce((ok, hook) => (hook[name]?.(...args) === false ? false : ok), true)
|
|
61
|
+
]))
|
|
62
|
+
|
|
37
63
|
export function morphElement(element, newHtml) {
|
|
38
64
|
const parser = new DOMParser()
|
|
39
65
|
const doc = parser.parseFromString(`<div>${newHtml}</div>`, "text/html")
|
|
@@ -42,7 +68,8 @@ export function morphElement(element, newHtml) {
|
|
|
42
68
|
if (typeof Idiomorph !== "undefined") {
|
|
43
69
|
Idiomorph.morph(element, newContent, {
|
|
44
70
|
morphStyle: "innerHTML",
|
|
45
|
-
ignoreActiveValue: true
|
|
71
|
+
ignoreActiveValue: true,
|
|
72
|
+
callbacks: morphCallbacks
|
|
46
73
|
})
|
|
47
74
|
} else {
|
|
48
75
|
element.innerHTML = newContent.innerHTML
|
|
@@ -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
|
|
@@ -15,6 +16,16 @@ module ReactiveComponent
|
|
|
15
16
|
ReactiveComponent::DataEvaluator.finalize!
|
|
16
17
|
end
|
|
17
18
|
|
|
19
|
+
# A component wires its model up when the class loads, so an app that does
|
|
20
|
+
# not eager load (development, test) would broadcast nothing from a process
|
|
21
|
+
# that has not rendered that component yet: a job, or a turbo-stream
|
|
22
|
+
# request. Load the subscribing components on boot and after each reload.
|
|
23
|
+
initializer 'reactive_component.load_subscribers' do |app|
|
|
24
|
+
next if app.config.eager_load
|
|
25
|
+
|
|
26
|
+
app.config.to_prepare { ReactiveComponent::SubscriberLoader.load_all }
|
|
27
|
+
end
|
|
28
|
+
|
|
18
29
|
initializer 'reactive_component.importmap', before: 'importmap' do |app|
|
|
19
30
|
if defined?(Importmap)
|
|
20
31
|
app.config.importmap.paths <<
|
|
@@ -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) }
|
|
@@ -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
|
|
@@ -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
|
@@ -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
|
|
|
@@ -37,6 +38,8 @@ module ReactiveComponent
|
|
|
37
38
|
class_attribute :_client_state_fields, instance_writer: false, default: {}
|
|
38
39
|
class_attribute :_subscribed_events, instance_writer: false, default: %i[create update destroy]
|
|
39
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
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
def render_in(view_context, &)
|
|
@@ -59,6 +62,7 @@ module ReactiveComponent
|
|
|
59
62
|
end
|
|
60
63
|
|
|
61
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
|
|
62
66
|
|
|
63
67
|
wrapped = ReactiveComponent::Wrapper.wrap(self.class, record, inner_html, stream: stream, client_state: client_state,
|
|
64
68
|
**extra_opts)
|
|
@@ -115,6 +119,8 @@ module ReactiveComponent
|
|
|
115
119
|
end
|
|
116
120
|
private_class_method :raise_unsafe!
|
|
117
121
|
|
|
122
|
+
def self.signal_for(component_class, record) = { 'id' => record.id, 'dom_id' => component_class.dom_id_for(record) }
|
|
123
|
+
|
|
118
124
|
def self.broadcast_for(component_class, record, action:)
|
|
119
125
|
return unless component_class._subscribed_events.include?(action)
|
|
120
126
|
|
|
@@ -128,11 +134,11 @@ module ReactiveComponent
|
|
|
128
134
|
|
|
129
135
|
case action
|
|
130
136
|
when :update
|
|
131
|
-
|
|
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)
|
|
132
140
|
when :destroy
|
|
133
|
-
Channel.broadcast_data(stream, action: :destroy, data:
|
|
134
|
-
'id' => record.id, 'dom_id' => component_class.dom_id_for(record)
|
|
135
|
-
})
|
|
141
|
+
Channel.broadcast_data(stream, action: :destroy, data: signal_for(component_class, record))
|
|
136
142
|
when :create
|
|
137
143
|
target = config&.dig(:prepend_target)
|
|
138
144
|
return unless target
|
|
@@ -144,11 +150,13 @@ module ReactiveComponent
|
|
|
144
150
|
end
|
|
145
151
|
|
|
146
152
|
class_methods do
|
|
147
|
-
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)
|
|
148
154
|
self._live_model_attr = attr_name.to_sym
|
|
149
155
|
self._live_model_class_name = class_name || attr_name.to_s.classify
|
|
150
156
|
self._subscribed_events = Array(only).map(&:to_sym)
|
|
151
157
|
self._subscribed_fields = fields && Array(fields).map(&:to_s)
|
|
158
|
+
self._strategy = strategy&.to_sym
|
|
159
|
+
self._broadcast_later = later
|
|
152
160
|
|
|
153
161
|
component_class = self
|
|
154
162
|
|
|
@@ -173,6 +181,8 @@ module ReactiveComponent
|
|
|
173
181
|
end
|
|
174
182
|
end
|
|
175
183
|
|
|
184
|
+
def notify? = _strategy == :notify
|
|
185
|
+
|
|
176
186
|
def live_model_class
|
|
177
187
|
_live_model_class_name&.constantize
|
|
178
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
|
|
@@ -115,6 +116,7 @@ files:
|
|
|
115
116
|
- lib/reactive_component/engine.rb
|
|
116
117
|
- lib/reactive_component/entity.rb
|
|
117
118
|
- lib/reactive_component/erubi.rb
|
|
119
|
+
- lib/reactive_component/subscriber_loader.rb
|
|
118
120
|
- lib/reactive_component/transpiler.rb
|
|
119
121
|
- lib/reactive_component/version.rb
|
|
120
122
|
- lib/reactive_component/wrapper.rb
|