reactive_component 0.8.3 → 0.8.5
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 +39 -0
- data/app/channels/reactive_component/channel.rb +15 -8
- data/app/javascript/reactive_component/controllers/reactive_renderer_controller.js +2 -1
- data/app/javascript/reactive_component/lib/reactive_renderer_utils.js +17 -1
- data/lib/reactive_component/engine.rb +15 -0
- data/lib/reactive_component/entity.rb +68 -6
- data/lib/reactive_component/subscriber_loader.rb +31 -0
- data/lib/reactive_component/version.rb +1 -1
- data/lib/reactive_component/wrapper.rb +10 -1
- data/lib/reactive_component.rb +1 -0
- 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: 5ec78650feb0396bbe85c2adc0994209dc4d1ab91a6cf0ca49d9692b17e0b316
|
|
4
|
+
data.tar.gz: c2d54ef039a0ea502c593a5eb1e173f509955e3e37b0f6eea1cd78c05ed365d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: caf86cb2646eea037defb27405b486555e70377351c6fe3a61b7d1b00aa33cf330d6ace77a42936e9c93c2bbb992026d0a5a2138137ac6591ff6efd1a45f6c21
|
|
7
|
+
data.tar.gz: fc02fd8569b770ff6b6ed6583cdfbc4fb3dfb2630768eaa7d4f095ce755cc7ecf7617721fa542ca5f76a7a149984cbd5549e4f959911e67b739f96a4e2aa3a22
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.5] - 2026-09-16
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `morphHooks`: register Idiomorph callbacks that run inside every component's
|
|
7
|
+
morph, for flashing changed values and similar page-wide behaviour, without
|
|
8
|
+
subclassing the controller. See Hooking into the morph.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- An app that does not eager load (development, test) broadcast nothing from a
|
|
12
|
+
process that had not rendered the subscribing component yet, such as a job or
|
|
13
|
+
a turbo-stream request. The engine now loads the components under
|
|
14
|
+
`app/components` that include `ReactiveComponent` on boot and after each
|
|
15
|
+
reload, and only those, so the rest of the app keeps lazy loading. Set
|
|
16
|
+
`ReactiveComponent::SubscriberLoader.paths` to look elsewhere.
|
|
17
|
+
|
|
18
|
+
## [0.8.4] - 2026-09-16
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- `key :company_id, :user_id` on a derived entity, for an entity keyed on a
|
|
22
|
+
tuple of values instead of one root record. It defines the readers, the
|
|
23
|
+
keyword initializer, a joined `id`, and `find` / `find_by(id:)`. A key of
|
|
24
|
+
the wrong arity resolves to nil instead of raising. The readers, the
|
|
25
|
+
initializer and `from_key` are defaults: an entity built from records
|
|
26
|
+
rather than ids replaces them, and `find` rebuilds it through `from_key`.
|
|
27
|
+
- `rebuilds_on ..., entities: ->(record) { ... }` fans one commit out to
|
|
28
|
+
every entity it affects, for when the entity is not reachable through a
|
|
29
|
+
single foreign key. Mutually exclusive with `via:`.
|
|
30
|
+
- Entities are `GlobalID::Identification`, so an entity used as a streamable
|
|
31
|
+
names its stream through `to_gid_param` like a model does.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- A notify component names its record to the server with a signed global id
|
|
35
|
+
instead of a raw one, and the channel resolves it with `locate_signed`
|
|
36
|
+
scoped to this gem. The stream check is unchanged. Upgrading re-renders
|
|
37
|
+
every page, so no client keeps the old payload.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- The README linked to `.html` documentation URLs the site does not serve.
|
|
41
|
+
|
|
3
42
|
## [0.8.3] - 2026-09-15
|
|
4
43
|
|
|
5
44
|
### Added
|
|
@@ -21,7 +21,7 @@ module ReactiveComponent
|
|
|
21
21
|
|
|
22
22
|
def request_update(data)
|
|
23
23
|
params = data['params'] || {}
|
|
24
|
-
component_class, record = subscribed_component_and_record(data
|
|
24
|
+
component_class, record = subscribed_component_and_record(data)
|
|
25
25
|
return unless record
|
|
26
26
|
|
|
27
27
|
if record_matches?(record, params)
|
|
@@ -34,16 +34,15 @@ module ReactiveComponent
|
|
|
34
34
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
|
-
# The component must be a reactive component
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
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)
|
|
42
41
|
component_class = data['component'].to_s.safe_constantize
|
|
43
42
|
return unless component_class.is_a?(Class) && component_class.include?(ReactiveComponent)
|
|
44
43
|
|
|
45
|
-
record =
|
|
46
|
-
return unless record
|
|
44
|
+
record = locate_signed(data['sgid'])
|
|
45
|
+
return unless record.is_a?(component_class.live_model_class)
|
|
47
46
|
|
|
48
47
|
stream = ReactiveComponent::Wrapper.find_stream_for(component_class, record)
|
|
49
48
|
signed = Turbo::StreamsChannel.signed_stream_name(stream)
|
|
@@ -52,6 +51,14 @@ module ReactiveComponent
|
|
|
52
51
|
[component_class, record]
|
|
53
52
|
end
|
|
54
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
|
+
|
|
55
62
|
def record_matches?(record, params)
|
|
56
63
|
return true unless self.class.filter_callback
|
|
57
64
|
|
|
@@ -68,6 +68,7 @@ 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
73
|
fieldMap: { type: Object, default: {} },
|
|
73
74
|
skipOwnBroadcasts: { type: Boolean, default: false }
|
|
@@ -165,7 +166,7 @@ export default class extends Controller {
|
|
|
165
166
|
|
|
166
167
|
sub.perform("request_update", {
|
|
167
168
|
component: this.componentValue,
|
|
168
|
-
|
|
169
|
+
sgid: this.sgidValue,
|
|
169
170
|
dom_id: this.element.id,
|
|
170
171
|
params: this.paramsValue
|
|
171
172
|
})
|
|
@@ -34,6 +34,21 @@ 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
|
+
const morphCallbacks = Object.fromEntries(HOOK_NAMES.map(name => [
|
|
48
|
+
name,
|
|
49
|
+
(...args) => morphHooks.reduce((ok, hook) => (hook[name]?.(...args) === false ? false : ok), true)
|
|
50
|
+
]))
|
|
51
|
+
|
|
37
52
|
export function morphElement(element, newHtml) {
|
|
38
53
|
const parser = new DOMParser()
|
|
39
54
|
const doc = parser.parseFromString(`<div>${newHtml}</div>`, "text/html")
|
|
@@ -42,7 +57,8 @@ export function morphElement(element, newHtml) {
|
|
|
42
57
|
if (typeof Idiomorph !== "undefined") {
|
|
43
58
|
Idiomorph.morph(element, newContent, {
|
|
44
59
|
morphStyle: "innerHTML",
|
|
45
|
-
ignoreActiveValue: true
|
|
60
|
+
ignoreActiveValue: true,
|
|
61
|
+
callbacks: morphCallbacks
|
|
46
62
|
})
|
|
47
63
|
} else {
|
|
48
64
|
element.innerHTML = newContent.innerHTML
|
|
@@ -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
|
|
@@ -10,6 +15,16 @@ module ReactiveComponent
|
|
|
10
15
|
ReactiveComponent::DataEvaluator.finalize!
|
|
11
16
|
end
|
|
12
17
|
|
|
18
|
+
# A component wires its model up when the class loads, so an app that does
|
|
19
|
+
# not eager load (development, test) would broadcast nothing from a process
|
|
20
|
+
# that has not rendered that component yet: a job, or a turbo-stream
|
|
21
|
+
# request. Load the subscribing components on boot and after each reload.
|
|
22
|
+
initializer 'reactive_component.load_subscribers' do |app|
|
|
23
|
+
next if app.config.eager_load
|
|
24
|
+
|
|
25
|
+
app.config.to_prepare { ReactiveComponent::SubscriberLoader.load_all }
|
|
26
|
+
end
|
|
27
|
+
|
|
13
28
|
initializer 'reactive_component.importmap', before: 'importmap' do |app|
|
|
14
29
|
if defined?(Importmap)
|
|
15
30
|
app.config.importmap.paths <<
|
|
@@ -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
|
-
#
|
|
38
|
-
#
|
|
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
|
-
|
|
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
|
|
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?
|
|
@@ -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
|
|
@@ -2,15 +2,22 @@
|
|
|
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
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.
|
|
11
18
|
if strategy.to_s == 'notify'
|
|
12
19
|
component_name ||= component_class.name
|
|
13
|
-
|
|
20
|
+
sgid = record.to_sgid_param(for: SGID_PURPOSE)
|
|
14
21
|
end
|
|
15
22
|
|
|
16
23
|
attrs = [
|
|
@@ -42,6 +49,8 @@ module ReactiveComponent
|
|
|
42
49
|
|
|
43
50
|
attrs << %(data-reactive-renderer-component-value="#{component_name}") if component_name
|
|
44
51
|
|
|
52
|
+
attrs << %(data-reactive-renderer-sgid-value="#{sgid}") if sgid
|
|
53
|
+
|
|
45
54
|
attrs << %(data-reactive-renderer-params-value="#{ERB::Util.html_escape(params.to_json)}") if params
|
|
46
55
|
|
|
47
56
|
if ReactiveComponent.debug
|
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
|
|
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.
|
|
4
|
+
version: 0.8.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Przemyslaw Lusar
|
|
@@ -115,6 +115,7 @@ files:
|
|
|
115
115
|
- lib/reactive_component/engine.rb
|
|
116
116
|
- lib/reactive_component/entity.rb
|
|
117
117
|
- lib/reactive_component/erubi.rb
|
|
118
|
+
- lib/reactive_component/subscriber_loader.rb
|
|
118
119
|
- lib/reactive_component/transpiler.rb
|
|
119
120
|
- lib/reactive_component/version.rb
|
|
120
121
|
- lib/reactive_component/wrapper.rb
|