reactive_component 0.7.1 → 0.8.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 +37 -0
- data/app/channels/reactive_component/channel.rb +24 -9
- data/app/controllers/reactive_component/actions_controller.rb +2 -0
- data/app/javascript/reactive_component/controllers/reactive_renderer_controller.js +22 -1
- data/app/javascript/reactive_component/lib/reactive_renderer_utils.js +7 -1
- data/lib/reactive_component/broadcastable.rb +11 -9
- data/lib/reactive_component/entity.rb +85 -0
- data/lib/reactive_component/version.rb +1 -1
- data/lib/reactive_component.rb +5 -1
- 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: a6b20beff63665f50b6910b02f241982c53bf7112145d2db4158b08738d2b3c4
|
|
4
|
+
data.tar.gz: 43c738cda0f1d36bb20a42f41a16ba9c55585858487d363cb37cebd4aa26de0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b1011b2b0c3d08ad80ecc86586d7c0061c77bc762c0bbafbbf031c2420d711e69c1c63b34ea67ef0f3bfd0824f7f48ce2737e59b26e61a3ad1def298a6b9ed3
|
|
7
|
+
data.tar.gz: dd3c3c6ad4e6d900bfd37ad95450c20344fda96060e0cb1d8f8695918b9e7a6678c89a2d8f1ff673a129cd850c6f07e8c556249bd4410eeaef549c29c9be49d2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0] - 2026-09-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `ReactiveComponent::Entity`: subscribe a component to an object built from
|
|
7
|
+
several models. The entity declares its `root` record and `rebuilds_on`
|
|
8
|
+
each source model, optionally narrowed to `fields:`. See the Derived
|
|
9
|
+
Entities guide.
|
|
10
|
+
- `broadcast_reactive(action)` and `broadcast_reactive_destroy` are public on
|
|
11
|
+
wired models, alongside `broadcast_reactive_update`.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Multibyte characters in a template (★, é, emoji) rendered as mojibake after
|
|
15
|
+
a live re-render. The client now decodes the base64 template as UTF-8.
|
|
16
|
+
|
|
17
|
+
## [0.7.2] - 2026-09-07
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
- The actions endpoint enforces CSRF itself with `protect_from_forgery`
|
|
21
|
+
instead of relying on the host's `load_defaults`.
|
|
22
|
+
- `live_action` tokens expire after `ReactiveComponent.action_token_ttl`
|
|
23
|
+
(default one day). Before, a token stayed valid forever.
|
|
24
|
+
- `request_update` passes only declared `client_state` fields to the
|
|
25
|
+
component. Any other client key was set as an instance variable and
|
|
26
|
+
handed to the constructor.
|
|
27
|
+
- `request_update` over the channel only answers for records whose
|
|
28
|
+
`broadcasts` stream is the one the subscriber verified. Before, any
|
|
29
|
+
subscriber could pull the rendered data of any record id of the model.
|
|
30
|
+
- The channel resolves the component name with `safe_constantize` and
|
|
31
|
+
requires a class that includes `ReactiveComponent`.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- The renderer controller imports its utils by bare specifier again. The
|
|
35
|
+
0.7.1 relative import resolved to an undigested `/assets/` URL under
|
|
36
|
+
Propshaft and 404ed, which left Turbo stream sources never connecting and
|
|
37
|
+
every system test failing. The bare specifier works for both importmap
|
|
38
|
+
(`pin_all_from`) and npm (`exports`).
|
|
39
|
+
|
|
3
40
|
## [0.7.1] - 2026-09-06
|
|
4
41
|
|
|
5
42
|
### Added
|
|
@@ -7,9 +7,9 @@ module ReactiveComponent
|
|
|
7
7
|
class_attribute :filter_callback, default: nil
|
|
8
8
|
|
|
9
9
|
def subscribed
|
|
10
|
-
stream_name = verified_stream_name
|
|
11
|
-
if stream_name
|
|
12
|
-
stream_from stream_name
|
|
10
|
+
@stream_name = verified_stream_name
|
|
11
|
+
if @stream_name
|
|
12
|
+
stream_from @stream_name
|
|
13
13
|
else
|
|
14
14
|
reject
|
|
15
15
|
end
|
|
@@ -20,12 +20,8 @@ module ReactiveComponent
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def request_update(data)
|
|
23
|
-
component_class = data['component'].constantize
|
|
24
23
|
params = data['params'] || {}
|
|
25
|
-
|
|
26
|
-
model_class = component_class.live_model_class
|
|
27
|
-
record_id = data['record_id'] || params.delete('record_id')
|
|
28
|
-
record = model_class.find_by(id: record_id)
|
|
24
|
+
component_class, record = subscribed_component_and_record(data, params)
|
|
29
25
|
return unless record
|
|
30
26
|
|
|
31
27
|
if data['record_id'].present?
|
|
@@ -35,13 +31,32 @@ module ReactiveComponent
|
|
|
35
31
|
transmit({ 'action' => 'remove', 'dom_id' => data['dom_id'] })
|
|
36
32
|
end
|
|
37
33
|
else
|
|
38
|
-
|
|
34
|
+
client_state = params.slice(*component_class._client_state_fields.keys.map(&:to_s))
|
|
35
|
+
result = component_class.build_data(record, **client_state.symbolize_keys)
|
|
39
36
|
transmit({ 'action' => 'render', 'data' => result })
|
|
40
37
|
end
|
|
41
38
|
end
|
|
42
39
|
|
|
43
40
|
private
|
|
44
41
|
|
|
42
|
+
# The component must be a reactive component and the record must broadcast
|
|
43
|
+
# to the stream this subscriber verified: the same stream the wrapper
|
|
44
|
+
# signed into the page. Anything else is a guess at a record id the client
|
|
45
|
+
# was never shown.
|
|
46
|
+
def subscribed_component_and_record(data, params)
|
|
47
|
+
component_class = data['component'].to_s.safe_constantize
|
|
48
|
+
return unless component_class.is_a?(Class) && component_class.include?(ReactiveComponent)
|
|
49
|
+
|
|
50
|
+
record = component_class.live_model_class.find_by(id: data['record_id'] || params.delete('record_id'))
|
|
51
|
+
return unless record
|
|
52
|
+
|
|
53
|
+
stream = ReactiveComponent::Wrapper.find_stream_for(component_class, record)
|
|
54
|
+
signed = Turbo::StreamsChannel.signed_stream_name(stream)
|
|
55
|
+
return unless Turbo::StreamsChannel.verified_stream_name(signed) == @stream_name
|
|
56
|
+
|
|
57
|
+
[component_class, record]
|
|
58
|
+
end
|
|
59
|
+
|
|
45
60
|
def record_matches?(record, params)
|
|
46
61
|
return true unless self.class.filter_callback
|
|
47
62
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
import { createConsumer } from "@rails/actioncable"
|
|
3
|
-
import { compileTemplate, decompress, morphElement, buildActionBody, routeMessage, duplicateIds, strictData } from "
|
|
3
|
+
import { compileTemplate, decompress, morphElement, buildActionBody, routeMessage, duplicateIds, strictData } from "reactive_component/lib/reactive_renderer_utils"
|
|
4
4
|
|
|
5
5
|
const consumer = createConsumer()
|
|
6
6
|
const log = (...args) => {
|
|
@@ -21,6 +21,18 @@ function subscribe(streamValue, controller) {
|
|
|
21
21
|
sub = consumer.subscriptions.create(
|
|
22
22
|
{ channel: "ReactiveComponent::Channel", signed_stream_name: streamValue },
|
|
23
23
|
{
|
|
24
|
+
connected() {
|
|
25
|
+
sub._connected = true
|
|
26
|
+
for (const handler of sub.handlers || []) {
|
|
27
|
+
handler.subscriptionConnected()
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
disconnected() {
|
|
31
|
+
sub._connected = false
|
|
32
|
+
for (const handler of sub.handlers || []) {
|
|
33
|
+
handler.subscriptionDisconnected()
|
|
34
|
+
}
|
|
35
|
+
},
|
|
24
36
|
received: async (message) => {
|
|
25
37
|
const decoded = message.z ? await decompress(message.z) : message
|
|
26
38
|
for (const handler of sub.handlers) {
|
|
@@ -32,6 +44,7 @@ function subscribe(streamValue, controller) {
|
|
|
32
44
|
sub.handlers = new Set()
|
|
33
45
|
}
|
|
34
46
|
sub.handlers.add(controller)
|
|
47
|
+
if (sub._connected) controller.subscriptionConnected()
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
function unsubscribe(streamValue, controller) {
|
|
@@ -90,6 +103,14 @@ export default class extends Controller {
|
|
|
90
103
|
}
|
|
91
104
|
}
|
|
92
105
|
|
|
106
|
+
subscriptionConnected() {
|
|
107
|
+
this.element.setAttribute("data-reactive-renderer-connected", "")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
subscriptionDisconnected() {
|
|
111
|
+
this.element.removeAttribute("data-reactive-renderer-connected")
|
|
112
|
+
}
|
|
113
|
+
|
|
93
114
|
resolveTemplate() {
|
|
94
115
|
if (this.hasTemplateValue) return this.templateValue
|
|
95
116
|
|
|
@@ -8,7 +8,7 @@ export function compileTemplate(source) {
|
|
|
8
8
|
if (templateCache.has(source)) return templateCache.get(source)
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
|
-
const body = isBase64(source) ?
|
|
11
|
+
const body = isBase64(source) ? decodeBase64(source) : source
|
|
12
12
|
const fn = new Function("data", body)
|
|
13
13
|
templateCache.set(source, fn)
|
|
14
14
|
return fn
|
|
@@ -18,6 +18,12 @@ export function compileTemplate(source) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// atob yields one char per byte, so multibyte UTF-8 (★, é, emoji) in a
|
|
22
|
+
// template literal came out as mojibake once the client re-rendered.
|
|
23
|
+
function decodeBase64(base64) {
|
|
24
|
+
return new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)))
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
export function clearTemplateCache() {
|
|
22
28
|
templateCache.clear()
|
|
23
29
|
}
|
|
@@ -16,6 +16,9 @@ module ReactiveComponent
|
|
|
16
16
|
|
|
17
17
|
self.reactive_component_classes = reactive_component_classes | [component_class]
|
|
18
18
|
|
|
19
|
+
# A derived entity (ReactiveComponent::Entity) is not an ActiveRecord
|
|
20
|
+
# model: it has no commit callbacks and broadcasts itself.
|
|
21
|
+
return unless respond_to?(:after_create_commit)
|
|
19
22
|
return if _commit_callbacks.map(&:filter).include?(:_broadcast_reactive_create)
|
|
20
23
|
|
|
21
24
|
after_create_commit :_broadcast_reactive_create
|
|
@@ -24,18 +27,17 @@ module ReactiveComponent
|
|
|
24
27
|
end
|
|
25
28
|
end
|
|
26
29
|
|
|
27
|
-
def
|
|
28
|
-
|
|
30
|
+
def broadcast_reactive(action)
|
|
31
|
+
reactive_component_classes.each { |klass| ReactiveComponent.broadcast_for(klass, self, action: action) }
|
|
29
32
|
end
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
def broadcast_reactive_update = broadcast_reactive(:update)
|
|
35
|
+
def broadcast_reactive_destroy = broadcast_reactive(:destroy)
|
|
32
36
|
|
|
33
|
-
|
|
34
|
-
def _broadcast_reactive_update = _broadcast_reactive(:update)
|
|
35
|
-
def _broadcast_reactive_destroy = _broadcast_reactive(:destroy)
|
|
37
|
+
private
|
|
36
38
|
|
|
37
|
-
def
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
def _broadcast_reactive_create = broadcast_reactive(:create)
|
|
40
|
+
def _broadcast_reactive_update = broadcast_reactive(:update)
|
|
41
|
+
def _broadcast_reactive_destroy = broadcast_reactive(:destroy)
|
|
40
42
|
end
|
|
41
43
|
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_model'
|
|
4
|
+
|
|
5
|
+
module ReactiveComponent
|
|
6
|
+
# A derived entity: a plain object built on top of several ActiveRecord
|
|
7
|
+
# models that a component can `subscribes_to` like a model. The entity owns
|
|
8
|
+
# the whole declaration — which record it is keyed on, and which changes to
|
|
9
|
+
# which models rebuild it:
|
|
10
|
+
#
|
|
11
|
+
# class OrderSummary
|
|
12
|
+
# include ReactiveComponent::Entity
|
|
13
|
+
#
|
|
14
|
+
# root :order
|
|
15
|
+
# rebuilds_on Order, fields: %i[status total_cents]
|
|
16
|
+
# rebuilds_on Payment, via: :order_id, fields: %i[amount]
|
|
17
|
+
# rebuilds_on Shipment, via: :order_id, fields: %i[delivered_at]
|
|
18
|
+
#
|
|
19
|
+
# def total = order.payments.sum(:amount)
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# class OrderSummaryComponent < ApplicationComponent
|
|
23
|
+
# include ReactiveComponent
|
|
24
|
+
# subscribes_to :summary, class_name: "OrderSummary"
|
|
25
|
+
# end
|
|
26
|
+
module Entity
|
|
27
|
+
extend ActiveSupport::Concern
|
|
28
|
+
include ActiveModel::Model
|
|
29
|
+
include Broadcastable
|
|
30
|
+
|
|
31
|
+
included do
|
|
32
|
+
class_attribute :root_name, instance_writer: false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def persisted? = true
|
|
36
|
+
|
|
37
|
+
# Default stream when the component declares no `broadcasts stream:`.
|
|
38
|
+
# A bare id would collide with every other entity sharing it.
|
|
39
|
+
def to_param = "#{self.class.model_name.param_key}/#{id}"
|
|
40
|
+
|
|
41
|
+
class_methods do
|
|
42
|
+
# The record the entity is keyed on. Defines `initialize(<name>:)`, the
|
|
43
|
+
# reader, `id`, and the `find` / `find_by(id:)` the channel and actions
|
|
44
|
+
# controller need.
|
|
45
|
+
def root(name, class_name: name.to_s.classify)
|
|
46
|
+
self.root_name = name.to_sym
|
|
47
|
+
attr_reader name
|
|
48
|
+
|
|
49
|
+
define_method(:initialize) { |**kwargs| instance_variable_set(:"@#{name}", kwargs.fetch(name)) }
|
|
50
|
+
delegate :id, to: name
|
|
51
|
+
|
|
52
|
+
define_singleton_method(:find) { |id| new(name => class_name.constantize.find(id)) }
|
|
53
|
+
define_singleton_method(:find_by) { |id:| (record = class_name.constantize.find_by(id: id)) && new(name => record) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Rebroadcast the entity after `model` commits. `via:` is the foreign key
|
|
57
|
+
# on `model` pointing at the root; omit it when `model` is the root.
|
|
58
|
+
# `fields:` narrows updates to the listed columns; create and destroy
|
|
59
|
+
# always count.
|
|
60
|
+
def rebuilds_on(model, via: nil, fields: nil)
|
|
61
|
+
entity = self
|
|
62
|
+
fields = fields&.map(&:to_s)
|
|
63
|
+
|
|
64
|
+
model.after_commit { entity.rebuild_from(self, via: via, fields: fields) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def rebuild_from(record, via:, fields:)
|
|
68
|
+
action = commit_action(record)
|
|
69
|
+
return if action == :update && fields && !record.saved_changes.keys.intersect?(fields)
|
|
70
|
+
return find_by(id: record.public_send(via))&.broadcast_reactive(:update) if via
|
|
71
|
+
|
|
72
|
+
new(root_name => record).broadcast_reactive(action)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def commit_action(record)
|
|
78
|
+
return :destroy if record.destroyed?
|
|
79
|
+
return :create if record.previously_new_record?
|
|
80
|
+
|
|
81
|
+
:update
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/reactive_component.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'active_support/core_ext/integer/time'
|
|
3
4
|
require 'active_support/concern'
|
|
4
5
|
|
|
5
6
|
require_relative 'reactive_component/version'
|
|
@@ -7,6 +8,7 @@ require_relative 'reactive_component/compiler'
|
|
|
7
8
|
require_relative 'reactive_component/data_evaluator'
|
|
8
9
|
require_relative 'reactive_component/wrapper'
|
|
9
10
|
require_relative 'reactive_component/broadcastable'
|
|
11
|
+
require_relative 'reactive_component/entity'
|
|
10
12
|
require_relative 'reactive_component/engine' if defined?(Rails::Engine)
|
|
11
13
|
|
|
12
14
|
module ReactiveComponent
|
|
@@ -14,6 +16,8 @@ module ReactiveComponent
|
|
|
14
16
|
|
|
15
17
|
mattr_accessor :debug, default: false
|
|
16
18
|
mattr_accessor :renderer, default: nil
|
|
19
|
+
# How long a live_action token minted into a page stays valid.
|
|
20
|
+
mattr_accessor :action_token_ttl, default: 1.day
|
|
17
21
|
|
|
18
22
|
class Error < StandardError; end
|
|
19
23
|
|
|
@@ -204,7 +208,7 @@ module ReactiveComponent
|
|
|
204
208
|
def live_action_token(record)
|
|
205
209
|
live_action_verifier.generate(
|
|
206
210
|
{ c: name, m: record.class.name, r: record.id },
|
|
207
|
-
purpose: :reactive_component_action
|
|
211
|
+
purpose: :reactive_component_action, expires_in: ReactiveComponent.action_token_ttl
|
|
208
212
|
)
|
|
209
213
|
end
|
|
210
214
|
|
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.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Przemyslaw Lusar
|
|
@@ -113,6 +113,7 @@ files:
|
|
|
113
113
|
- lib/reactive_component/compiler.rb
|
|
114
114
|
- lib/reactive_component/data_evaluator.rb
|
|
115
115
|
- lib/reactive_component/engine.rb
|
|
116
|
+
- lib/reactive_component/entity.rb
|
|
116
117
|
- lib/reactive_component/erubi.rb
|
|
117
118
|
- lib/reactive_component/transpiler.rb
|
|
118
119
|
- lib/reactive_component/version.rb
|