reactive_component 0.7.2 → 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: 8ef3f09713ddda3cdec03ffd6dac861df951589a1b077504a7ea1323e6ac8d73
4
- data.tar.gz: a08ef29771672722c29145c51f15a9b159d0b9487d801dec2a045d0a5ebf0127
3
+ metadata.gz: a6b20beff63665f50b6910b02f241982c53bf7112145d2db4158b08738d2b3c4
4
+ data.tar.gz: 43c738cda0f1d36bb20a42f41a16ba9c55585858487d363cb37cebd4aa26de0d
5
5
  SHA512:
6
- metadata.gz: e7142557dca9daff0f7f5b9a6034144228d47578c417e1a9e959694f8ec0e62371fe4757bffc16738ef7ed8879f40cffa53b347887a5a1a9cb153ce63f7756fc
7
- data.tar.gz: 92e76f033d5883773851f94f551499c78072a33624fe016ce579834be04e1cfcad000c0e8ba6009500fa2477c05fa3ca401cc755c061c2d86b736a430d224d46
6
+ metadata.gz: 5b1011b2b0c3d08ad80ecc86586d7c0061c77bc762c0bbafbbf031c2420d711e69c1c63b34ea67ef0f3bfd0824f7f48ce2737e59b26e61a3ad1def298a6b9ed3
7
+ data.tar.gz: dd3c3c6ad4e6d900bfd37ad95450c20344fda96060e0cb1d8f8695918b9e7a6678c89a2d8f1ff673a129cd850c6f07e8c556249bd4410eeaef549c29c9be49d2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
3
17
  ## [0.7.2] - 2026-09-07
4
18
 
5
19
  ### Security
@@ -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.2'
4
+ VERSION = '0.8.0'
5
5
  end
@@ -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/entity'
11
12
  require_relative 'reactive_component/engine' if defined?(Rails::Engine)
12
13
 
13
14
  module ReactiveComponent
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.2
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