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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6897d1bd7689445185a2a06d80b868778d1dca89fa15bf1cbbc81ac31ce2ccb2
4
- data.tar.gz: a285c182b5fea6ab970d8d0f609237f81a98d7724d95c88a8f595dce121b361f
3
+ metadata.gz: a6b20beff63665f50b6910b02f241982c53bf7112145d2db4158b08738d2b3c4
4
+ data.tar.gz: 43c738cda0f1d36bb20a42f41a16ba9c55585858487d363cb37cebd4aa26de0d
5
5
  SHA512:
6
- metadata.gz: d8c1a5eaddc568b0c8aa20e88a18e7631633d46d344892d447ed0bbe6b12513b7637c0b00794d19679ef2f3da13d1ba012a1559540f754f911c5b2f2179230f1
7
- data.tar.gz: 1b6ec87280c44844c8b93350d21f255280bce22ed79cf192234c6319f1f30971f5e0947e34433c2ee19a72fde6535f31b3a7ccfe5dac3800a10fdc28e50f72fe
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
- result = component_class.build_data(record, **params.symbolize_keys)
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
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ReactiveComponent
4
4
  class ActionsController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+
5
7
  def create
6
8
  payload = verify_token!.symbolize_keys
7
9
  component_class = payload[:c].constantize
@@ -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 "../lib/reactive_renderer_utils.js"
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) ? atob(source) : 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 broadcast_reactive_update
28
- _broadcast_reactive(:update)
30
+ def broadcast_reactive(action)
31
+ reactive_component_classes.each { |klass| ReactiveComponent.broadcast_for(klass, self, action: action) }
29
32
  end
30
33
 
31
- private
34
+ def broadcast_reactive_update = broadcast_reactive(:update)
35
+ def broadcast_reactive_destroy = broadcast_reactive(:destroy)
32
36
 
33
- def _broadcast_reactive_create = _broadcast_reactive(:create)
34
- def _broadcast_reactive_update = _broadcast_reactive(:update)
35
- def _broadcast_reactive_destroy = _broadcast_reactive(:destroy)
37
+ private
36
38
 
37
- def _broadcast_reactive(action)
38
- reactive_component_classes.each { |klass| ReactiveComponent.broadcast_for(klass, self, action: action) }
39
- end
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactiveComponent
4
- VERSION = '0.7.1'
4
+ VERSION = '0.8.0'
5
5
  end
@@ -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.7.1
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