phlex-reactive 0.2.7 → 0.2.9

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: 479ff63041e0a402939303aec9ea5fc8064dc0b01f5e63c8c58cfe989e5173b3
4
- data.tar.gz: b81323095162d6b0662ff18b91b0c8cb34a8e2000028353c695e255ffa13093f
3
+ metadata.gz: a6b8ebca93e744e1c2fa3797de877cefc732db8a6cb950d95874c9e00e4dee76
4
+ data.tar.gz: 119b1373f8ade989791845fc9cf3206f7786eb97f6009faaabfadb1485c82b44
5
5
  SHA512:
6
- metadata.gz: 80070c0e2868530f8499008a603e6f32c4815916fa34c4fccc050b3fb0772c0501ea27e569ee88ada9acb68d7f3eca5fc7333201fc3b9e4358780abd9b6e5d6a
7
- data.tar.gz: 6412dc6000663fd888193bf9a1b071b9d2f1984692cac474217ebd4955f302e963103bb4bd5ad5c3407b351dfe69a5c2a7079fcdd04b65d73dfed34092c60765
6
+ metadata.gz: ee11a3bf0ac506cdbde2e97e9e47bad436a16050c0b150cd5340bbff9ae846586487bbf6a2bbf028ff20c44faa64f2e57953ddf15adcbafc82491d5e15a9f22f
7
+ data.tar.gz: 3bd5cd4a362ed18f59da8bf375474aa8beabd5cfdd0d62572d5fce043318c057b01afcf3942acaec30417dc16fd3b3d8d9e6ef9f7ec524f55cf411cc168b8003
data/CHANGELOG.md CHANGED
@@ -8,6 +8,36 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Input/select param-binding helpers (issue #23).** `reactive_input(:value,
12
+ …)` and `reactive_select(:status, …) { … }` render a form control already bound
13
+ to an action param — no hand-written `name: "value"` magic string to forget
14
+ (which silently leaves the action with no params). `reactive_field(:param,
15
+ **attrs)` returns just the attribute hash to spread onto any control; an
16
+ explicit `name:` still wins as an escape hatch. The trigger stays on the
17
+ button, so focusing the field doesn't dispatch and collapse edit mode.
18
+ - **`accepts_nested_attributes_for` helper (issue #24).** `nested_update!(:address,
19
+ address)` maps a declared nested param straight onto `<assoc>_attributes` and
20
+ carries the existing associated record's id, so `update_only:` matches it in
21
+ place instead of building a second `has_one` — replacing the per-editor
22
+ `*_attributes` + id-preservation boilerplate that was easy to get subtly wrong.
23
+ `nested_attributes(:address, address)` returns the id-merged hash without
24
+ updating, for combining with other attributes.
25
+ - **`Response#also_update` / `#also_replace` (issue #25).** Re-render a companion
26
+ element alongside self without dropping to raw `turbo_stream_builder`:
27
+ `Response.replace(self).also_update("page_heading", html: @record.name)` adds an
28
+ update stream for an arbitrary DOM id (`html` is a string or a Phlex component,
29
+ rendered through the configured renderer), and `.also_replace(other_component)`
30
+ re-renders another Streamable component targeting its own `#id`. Both are
31
+ immutable and additive, so the self-replace still refreshes the signed token.
32
+ - **Boot-time integration guards (issue #26).** Two silent first-run failures now
33
+ surface a clear warning. A host catch-all route (`match "*path", …`) that
34
+ shadows the engine's appended `POST /reactive/actions` (every reactive POST
35
+ 404s) is detected at boot via a route-recognition check
36
+ (`Phlex::Reactive.action_route_ok?`), logging how to exempt the path. And when
37
+ `data-controller="reactive"` elements are on the page but no controller
38
+ connected — the `lazyLoadControllersFrom` case where the gem's controller was
39
+ never registered — the client runtime logs a console warning naming the fix.
40
+ The README gains an "Integration troubleshooting" section covering both.
11
41
  - **Nested & array param types (issue #16).** Action param schemas can now
12
42
  declare arrays and nested hashes, not just scalars: wrap a type in an array
13
43
  (`bank_account_ids: [:integer]`) for an array param, or wrap a hash schema in
@@ -31,6 +61,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
31
61
 
32
62
  ### Fixed
33
63
 
64
+ - **Model-scoped form fields feed a nested param (issue #21).** A Rails
65
+ `Form(model: @invoice)` posts flat bracketed keys (`invoice[date]`,
66
+ `invoice[status]`) because the client keeps each input's `name` verbatim. The
67
+ server did exact-key matching, so a nested schema (`params: { invoice: { date:
68
+ :string } }`) looked for the literal key `"invoice"`, never found it, and
69
+ dropped the whole param. Param normalization now expands bracket notation
70
+ before coercion — `invoice[date]` nests under `invoice`, and
71
+ `items_attributes[0][qty]` becomes the Rails index-hash form the array coercer
72
+ already understands. A nested schema matches a normal Rails form with zero
73
+ field renaming, which is what makes the issue #16 nested-param types useful for
74
+ real forms. Pre-nested objects, plain scalars, and non-string values (a
75
+ checkbox boolean) pass through unchanged.
34
76
  - **Nested reactive roots no longer leak fields (issue #15).** When a reactive
35
77
  component is rendered inside another (both are `data-controller="reactive"`
36
78
  roots), an action on the outer root previously swept *every* descendant named
data/README.md CHANGED
@@ -124,7 +124,7 @@ Import and register it from your controllers entrypoint:
124
124
 
125
125
  ```js
126
126
  import { application } from "./application"
127
- import ReactiveController from "phlex-reactive/reactive_controller"
127
+ import ReactiveController from "phlex/reactive/reactive_controller"
128
128
  application.register("reactive", ReactiveController)
129
129
  ```
130
130
 
@@ -137,6 +137,47 @@ gem; point your bundler at the gem path or copy it in. See
137
137
  and a Phlex `ApplicationComponent` base class. pgbus is optional but recommended
138
138
  for broadcasting.
139
139
 
140
+ ### Integration troubleshooting (silent "nothing happens")
141
+
142
+ Two host-app setups make the first reactive component *silently do nothing* —
143
+ components render, but no action ever fires, with no error pointing at the cause.
144
+ The gem now logs a warning for each, but here are the fixes:
145
+
146
+ **A catch-all route shadows `POST /reactive/actions`.** The engine appends its
147
+ route *after* everything in your `config/routes.rb`, so a bottom-of-file
148
+ catch-all wins and every reactive POST 404s:
149
+
150
+ ```ruby
151
+ # config/routes.rb — a catch-all like this shadows the engine's appended route
152
+ match "*path", to: "errors#not_found", via: :all
153
+ ```
154
+
155
+ Exempt the reactive path from the catch-all (or set
156
+ `Phlex::Reactive.action_path` to an unshadowed path):
157
+
158
+ ```ruby
159
+ match "*path", to: "errors#not_found", via: :all,
160
+ constraints: ->(req) { !req.path.start_with?("/reactive/") }
161
+ ```
162
+
163
+ At boot the gem warns (`[phlex-reactive] POST /reactive/actions does not resolve
164
+ to phlex/reactive/actions …`) when the route is shadowed.
165
+
166
+ **The `reactive` controller isn't registered (`lazyLoadControllersFrom` apps).**
167
+ `lazyLoadControllersFrom("controllers", application)` only registers controllers
168
+ under `app/javascript/controllers/`. The gem's controller lives outside that dir,
169
+ so `data-controller="reactive"` does nothing until you register it explicitly:
170
+
171
+ ```js
172
+ // app/javascript/controllers/index.js (or your Stimulus entrypoint)
173
+ import ReactiveController from "phlex/reactive/reactive_controller"
174
+ application.register("reactive", ReactiveController)
175
+ ```
176
+
177
+ If reactive elements are on the page but the controller never connected, the
178
+ runtime logs a console warning (`[phlex-reactive] found N element(s) with
179
+ data-controller="reactive" but the reactive controller never connected …`).
180
+
140
181
  ---
141
182
 
142
183
  ## The mental model in one picture
@@ -269,6 +310,9 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
269
310
  | `reactive_attrs` | Spread onto the root element: marks it reactive + carries the signed token. |
270
311
  | `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
271
312
  | `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
313
+ | `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
314
+ | `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
315
+ | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
272
316
 
273
317
  Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
274
318
  in the schema is dropped before reaching your method.
@@ -294,6 +338,18 @@ end
294
338
  Nested coercion recurses per field, drops undeclared nested keys, and accepts an
295
339
  array as either a JSON array or a Rails index hash (`{ "0" => …, "1" => … }`).
296
340
 
341
+ **Model-scoped form fields just work.** A standard Rails `Form(model: @invoice)`
342
+ names its inputs `invoice[date]`, `invoice[status]`, … and the client posts those
343
+ names verbatim. A nested schema matches them with zero field renaming — the
344
+ endpoint expands bracket notation before coercion, so `invoice[date]` nests under
345
+ `invoice` and `invoice_items_attributes[0][qty]` becomes the index-hash form
346
+ above:
347
+
348
+ ```ruby
349
+ action :save, params: {invoice: {date: :string, status: :string}}
350
+ # client posts { "invoice[date]": "…", "invoice[status]": "…" } → save(invoice: { date:, status: })
351
+ ```
352
+
297
353
  **Nested reactive components compose.** A reactive component rendered inside
298
354
  another is its own root — field collection stops at nested
299
355
  `data-controller="reactive"` roots, so an outer action collects only *its own*
@@ -325,6 +381,47 @@ div(**mix(reactive_attrs, id:, class: "card")) { ... }
325
381
  button(**on(:increment), data: { testid: "inc" }) { "+" }
326
382
  ```
327
383
 
384
+ **Binding inputs to action params (drop the magic `name:`).** A field's value
385
+ travels with an action only if its `name` equals the param. Hand-writing
386
+ `name: "value"` on every input is easy to forget — the action then silently gets
387
+ nothing. `reactive_input`/`reactive_select` emit the binding for you (the trigger
388
+ stays on the button, so focusing the field doesn't dispatch and collapse edit
389
+ mode):
390
+
391
+ ```ruby
392
+ action :save, params: { value: :string, status: :string }
393
+
394
+ def view_template
395
+ span(id:, **reactive_attrs) do
396
+ reactive_input(:value, value: @record.name) # <input name="value" …>
397
+ reactive_select(:status) do # <select name="status">…</select>
398
+ %w[open closed].each { |s| option(value: s, selected: s == @record.status) { s } }
399
+ end
400
+ button(**mix(on(:save), data: { testid: "save" })) { "Save" }
401
+ end
402
+ end
403
+ ```
404
+
405
+ `reactive_field(:value, **attrs)` returns just the attribute hash if you'd rather
406
+ spread it onto a control yourself. An explicit `name:` still wins (escape hatch).
407
+
408
+ **Editing an associated record (`accepts_nested_attributes_for`).** `nested_update!`
409
+ maps a declared nested param straight onto `<assoc>_attributes` and carries the
410
+ existing record's id, so `update_only:` matches it in place instead of building a
411
+ second `has_one` (the boilerplate that's easy to get subtly wrong):
412
+
413
+ ```ruby
414
+ # Account has_one :address; accepts_nested_attributes_for :address, update_only: true
415
+ action :save, params: { address: { street: :string, city: :string } }
416
+
417
+ def save(address:)
418
+ nested_update!(:address, address) # update!(address_attributes: address.merge(id: @account.address&.id))
419
+ end
420
+ ```
421
+
422
+ `nested_attributes(:address, address)` returns the id-merged hash without
423
+ updating, if you need to combine it with other attributes.
424
+
328
425
  ### `Phlex::Reactive::Response` — controlling the action's reply
329
426
 
330
427
  By default an action re-renders its component in place. **Return** a
@@ -347,18 +444,29 @@ end
347
444
  def approve = (@row.approve!; Response.remove(self)) # drop the element
348
445
  def publish = (@article.publish!; Response.redirect(article_url(@article))) # slug changed → Turbo.visit
349
446
  def add(item:) = Response.replace(self).stream(Totals.update(@order)) # multi-stream
447
+
448
+ # Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
449
+ def rename(value:) = (@account.update!(name: value); Response.replace(self).also_update("page_heading", html: @account.name))
350
450
  ```
351
451
 
352
452
  | Builder | Reply |
353
453
  |---|---|
354
454
  | `Response.replace(self)` / `.update(self)` | re-render in place (explicit default) |
355
- | `.flash(level, content, target:)` | append a flash; `content` is a string or Phlex component (off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
455
+ | `.also_update(target, html:)` | also re-render a companion element by DOM id; `html` is a plain string (escaped) or a Phlex component |
456
+ | `.also_replace(component)` | also re-render another Streamable component, targeting its own `#id` |
457
+ | `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped) or a Phlex component (off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
356
458
  | `Response.remove(self)` | remove the element (backed by `Streamable#to_stream_remove`) |
357
459
  | `Response.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
358
460
  | `Response.with(*streams)` / `#stream(*more)` | multi-stream |
359
461
 
360
- `.flash`/`.stream` are additive on a self-replace, so the component's signed
361
- token always refreshes.
462
+ `.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's
463
+ signed token always refreshes.
464
+
465
+ > **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
466
+ > `html: @account.name` is safe even for user-supplied values. To emit intentional
467
+ > markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —
468
+ > rendered and auto-escaped through the renderer — or an `html_safe` string for
469
+ > raw HTML you control.
362
470
 
363
471
  ### Configuration (`config/initializers/phlex_reactive.rb`)
364
472
 
@@ -201,12 +201,69 @@ module Phlex
201
201
  end
202
202
 
203
203
  # Unwrap ActionController::Parameters (or a plain Hash) to a string-keyed
204
- # Hash so coercion can index it uniformly.
204
+ # Hash so coercion can index it uniformly, then expand bracket notation so
205
+ # a model-scoped Rails form's flat keys nest (issue #21).
205
206
  def to_param_hash(value)
206
- return value.to_unsafe_h.stringify_keys if value.respond_to?(:to_unsafe_h)
207
- return value.stringify_keys if value.is_a?(Hash)
207
+ flat =
208
+ if value.respond_to?(:to_unsafe_h)
209
+ value.to_unsafe_h.stringify_keys
210
+ elsif value.is_a?(Hash)
211
+ value.stringify_keys
212
+ else
213
+ return {}
214
+ end
215
+
216
+ expand_bracket_keys(flat)
217
+ end
218
+
219
+ # The client's #collectFields keeps a form input's name verbatim, so a
220
+ # Rails Form(model: @invoice) posts FLAT bracketed keys like
221
+ # "invoice[date]". Coercion does exact-key matching, so without this a
222
+ # nested schema (params: { invoice: { date: … } }) never finds "invoice"
223
+ # and drops everything. Expand "invoice[date]" => "2026-01-02" into
224
+ # { "invoice" => { "date" => "2026-01-02" } } — and "items[0][qty]" into
225
+ # the Rails index-hash form coerce_array already understands — deep-merging
226
+ # so sibling keys (invoice[date], invoice[status]) coalesce. Keys WITHOUT
227
+ # brackets and already-nested values pass through untouched, so a
228
+ # pre-nested object (issue #16) and plain scalars still work. Value types
229
+ # (a checkbox boolean, an explicit array) are preserved verbatim — unlike a
230
+ # round-trip through parse_nested_query, which only handles strings.
231
+ def expand_bracket_keys(flat)
232
+ flat.each_with_object({}) do |(key, value), out|
233
+ deep_assign(out, bracket_path(key), value)
234
+ end
235
+ end
236
+
237
+ # "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
238
+ # "0", "qty"]. A key with no brackets is a single-element path.
239
+ def bracket_path(key)
240
+ return [key] unless key.include?("[")
241
+
242
+ head, rest = key.split("[", 2)
243
+ [head, *rest.scan(/[^\[\]]+/)]
244
+ end
245
+
246
+ # Walk/create nested hashes along `path`, then merge `value` at the leaf so
247
+ # a bracket key and a sibling pre-nested object coalesce regardless of which
248
+ # arrives first ({ "invoice[date]" => …, invoice: { status: … } } keeps
249
+ # both). #merge_value deep-merges hash/hash collisions and otherwise lets
250
+ # the later value win (a bracket key colliding with a flat scalar).
251
+ def deep_assign(hash, path, value)
252
+ *parents, leaf = path
253
+ node = parents.reduce(hash) do |acc, segment|
254
+ acc[segment] = {} unless acc[segment].is_a?(Hash)
255
+ acc[segment]
256
+ end
257
+ node[leaf] = merge_value(node[leaf], value)
258
+ end
259
+
260
+ # Combine an existing leaf value with a new one. Two hashes deep-merge (so
261
+ # bracket-expanded fields and a pre-nested object for the same key both
262
+ # survive); any other collision takes the new value.
263
+ def merge_value(existing, value)
264
+ return value unless existing.is_a?(Hash) && value.is_a?(Hash)
208
265
 
209
- {}
266
+ existing.merge(value.stringify_keys) { |_k, old, new| merge_value(old, new) }
210
267
  end
211
268
 
212
269
  # Only components that opt into Reactive may be resolved. The signature
@@ -36,6 +36,49 @@ if (typeof window !== "undefined") {
36
36
  else document.addEventListener("turbo:load", registerReactiveVisit, { once: true })
37
37
  }
38
38
 
39
+ // --- Registration guard (issue #26 part 2) -------------------------------
40
+ // In a `lazyLoadControllersFrom("controllers", application)` app, only
41
+ // controllers under app/javascript/controllers/ are registered. This module
42
+ // lives outside that dir, so importing it isn't enough — `data-controller=
43
+ // "reactive"` does NOTHING until the host runs application.register("reactive",
44
+ // ...). The failure is silent: components render, but no action ever fires.
45
+ //
46
+ // We can't warn from connect() in that case (connect never runs). Instead, once
47
+ // the page is ready, if reactive elements exist but no controller has connected,
48
+ // the controller wasn't registered — so we warn, pointing at the fix.
49
+ let reactiveConnected = false
50
+
51
+ export function checkReactiveRegistration() {
52
+ if (reactiveConnected) return
53
+ if (typeof document === "undefined") return
54
+ const els = document.querySelectorAll('[data-controller~="reactive"]')
55
+ if (!els || els.length === 0) return
56
+ console.warn(
57
+ "[phlex-reactive] found " + els.length + ' element(s) with data-controller="reactive" ' +
58
+ "but the reactive controller never connected. It is loaded but not registered — " +
59
+ 'add `application.register("reactive", ReactiveController)` (importmap) or import it ' +
60
+ "into app/javascript/controllers/ for lazyLoadControllersFrom apps. See the README."
61
+ )
62
+ }
63
+
64
+ // Test seams (no-ops in production usage).
65
+ export function __resetReactiveRegistrationForTest() {
66
+ reactiveConnected = false
67
+ }
68
+ export function __markReactiveConnectedForTest() {
69
+ reactiveConnected = true
70
+ }
71
+
72
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
73
+ // Defer past initial controller connection (a microtask/tick after ready).
74
+ const scheduleCheck = () => setTimeout(checkReactiveRegistration, 0)
75
+ if (document.readyState === "loading") {
76
+ document.addEventListener("DOMContentLoaded", scheduleCheck, { once: true })
77
+ } else {
78
+ scheduleCheck()
79
+ }
80
+ }
81
+
39
82
  // Register this controller eagerly (not lazily) so a click immediately after
40
83
  // page load is never missed. The phlex-reactive engine auto-pins it with
41
84
  // preload: true for importmap apps; see the README for esbuild/webpack.
@@ -47,6 +90,12 @@ export default class extends Controller {
47
90
  #tokenCache // freshest token, threaded synchronously across queued requests
48
91
  #debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
49
92
 
93
+ // Mark that a reactive controller actually connected, so the registration
94
+ // guard above knows the controller was registered (issue #26 part 2).
95
+ connect() {
96
+ reactiveConnected = true
97
+ }
98
+
50
99
  // Tear down any pending debounce timers when the controller leaves the DOM
51
100
  // (Turbo morph/navigation removes the element). Otherwise a timer that hasn't
52
101
  // fired yet would later call #enqueue on a disconnected controller — a round
@@ -206,8 +206,72 @@ module Phlex
206
206
  attrs
207
207
  end
208
208
 
209
+ # Bind a form control's `name` to an action param so its value travels with
210
+ # the action — instead of hand-writing the magic `name: "value"` on every
211
+ # input and silently getting no params when you forget it (issue #23).
212
+ # Returns a Phlex attributes hash to spread onto any control:
213
+ # input(**reactive_field(:value, value: @record.name))
214
+ # select(**reactive_field(:status)) { ... }
215
+ # Extra attrs merge over the binding; an explicit name: still wins (escape
216
+ # hatch). The trigger (on(:save)) stays on the button, not the field — so
217
+ # focusing the input doesn't dispatch and collapse edit mode.
218
+ def reactive_field(param, **attrs)
219
+ {name: param.to_s, **attrs}
220
+ end
221
+
222
+ # Render an <input> already bound to an action param (issue #23). Sugar for
223
+ # input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
224
+ # reactive_input(:value, value: @record.name, type: "text")
225
+ def reactive_input(param, **attrs)
226
+ input(**reactive_field(param, **attrs))
227
+ end
228
+
229
+ # Render a <select> bound to an action param (issue #23). The options block
230
+ # is the element's content, so the awkward FormBuilder positional split
231
+ # (where name: lands after the options/html-options args) goes away:
232
+ # reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
233
+ def reactive_select(param, **attrs, &block)
234
+ select(**reactive_field(param, **attrs), &block)
235
+ end
236
+
237
+ # Map a declared nested param onto Rails' <assoc>_attributes, carrying the
238
+ # existing associated record's id so accepts_nested_attributes_for matches
239
+ # it IN PLACE instead of building a second one (issue #24). Returns the
240
+ # update hash; pass it to update!:
241
+ # def save(address:) = nested_update!(:address, address)
242
+ # The id is only added when the association already exists, so the first
243
+ # save (no associated record yet) creates one cleanly. The given attrs are
244
+ # not mutated.
245
+ def nested_attributes(association, attrs)
246
+ merged = attrs.dup
247
+ existing = reactive_record_for_nested.public_send(association)
248
+ merged[:id] = existing.id if existing
249
+
250
+ {"#{association}_attributes": merged}
251
+ end
252
+
253
+ # Map a nested param onto <assoc>_attributes (with id preservation) AND
254
+ # apply it to the component's record in one call (issue #24). Extra keyword
255
+ # attributes update alongside the association.
256
+ # def save(address:, name:) = nested_update!(:address, address, name:)
257
+ def nested_update!(association, attrs, **extra)
258
+ reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
259
+ end
260
+
209
261
  private
210
262
 
263
+ # The component's record, for the nested-attributes helpers. Requires a
264
+ # declared reactive_record (the nested helper only makes sense for a
265
+ # record-backed component).
266
+ def reactive_record_for_nested
267
+ key = self.class.reactive_record_key
268
+ unless key
269
+ raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes"
270
+ end
271
+
272
+ instance_variable_get(:"@#{key}")
273
+ end
274
+
211
275
  # Signed identity payload: the class name plus whichever identity pieces
212
276
  # the component declares — a record GlobalID (`gid`), signed state (`s`),
213
277
  # or both. Keeping them in ONE MessageVerifier payload makes the state
@@ -40,6 +40,15 @@ module Phlex
40
40
  )
41
41
  end
42
42
  end
43
+
44
+ # Boot-time guard (issue #26): warn if the action path doesn't resolve to
45
+ # the gem controller. Runs after_initialize so the host's full route set
46
+ # (including a bottom-of-file catch-all that would shadow our appended
47
+ # route) is drawn. Turns the opaque "every reactive POST 404s" failure into
48
+ # a one-line log pointing at the cause. No-op when the route is fine.
49
+ config.after_initialize do
50
+ Phlex::Reactive.warn_unless_action_route_mounted!
51
+ end
43
52
  end
44
53
  end
45
54
  end
@@ -12,6 +12,7 @@ module Phlex
12
12
  #
13
13
  # Response.replace(self) # re-render in place (the default, explicit)
14
14
  # Response.replace(self).flash(:error, msg) # surface a validation error
15
+ # Response.replace(self).also_update("heading", html: @record.name) # + a companion element
15
16
  # Response.remove(self) # drop the element (e.g. moderation queue)
16
17
  # Response.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
17
18
  # Response.replace(self).stream(Totals.update(@order)) # multi-stream
@@ -44,8 +45,27 @@ module Phlex
44
45
  # supplied by the caller because the render context is off-request
45
46
  # (there is no Rails `flash`).
46
47
  def flash_stream(_level, content, target:)
47
- html = content.is_a?(::Phlex::SGML) ? Phlex::Reactive.render(content) : content.to_s
48
- Phlex::Reactive.flash_builder.append(target, html: html)
48
+ Phlex::Reactive.flash_builder.append(target, html: render_html(content))
49
+ end
50
+
51
+ # Build a turbo-stream that updates an arbitrary target id with `content`
52
+ # (a Phlex component instance or an HTML string). Used by #also_update to
53
+ # re-render a companion element that isn't itself a Streamable component.
54
+ def update_stream(target, content)
55
+ Phlex::Reactive.flash_builder.update(target, html: render_html(content))
56
+ end
57
+
58
+ # Resolve `content` to the HTML for a turbo-stream's `html:`. Two forms,
59
+ # both SAFE against injection by default:
60
+ # * a Phlex component instance — rendered through the configured
61
+ # renderer, which auto-escapes interpolated values.
62
+ # * any other value — coerced with to_s and handed to Turbo's
63
+ # TagBuilder, which HTML-ESCAPES a plain String. So a model value
64
+ # (`html: @record.name`) cannot inject markup. To emit intentional
65
+ # raw HTML, pass an `html_safe` String (Turbo leaves those verbatim)
66
+ # or a Phlex component. Same contract as the pre-existing flash_stream.
67
+ def render_html(content)
68
+ content.is_a?(::Phlex::SGML) ? Phlex::Reactive.render(content) : content.to_s
49
69
  end
50
70
  end
51
71
 
@@ -76,6 +96,27 @@ module Phlex
76
96
  stream(self.class.flash_stream(level, content, target:))
77
97
  end
78
98
 
99
+ # Also re-render a COMPANION element alongside self — a page heading, a
100
+ # summary card, a badge that recomputes from the saved value (issue #25).
101
+ # `target` is the sibling element's DOM id. `html` is either:
102
+ # * a plain String — HTML-ESCAPED by Turbo, so a model value is safe:
103
+ # Response.replace(self).also_update("page_heading", html: @record.name)
104
+ # * a Phlex component — rendered + auto-escaped through the renderer (use
105
+ # this when the companion has its own markup), or an `html_safe` String
106
+ # for intentional raw HTML.
107
+ # Returns a NEW Response (immutable). The common "re-render self + N
108
+ # siblings" case no longer needs raw turbo_stream_builder.
109
+ def also_update(target, html:)
110
+ stream(self.class.update_stream(target, html))
111
+ end
112
+
113
+ # Like #also_update, but renders ANOTHER Streamable component and replaces
114
+ # it by its own #id — for a companion that is itself a component.
115
+ # Response.replace(self).also_replace(SummaryCard.new(order: @order))
116
+ def also_replace(component)
117
+ stream(component.to_stream_replace)
118
+ end
119
+
79
120
  def redirect? = !@redirect_url.nil?
80
121
  def render_self? = @render_self
81
122
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.2.7"
5
+ VERSION = "0.2.9"
6
6
  end
7
7
  end
@@ -133,8 +133,62 @@ module Phlex
133
133
  Thread.current[:phlex_reactive_connection_id] = previous
134
134
  end
135
135
 
136
+ # The controller a correctly-mounted action path resolves to. Used by the
137
+ # route guard below.
138
+ ACTIONS_CONTROLLER = "phlex/reactive/actions"
139
+
140
+ # True when a POST to `path` resolves to the gem's ActionsController. A host
141
+ # catch-all route (match "*path", ...) appended above the engine's route
142
+ # SHADOWS it, so every reactive POST 404s and none of the controller runs —
143
+ # the opaque "is the endpoint even mounted?" failure (issue #26). A false
144
+ # here is the signal. Returns false (not raise) when nothing matches.
145
+ def action_route_ok?(path = action_path)
146
+ return false unless defined?(::Rails) && ::Rails.application
147
+
148
+ # At after_initialize (when the boot guard runs) the host's routes may not
149
+ # be drawn yet, so recognize_path would see an incomplete set and report a
150
+ # false shadow. Force-load routes first (idempotent — no-op if already
151
+ # loaded), so the check is correct whether it runs at boot or at runtime.
152
+ ensure_routes_loaded
153
+ recognized = ::Rails.application.routes.recognize_path(path, method: :post)
154
+ recognized[:controller] == ACTIONS_CONTROLLER
155
+ rescue ActionController::RoutingError, ActiveRecord::RecordNotFound
156
+ false
157
+ end
158
+
159
+ # Log a clear warning (once, at boot) when the action path doesn't resolve
160
+ # to the gem controller — pointing at the catch-all shadow rather than
161
+ # leaving an adopter to guess. Called from the engine's after_initialize.
162
+ def warn_unless_action_route_mounted!(path: action_path, logger: default_logger)
163
+ return if action_route_ok?(path)
164
+ return unless logger
165
+
166
+ logger.warn(
167
+ "[phlex-reactive] POST #{path} does not resolve to #{ACTIONS_CONTROLLER}. " \
168
+ "A host catch-all route (e.g. match \"*path\", ...) likely shadows it, so reactive " \
169
+ "actions will 404. Exempt #{path.sub(%r{\A/}, "")} from the catch-all, or set " \
170
+ "Phlex::Reactive.action_path to an unshadowed path. See the README integration section."
171
+ )
172
+ end
173
+
136
174
  private
137
175
 
176
+ # Materialize the route set if it hasn't been drawn yet (the engine appends
177
+ # POST /reactive/actions when routes are drawn, which may be after the boot
178
+ # guard's after_initialize). Idempotent; tolerant of older Rails.
179
+ def ensure_routes_loaded
180
+ reloader = ::Rails.application.routes_reloader
181
+ if reloader.respond_to?(:execute_unless_loaded)
182
+ reloader.execute_unless_loaded
183
+ elsif ::Rails.application.respond_to?(:reload_routes_unless_loaded)
184
+ ::Rails.application.reload_routes_unless_loaded
185
+ end
186
+ end
187
+
188
+ def default_logger
189
+ ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
190
+ end
191
+
138
192
  def default_verifier
139
193
  unless defined?(::Rails) && ::Rails.application
140
194
  raise Error, "Phlex::Reactive.verifier is unset and Rails.application is unavailable; " \
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.7
4
+ version: 0.2.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson