reactive_component 0.1.0 → 0.7.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: 7f5fd3fc0f10d7fd47f43039a0ba4b58824915989d618d97e32c48b09397ceed
4
- data.tar.gz: 4755871d523443f453f870939d01183bdd165e1f39f97092f7c87c0a367c89fe
3
+ metadata.gz: 4a63e206cf0f143eb6c390357606b7de7bd67d8c516867883d3c125b865ff664
4
+ data.tar.gz: ab1e03efda80cff6207b6e7828bc42cdd23cf207953f803a620ccea143719a6c
5
5
  SHA512:
6
- metadata.gz: cc558b9d15841f9cab6c359450f3324c4f98a38078240437e951a60699e3eeb33cd58acd057622926b62c094fc9b1d68aa58b971fa70efa779a22d8bc83fbe81
7
- data.tar.gz: 1a785c4e6d55be8b767d90b2a483eb5415d684da8513cf1a96c6abdb8811273a6bc5890bcd08f8ce65a6b4cd3043dfc0a8ed2f8e6e2c9d9f5a5522b23f1efd8f
6
+ metadata.gz: 2476e8ed1dcef187d6abfe7b011cbb8e076ac41ac0cd266b48f4174ec1c4f503ba618d8eb104c19d288f18d694a74d57e2fa2c0e591ded079113d99dc166ee7f
7
+ data.tar.gz: 5f6aae9647f439578c0585d3ba66586c60442a75003531c9a98e8ce0f267c77fd3089830ce9d5c17f3c91638ffd5b3c13f4ba7533d638f4c7d90da92d80f43b5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,166 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0] - 2026-09-06
4
+
5
+ ### Fixed
6
+ - `live_action` params are filtered with `permit` against the declared
7
+ list, so a nested hash or array sent for a scalar param no longer reaches
8
+ the action method.
9
+
10
+ ### Added
11
+ - `live_action` `params:` accepts the full `permit` spec, so an action can
12
+ declare arrays and nested hashes: `params: [:title, { tags: [] }]`.
13
+
14
+ ## [0.6.2] - 2026-09-06
15
+
16
+ ### Changed
17
+ - Gem metadata points at the right places: homepage and documentation
18
+ are the GitHub Pages docs, source/changelog/issues are
19
+ `lluzak/reactive_component`. (First release on RubyGems since 0.1.0.)
20
+
21
+ ## [0.6.1] - 2026-09-06
22
+
23
+ ### Changed
24
+ - Docs describe the 0.6 compiler (Prism, prefixed ids, typed conditions,
25
+ compile errors); the live-action examples use the real Stimulus params.
26
+ - RuboCop pinned to `~> 1.90.0` so CI and local lint agree. The 0.6.0 tag
27
+ was never published: its commit failed lint on CI.
28
+
29
+ ## [0.6.0] - 2026-09-05
30
+
31
+ ### Changed
32
+ - **ruby2js is gone; the compiler runs on Prism's own tree.** One pass
33
+ over the Prism AST lifts every Ruby expression to a server-evaluated
34
+ data key and emits the template skeleton as JavaScript — a whitelist
35
+ (literals, data reads, `if`/`unless`/ternaries, boolean and comparison
36
+ operators, `.each`, the `_tag*`/`_render_*` helpers). Anything else raises
37
+ `ReactiveComponent::CompileError` naming the source, where ruby2js used
38
+ to guess (`present?` became a `.present` property read). Dependencies:
39
+ `ruby2js` out, `erubi` in; no `parser` gem. The compiled JS keeps the
40
+ same shape (`function render({ … })`, `_buf +=`, `for..of`,
41
+ `_tag_open(...)`).
42
+ - `"#{@x}!"` — an interpolated string with an ivar or helper — is now
43
+ server-evaluated and escaped like any other expression, instead of being
44
+ emitted as an unescaped template literal.
45
+ - The extractor now lifts a chain rooted at a self call
46
+ (`content.present?`, `current_user.name`) whole, like an ivar chain.
47
+ - **Wrapper ids are now component-prefixed by default** —
48
+ `message_row_message_1` instead of `message_1`. Two components rendering
49
+ the same record previously shared one id, and because broadcasts are
50
+ routed by id each rendered the *other's* payload: a `TypeError` deep in
51
+ the compiled template as soon as the shapes differed, plus duplicate ids
52
+ in the page. `dom_id_prefix` still overrides the default. Anything that
53
+ targeted the old bare ids (CSS, Turbo Stream targets, tests) needs the
54
+ new prefix.
55
+
56
+ ### Fixed
57
+ - **Block variables in conditions.** Inside a `.each`, an item read in a
58
+ non-output position — `<% if item.flag %>`, a ternary, `"x" if item.y` —
59
+ passed through ruby2js as `item.flag`, a property the client never
60
+ receives (items ship as their extracted expressions only, never the raw
61
+ record). Conditions were silently false after every broadcast and
62
+ `item.x.present?` threw `Cannot read properties of undefined`. They are
63
+ now server-evaluated per item like output expressions, but keep their
64
+ type (`"false"` is truthy in JS).
65
+
66
+ ### Added
67
+ - **Compile-time invariant for loops.** After a `.each` body is processed,
68
+ any surviving read of the loop variable that is not an extracted
69
+ `item["vN"]` raises `ReactiveComponent::CompileError` naming the
70
+ expression — an item ships only as its extracted expressions, so such a
71
+ read could never resolve on the client. Guards the block-variable fix
72
+ against new syntactic positions.
73
+ - **Debug-mode strict payloads.** With `ReactiveComponent.debug` on, the
74
+ client renders through a Proxy that throws on reading a key the payload
75
+ does not carry, naming the key, its path, and the keys present — instead
76
+ of an `undefined` that is silently falsy in an `if`.
77
+ - The Stimulus controller checks for other elements sharing its id on
78
+ connect and logs a `console.error` naming the collision and the fix.
79
+ Piggybacks on Stimulus's own MutationObserver, so it also catches
80
+ hand-picked `dom_id_prefix`es that collide.
81
+
82
+ ## [0.4.1] - 2026-05-29
83
+
84
+ ### Changed
85
+ - Bumped Rails to 8.1.3 (and matching patches across the 8.1.x stack)
86
+ in development and test gemfiles.
87
+ - Upgraded `view_component` to 4.11.0 — full test suite (119 runs,
88
+ 308 assertions) green on Rails 7.1, 7.2, and 8.0 appraisals.
89
+ - Refreshed transitive dependencies (rack 3.2.6, nokogiri 1.19.3,
90
+ loofah 2.25.1, zeitwerk 2.8.2, propshaft 1.3.2, sqlite3 2.9.4,
91
+ rubocop 1.86.2, etc.).
92
+
93
+ ## [0.4.0] - 2026-04-18
94
+
95
+ ### Security
96
+ - **Broadcast payloads now refuse non-primitive values.** Previously
97
+ `build_data` could ship full `ActiveRecord` records over ActionCable,
98
+ leaking every column (including `password_digest`, tokens). The
99
+ sanitizer now raises `ReactiveComponent::UnsafeBroadcastValueError`
100
+ with a context-aware hint pointing at the offending ERB expression.
101
+
102
+ ### Added
103
+ - `ReactiveComponent.sanitize_for_broadcast(value, source:)` — the
104
+ strict gatekeeper. Allows primitives (`nil`, booleans, `Integer`,
105
+ `Float`, `String`), `Symbol` (downcast to `String`), and `Array`/
106
+ `Hash` of those; raises on everything else.
107
+ - Node-based compiler regression test: actually executes compiled
108
+ templates so undefined-identifier runtime bugs fail the build.
109
+ - `RichRowComponent` + `WrapperComponent` dummy fixtures exercising
110
+ every shape that has historically broken the extractor.
111
+
112
+ ### Fixed
113
+ - `<%= tag.xxx(attrs) do %> … <% end %>` now compiles into
114
+ `_tag_open` + body + `_tag_close`, so inner expressions stay
115
+ per-field reactive instead of collapsing into invalid JS.
116
+ - `<%= raw bare_helper %>` extracts the inner call as a server-
117
+ computed raw field (previously emitted an undefined JS identifier).
118
+ - `**@options` keyword-splat on `tag.xxx` no longer emits `#options`
119
+ (a JS private-field reference, which is a syntax error outside a
120
+ class body).
121
+ - Bare helper calls in conditions and tag attrs (`banner_visible?`,
122
+ `row_classes`, `status_label(@x)`) are extracted as server-computed
123
+ fields instead of surfacing as undefined JS identifiers.
124
+ - Bare `<%= @ivar %>` output alongside `<%= @ivar.chain %>` — both
125
+ destructures are now provided in the broadcast payload.
126
+ - ViewComponent sidecar template layout (`foo_component/foo_component.html.erb`)
127
+ is now supported by `Compiler.read_erb`.
128
+ - `escapeHTML` is aliased in the compiled preamble (ruby2js emits it
129
+ in some nested-component paths).
130
+ - `_render_attrs` now expands `data:`/`aria:` hashes, handles mixed
131
+ `class: [string, {name => cond}]` arrays, and emits bare boolean
132
+ attributes — matching Rails tag-builder semantics.
133
+ - Live-model ivar (e.g. `@message` under `subscribes_to :message`) is
134
+ excluded from broadcast payloads — it's the subscription key, not a
135
+ data field.
136
+
137
+ ## [0.3.0] - 2026-04-16
138
+
139
+ ### Added
140
+ - `broadcast_reactive_update` public method on models for manual broadcasts without touching the record
141
+ - Client state rendering: `setState` now re-renders components after updating client state
142
+ - Exclusive client state: `setState` with `exclusive` param deselects sibling components
143
+ - Folder navigation (Inbox, Starred, Sent, Archive, Trash) in dummy app
144
+ - Documentation for `client_state` usage (setState, exclusive mode, selectable lists)
145
+ - Documentation for `broadcast_reactive_update` with examples
146
+ - DataEvaluator tests for path helper resolution
147
+
148
+ ### Fixed
149
+ - Path helpers (e.g. `message_path`) returning nil in reactive broadcasts — added engine initializer to finalize DataEvaluator at boot
150
+ - `setState` not triggering re-render after updating client state
151
+ - Turbo frame navigation breaking when `setState` morphed the DOM synchronously — deferred with `requestAnimationFrame`
152
+ - `live_action` documentation using outdated Stimulus data attribute conventions
153
+
154
+ ## [0.2.0] - 2026-03-25
155
+
156
+ ### Added
157
+ - "How It Works" architecture documentation page
158
+ - "Nested Components" guide
159
+ - "Collections & Loops" guide
160
+ - "Troubleshooting" page
161
+ - Enriched README with architecture summary, advanced features, and license section
162
+ - Reorganized docs sidebar for logical learning path
163
+
3
164
  ## [0.1.0] - 2026-03-15
