reactive_component 0.1.0 → 0.6.2

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: b0c408bc69906500eee92927b43eb4f307039d4faab5e6fda80b5461ba922db0
4
+ data.tar.gz: 6ccf997e199cdd409138b48b187199b55c625dcdb165aabb808c2ed0657a23f6
5
5
  SHA512:
6
- metadata.gz: cc558b9d15841f9cab6c359450f3324c4f98a38078240437e951a60699e3eeb33cd58acd057622926b62c094fc9b1d68aa58b971fa70efa779a22d8bc83fbe81
7
- data.tar.gz: 1a785c4e6d55be8b767d90b2a483eb5415d684da8513cf1a96c6abdb8811273a6bc5890bcd08f8ce65a6b4cd3043dfc0a8ed2f8e6e2c9d9f5a5522b23f1efd8f
6
+ metadata.gz: 689c1ffeab1b2b2c93b7df69765a9a0660c9cd5c215704b63cd5ac9128d152ac34bd39b0f8934b2798e37a649b3d6b2486aad3cf97d977f0cf3c374885f42a92
7
+ data.tar.gz: 6596df41e9f41b447e9e8365c5f7c1fd8603fca4212eb2f15e429278a78e65c09c929edf753d353f1f65311533c9018bdcb4236aadf0d47a97a4964cdbb6af71
data/CHANGELOG.md CHANGED
@@ -1,5 +1,155 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.2] - 2026-09-06
4
+
5
+ ### Changed
6
+ - Gem metadata points at the right places: homepage and documentation
7
+ are the GitHub Pages docs, source/changelog/issues are
8
+ `lluzak/reactive_component`. (First release on RubyGems since 0.1.0.)
9
+
10
+ ## [0.6.1] - 2026-09-06
11
+
12
+ ### Changed
13
+ - Docs describe the 0.6 compiler (Prism, prefixed ids, typed conditions,
14
+ compile errors); the live-action examples use the real Stimulus params.
15
+ - RuboCop pinned to `~> 1.90.0` so CI and local lint agree. The 0.6.0 tag
16
+ was never published: its commit failed lint on CI.
17
+
18
+ ## [0.6.0] - 2026-09-05
19
+
20
+ ### Changed
21
+ - **ruby2js is gone; the compiler runs on Prism's own tree.** One pass
22
+ over the Prism AST lifts every Ruby expression to a server-evaluated
23
+ data key and emits the template skeleton as JavaScript — a whitelist
24
+ (literals, data reads, `if`/`unless`/ternaries, boolean and comparison
25
+ operators, `.each`, the `_tag*`/`_render_*` helpers). Anything else raises
26
+ `ReactiveComponent::CompileError` naming the source, where ruby2js used
27
+ to guess (`present?` became a `.present` property read). Dependencies:
28
+ `ruby2js` out, `erubi` in; no `parser` gem. The compiled JS keeps the
29
+ same shape (`function render({ … })`, `_buf +=`, `for..of`,
30
+ `_tag_open(...)`).
31
+ - `"#{@x}!"` — an interpolated string with an ivar or helper — is now
32
+ server-evaluated and escaped like any other expression, instead of being
33
+ emitted as an unescaped template literal.
34
+ - The extractor now lifts a chain rooted at a self call
35
+ (`content.present?`, `current_user.name`) whole, like an ivar chain.
36
+ - **Wrapper ids are now component-prefixed by default** —
37
+ `message_row_message_1` instead of `message_1`. Two components rendering
38
+ the same record previously shared one id, and because broadcasts are
39
+ routed by id each rendered the *other's* payload: a `TypeError` deep in
40
+ the compiled template as soon as the shapes differed, plus duplicate ids
41
+ in the page. `dom_id_prefix` still overrides the default. Anything that
42
+ targeted the old bare ids (CSS, Turbo Stream targets, tests) needs the
43
+ new prefix.
44
+
45
+ ### Fixed
46
+ - **Block variables in conditions.** Inside a `.each`, an item read in a
47
+ non-output position — `<% if item.flag %>`, a ternary, `"x" if item.y` —
48
+ passed through ruby2js as `item.flag`, a property the client never
49
+ receives (items ship as their extracted expressions only, never the raw
50
+ record). Conditions were silently false after every broadcast and
51
+ `item.x.present?` threw `Cannot read properties of undefined`. They are
52
+ now server-evaluated per item like output expressions, but keep their
53
+ type (`"false"` is truthy in JS).
54
+
55
+ ### Added
56
+ - **Compile-time invariant for loops.** After a `.each` body is processed,
57
+ any surviving read of the loop variable that is not an extracted
58
+ `item["vN"]` raises `ReactiveComponent::CompileError` naming the
59
+ expression — an item ships only as its extracted expressions, so such a
60
+ read could never resolve on the client. Guards the block-variable fix
61
+ against new syntactic positions.
62
+ - **Debug-mode strict payloads.** With `ReactiveComponent.debug` on, the
63
+ client renders through a Proxy that throws on reading a key the payload
64
+ does not carry, naming the key, its path, and the keys present — instead
65
+ of an `undefined` that is silently falsy in an `if`.
66
+ - The Stimulus controller checks for other elements sharing its id on
67
+ connect and logs a `console.error` naming the collision and the fix.
68
+ Piggybacks on Stimulus's own MutationObserver, so it also catches
69
+ hand-picked `dom_id_prefix`es that collide.
70
+
71
+ ## [0.4.1] - 2026-05-29
72
+
73
+ ### Changed
74
+ - Bumped Rails to 8.1.3 (and matching patches across the 8.1.x stack)
75
+ in development and test gemfiles.
76
+ - Upgraded `view_component` to 4.11.0 — full test suite (119 runs,
77
+ 308 assertions) green on Rails 7.1, 7.2, and 8.0 appraisals.
78
+ - Refreshed transitive dependencies (rack 3.2.6, nokogiri 1.19.3,
79
+ loofah 2.25.1, zeitwerk 2.8.2, propshaft 1.3.2, sqlite3 2.9.4,
80
+ rubocop 1.86.2, etc.).
81
+
82
+ ## [0.4.0] - 2026-04-18
83
+
84
+ ### Security
85
+ - **Broadcast payloads now refuse non-primitive values.** Previously
86
+ `build_data` could ship full `ActiveRecord` records over ActionCable,
87
+ leaking every column (including `password_digest`, tokens). The
88
+ sanitizer now raises `ReactiveComponent::UnsafeBroadcastValueError`
89
+ with a context-aware hint pointing at the offending ERB expression.
90
+
91
+ ### Added
92
+ - `ReactiveComponent.sanitize_for_broadcast(value, source:)` — the
93
+ strict gatekeeper. Allows primitives (`nil`, booleans, `Integer`,
94
+ `Float`, `String`), `Symbol` (downcast to `String`), and `Array`/
95
+ `Hash` of those; raises on everything else.
96
+ - Node-based compiler regression test: actually executes compiled
97
+ templates so undefined-identifier runtime bugs fail the build.
98
+ - `RichRowComponent` + `WrapperComponent` dummy fixtures exercising
99
+ every shape that has historically broken the extractor.
100
+
101
+ ### Fixed
102
+ - `<%= tag.xxx(attrs) do %> … <% end %>` now compiles into
103
+ `_tag_open` + body + `_tag_close`, so inner expressions stay
104
+ per-field reactive instead of collapsing into invalid JS.
105
+ - `<%= raw bare_helper %>` extracts the inner call as a server-
106
+ computed raw field (previously emitted an undefined JS identifier).
107
+ - `**@options` keyword-splat on `tag.xxx` no longer emits `#options`
108
+ (a JS private-field reference, which is a syntax error outside a
109
+ class body).
110
+ - Bare helper calls in conditions and tag attrs (`banner_visible?`,
111
+ `row_classes`, `status_label(@x)`) are extracted as server-computed
112
+ fields instead of surfacing as undefined JS identifiers.
113
+ - Bare `<%= @ivar %>` output alongside `<%= @ivar.chain %>` — both
114
+ destructures are now provided in the broadcast payload.
115
+ - ViewComponent sidecar template layout (`foo_component/foo_component.html.erb`)
116
+ is now supported by `Compiler.read_erb`.
117
+ - `escapeHTML` is aliased in the compiled preamble (ruby2js emits it
118
+ in some nested-component paths).
119
+ - `_render_attrs` now expands `data:`/`aria:` hashes, handles mixed
120
+ `class: [string, {name => cond}]` arrays, and emits bare boolean
121
+ attributes — matching Rails tag-builder semantics.
122
+ - Live-model ivar (e.g. `@message` under `subscribes_to :message`) is
123
+ excluded from broadcast payloads — it's the subscription key, not a
124
+ data field.
125
+
126
+ ## [0.3.0] - 2026-04-16
127
+
128
+ ### Added
129
+ - `broadcast_reactive_update` public method on models for manual broadcasts without touching the record
130
+ - Client state rendering: `setState` now re-renders components after updating client state
131
+ - Exclusive client state: `setState` with `exclusive` param deselects sibling components
132
+ - Folder navigation (Inbox, Starred, Sent, Archive, Trash) in dummy app
133
+ - Documentation for `client_state` usage (setState, exclusive mode, selectable lists)
134
+ - Documentation for `broadcast_reactive_update` with examples
135
+ - DataEvaluator tests for path helper resolution
136
+
137
+ ### Fixed
138
+ - Path helpers (e.g. `message_path`) returning nil in reactive broadcasts — added engine initializer to finalize DataEvaluator at boot
139
+ - `setState` not triggering re-render after updating client state
140
+ - Turbo frame navigation breaking when `setState` morphed the DOM synchronously — deferred with `requestAnimationFrame`
141
+ - `live_action` documentation using outdated Stimulus data attribute conventions
142
+
143
+ ## [0.2.0] - 2026-03-25
144
+
145
+ ### Added
146
+ - "How It Works" architecture documentation page
147
+ - "Nested Components" guide
148
+ - "Collections & Loops" guide
149
+ - "Troubleshooting" page
150
+ - Enriched README with architecture summary, advanced features, and license section
151
+ - Reorganized docs sidebar for logical learning path
152
+
3
153
  ## [0.1.0] - 2026-03-15
4
154
 
5
155
  ### 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
@@ -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