4
165
 
5
166
  ### Added
@@ -20,23 +20,23 @@ module ReactiveComponent
20
20
  end
21
21
 
22
22
  def request_update(data)
23
- component_class = data["component"].constantize
24
- params = data["params"] || {}
23
+ component_class = data['component'].constantize
24
+ params = data['params'] || {}
25
25
 
26
26
  model_class = component_class.live_model_class
27
- record_id = data["record_id"] || params.delete("record_id")
27
+ record_id = data['record_id'] || params.delete('record_id')
28
28
  record = model_class.find_by(id: record_id)
29
29
  return unless record
30
30
 
31
- if data["record_id"].present?
31
+ if data['record_id'].present?
32
32
  if record_matches?(record, params)
33
- transmit({"action" => "render", "data" => component_class.build_data(record)})
33
+ transmit({ 'action' => 'render', 'data' => component_class.build_data(record) })
34
34
  else
35
- transmit({"action" => "remove", "dom_id" => data["dom_id"]})
35
+ transmit({ 'action' => 'remove', 'dom_id' => data['dom_id'] })
36
36
  end
37
37
  else
38
38
  result = component_class.build_data(record, **params.symbolize_keys)
39
- transmit({"action" => "render", "data" => result})
39
+ transmit({ 'action' => 'render', 'data' => result })
40
40
  end
41
41
  end
42
42
 
@@ -50,7 +50,7 @@ module ReactiveComponent
50
50
 
51
51
  def verified_stream_name
52
52
  Turbo::StreamsChannel.verified_stream_name(params[:signed_stream_name])
53
- rescue
53
+ rescue StandardError
54
54
  nil
55
55
  end
56
56
 
@@ -59,11 +59,11 @@ module ReactiveComponent
59
59
  signed = Turbo::StreamsChannel.signed_stream_name(streamables)
60
60
  stream_name = Turbo::StreamsChannel.verified_stream_name(signed)
61
61
 
62
- payload = {action: action, data: data}
62
+ payload = { action: action, data: data }
63
63
 
64
64
  if compress
65
65
  json = ActiveSupport::JSON.encode(payload)
66
- ActionCable.server.broadcast(stream_name, {z: Base64.strict_encode64(ActiveSupport::Gzip.compress(json))})
66
+ ActionCable.server.broadcast(stream_name, { z: Base64.strict_encode64(ActiveSupport::Gzip.compress(json)) })
67
67
  else
68
68
  ActionCable.server.broadcast(stream_name, payload)
69
69
  end
@@ -10,7 +10,7 @@ module ReactiveComponent
10
10
  component_class.execute_action(
11
11
  params[:action_name],
12
12
  record,
13
- params.fetch(:params, {}).permit!.to_h
13
+ params.fetch(:params, {})
14
14
  )
15
15
 
16
16
  head :ok
@@ -20,9 +20,9 @@ module ReactiveComponent
20
20
 
21
21
  def verify_token!
22
22
  Rails.application.message_verifier(:reactive_component_action)
23
- .verify(params[:token], purpose: :reactive_component_action)
23
+ .verify(params[:token], purpose: :reactive_component_action)
24
24
  rescue ActiveSupport::MessageVerifier::InvalidSignature
25
- raise ActionController::RoutingError, "Not found"
25
+ raise ActionController::RoutingError, 'Not found'
26
26
  end
27
27
  end
28
28
  end
@@ -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 } from "reactive_component/lib/reactive_renderer_utils"
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) => {
@@ -60,6 +60,15 @@ export default class extends Controller {
60
60
  }
61
61
 
62
62
  connect() {
63
+ const clashes = duplicateIds(document, this.element)
64
+ if (clashes.length) {
65
+ console.error(
66
+ `[reactive-renderer] ${clashes.length + 1} components share id "${this.element.id}" — ` +
67
+ "each will render every other's broadcast. Give the component a distinct dom_id_prefix.",
68
+ this.element, ...clashes
69
+ )
70
+ }
71
+
63
72
  this.clientState = { ...this.stateValue }
64
73
  this.lastServerData = Object.keys(this.dataValue).length > 0 ? this.dataValue : null
65
74
 
@@ -142,7 +151,10 @@ export default class extends Controller {
142
151
  }
143
152
 
144
153
  render(data) {
145
- const newHtml = this.renderFn(data)
154
+ // ReactiveComponent.debug marks the wrapper; a missing key then throws
155
+ // with its name rather than rendering as a silent falsy/undefined.
156
+ const input = this.element.hasAttribute("data-reactive-debug") ? strictData(data, this.element.id) : data
157
+ const newHtml = this.renderFn(input)
146
158
  this.morph(newHtml)
147
159
  }
148
160
 
@@ -208,14 +220,24 @@ export default class extends Controller {
208
220
  if (el === this.element) return
209
221
  const ctrl = this.application.getControllerForElementAndIdentifier(el, "reactive-renderer")
210
222
  if (!ctrl?.clientState) return
223
+ let changed = false
211
224
  for (const key of Object.keys(updates)) {
212
- if (ctrl.clientState[key]) ctrl.clientState[key] = false
225
+ if (ctrl.clientState[key]) {
226
+ ctrl.clientState[key] = false
227
+ changed = true
228
+ }
229
+ }
230
+ if (changed && ctrl.lastServerData && ctrl.renderFn) {
231
+ requestAnimationFrame(() => ctrl.render({ ...ctrl.lastServerData, ...ctrl.clientState }))
213
232
  }
214
233
  })
215
234
  }
216
235
  }
217
236
 
218
237
  Object.assign(this.clientState, updates)
238
+ if (this.lastServerData && this.renderFn) {
239
+ requestAnimationFrame(() => this.render({ ...this.lastServerData, ...this.clientState }))
240
+ }
219
241
  }
220
242
 
221
243
  morph(newHtml) {
@@ -97,3 +97,37 @@ export function routeMessage(message, elementId, strategy) {
97
97
 
98
98
  return { type: "ignore" }
99
99
  }
100
+
101
+ // Every element in `doc` that shares `element`'s id, excluding `element`.
102
+ // Broadcasts are routed to a component by its id, so a duplicate means one
103
+ // payload renders into several components — and a TypeError deep in the
104
+ // compiled template as soon as their shapes differ. Checked on connect, which
105
+ // Stimulus already fires for every component entering the DOM (its own
106
+ // MutationObserver), so nothing else needs to watch the page.
107
+ export function duplicateIds(doc, element) {
108
+ if (!element.id) return []
109
+ const selector = `[id="${element.id.replace(/["\\]/g, "\\$&")}"]`
110
+ return [...doc.querySelectorAll(selector)].filter(other => other !== element)
111
+ }
112
+
113
+ // Debug mode only: `data` (and every item inside it) wrapped so that reading
114
+ // a key the payload does not carry THROWS, naming the key and what is there,
115
+ // instead of yielding undefined — which is silently falsy in an `if` and
116
+ // only ever surfaces as a mystery `.x of undefined` further down. Keys on the
117
+ // prototype chain (map, toString, constructor) and symbols pass through, so
118
+ // only a genuinely missing field trips it.
119
+ export function strictData(data, label) {
120
+ const wrap = (target, path) => new Proxy(target, {
121
+ get(obj, key, receiver) {
122
+ if (typeof key === "symbol" || key in obj) {
123
+ const value = Reflect.get(obj, key, receiver)
124
+ return value && typeof value === "object" ? wrap(value, `${path}${String(key)}.`) : value
125
+ }
126
+ throw new TypeError(
127
+ `[reactive-renderer] ${label}: template read "${path}${String(key)}" but the payload only has: ` +
128
+ Object.keys(obj).join(", ")
129
+ )
130
+ }
131
+ })
132
+ return wrap(data, "")
133
+ }
data/config/importmap.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- pin_all_from ReactiveComponent::Engine.root.join("app/javascript/reactive_component"),
4
- under: "reactive_component"
3
+ pin_all_from ReactiveComponent::Engine.root.join('app/javascript/reactive_component'),
4
+ under: 'reactive_component'
data/config/routes.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  ReactiveComponent::Engine.routes.draw do
4
- post "actions", to: "actions#create", as: :reactive_component_actions
4
+ post 'actions', to: 'actions#create', as: :reactive_component_actions
5
5
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module ReactiveComponent
6
+ module Broadcastable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ class_attribute :reactive_component_classes, instance_writer: false, default: Set.new
11
+ end
12
+
13
+ class_methods do
14
+ def register_reactive_component(component_class)
15
+ return if reactive_component_classes.include?(component_class)
16
+
17
+ self.reactive_component_classes = reactive_component_classes | [component_class]
18
+
19
+ return if _commit_callbacks.map(&:filter).include?(:_broadcast_reactive_create)
20
+
21
+ after_create_commit :_broadcast_reactive_create
22
+ after_update_commit :_broadcast_reactive_update
23
+ after_destroy_commit :_broadcast_reactive_destroy
24
+ end
25
+ end
26
+
27
+ def broadcast_reactive_update
28
+ _broadcast_reactive(:update)
29
+ end
30
+
31
+ private
32
+
33
+ def _broadcast_reactive_create = _broadcast_reactive(:create)
34
+ def _broadcast_reactive_update = _broadcast_reactive(:update)
35
+ def _broadcast_reactive_destroy = _broadcast_reactive(:destroy)
36
+
37
+ def _broadcast_reactive(action)
38
+ reactive_component_classes.each { |klass| ReactiveComponent.broadcast_for(klass, self, action: action) }
39
+ end
40
+ end
41
+ end