phlex-reactive 0.2.8 → 0.3.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: 9b321b7411a892ec63333f9d85d988f7cf63609601386a03593efd736e88fb42
4
- data.tar.gz: 00d40c394774eb54af634d328251f646a3fae1ac9bc0413ccbc89dd863608001
3
+ metadata.gz: 7232501319d17c45c8907195117f5af59d8b7a4ff9c8d5efb2c0c8248350041e
4
+ data.tar.gz: 783cd0758a5a51cead67f6f1ea2c8bf23245f62e49a38ea0831328b68b9d0b2c
5
5
  SHA512:
6
- metadata.gz: bd7a2129ec09c6b33a9f623f201037f5bc6639f7b097c95f521a6bf7ba4999b8dc8e11f53f2b922f5e617d0dfd8e2b0185cc5676e20f040a9caf97637d2d9438
7
- data.tar.gz: b6e611eab9a2ab3f3d3a6afff9c4780061ba07321ddb5db89970e076facc9d7a374f7809fe8bc38958040c34303a42484eefe25109495bdfb2b6f894a597ae3e
6
+ metadata.gz: a673a7253c99e7b6fccb5228014a47d2918cda2995de79afdb6545adb89a8af8ab99812569455bc7f206c12d7c7efb710ff90d628d767da012ba5deb690e5ab7
7
+ data.tar.gz: cf68ac7ff60925c73aa0de543665b60d85d8254bada63a40c1786a6a3e7ee3bfceaad123c91daf2f7fec18de8aab04ab61d2ee165b5e0ad241b451ce60c24769
data/CHANGELOG.md CHANGED
@@ -8,6 +8,77 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **`reply.streams` — partial update with a token-only refresh (issue #30).**
12
+ `reply.streams(Totals.update(@item))` emits **exactly** the streams you pass —
13
+ no forced full-self replace — so an action can re-render only part of a
14
+ component (one total cell, one sub-region) while the rest of the DOM, including
15
+ a sibling `<input>` the user is mid-typing in, is left untouched. The signed
16
+ identity token still rolls forward: the endpoint appends a tiny
17
+ `<turbo-stream action="reactive:token">` (new `Streamable#to_stream_token`) that
18
+ carries the fresh `data-reactive-token-value` and is applied by an inert client
19
+ action — a pure attribute write on the root, so the focused field + caret
20
+ survive. This unblocks spreadsheet-like per-field grid editing that the old
21
+ "every reply re-renders the whole component" behavior made unusable (you had to
22
+ reach for `data-turbo-permanent`). `Response.streams(self, *)` is the underlying
23
+ builder; `reply.streams(*)` is the bound form. Backed by a new `render_self:
24
+ false` + `token_component` path in the endpoint — the legacy full-self-replace
25
+ refresh (`reply.replace` / `reply.with`) is unchanged.
26
+
27
+ - **`reply` — the action-reply builder.** Control an action's reply with
28
+ `reply.replace` / `reply.morph` / `reply.update` / `reply.remove` /
29
+ `reply.redirect(url)` / `reply.with(*)`, chaining `.flash` / `.stream` /
30
+ `.also_update` / `.also_replace` as before. `reply` is a subject-bound facade on
31
+ the component, so the two warts of the old form disappear: no `self` to thread
32
+ (`reply.morph`, not `Response.morph(self)`) and no constant to qualify — a
33
+ namespaced component no longer needs a per-file `Response = …` alias, because
34
+ `reply` is a method resolved on the component, not a lexical constant. It
35
+ returns the same immutable value object the endpoint reads, so the return-value
36
+ contract, immutability, and chaining are unchanged. `Phlex::Reactive::Response`
37
+ remains fully functional but is now an internal detail — `reply` is the
38
+ documented surface.
39
+
40
+ - **Morph response — focus-preserving re-render (issue #28).**
41
+ `reply.morph` (and the opt-in `reply.replace(morph: true)`)
42
+ re-renders a component in place via Turbo 8's bundled Idiomorph
43
+ (`<turbo-stream action="replace" method="morph">`) instead of an outerHTML
44
+ swap. The focused `<input>` and its caret survive the save, making per-field
45
+ reactive editing — a "spreadsheet" grid where a debounced save fires while the
46
+ user is still typing/tabbing — actually viable. Backed by the new
47
+ `Streamable#to_stream_morph` primitive and a `morph:` flag on the
48
+ `.replace` / `.broadcast_replace_to` class builders and `#also_replace`
49
+ (live cross-tab updates can morph too). The default everywhere stays the plain
50
+ replace (no `method="morph"` attribute), so existing components are unchanged.
51
+ No new dependency — Idiomorph ships with `turbo-rails >= 2.0`.
52
+ - **Input/select param-binding helpers (issue #23).** `reactive_input(:value,
53
+ …)` and `reactive_select(:status, …) { … }` render a form control already bound
54
+ to an action param — no hand-written `name: "value"` magic string to forget
55
+ (which silently leaves the action with no params). `reactive_field(:param,
56
+ **attrs)` returns just the attribute hash to spread onto any control; an
57
+ explicit `name:` still wins as an escape hatch. The trigger stays on the
58
+ button, so focusing the field doesn't dispatch and collapse edit mode.
59
+ - **`accepts_nested_attributes_for` helper (issue #24).** `nested_update!(:address,
60
+ address)` maps a declared nested param straight onto `<assoc>_attributes` and
61
+ carries the existing associated record's id, so `update_only:` matches it in
62
+ place instead of building a second `has_one` — replacing the per-editor
63
+ `*_attributes` + id-preservation boilerplate that was easy to get subtly wrong.
64
+ `nested_attributes(:address, address)` returns the id-merged hash without
65
+ updating, for combining with other attributes.
66
+ - **`Response#also_update` / `#also_replace` (issue #25).** Re-render a companion
67
+ element alongside self without dropping to raw `turbo_stream_builder`:
68
+ `Response.replace(self).also_update("page_heading", html: @record.name)` adds an
69
+ update stream for an arbitrary DOM id (`html` is a string or a Phlex component,
70
+ rendered through the configured renderer), and `.also_replace(other_component)`
71
+ re-renders another Streamable component targeting its own `#id`. Both are
72
+ immutable and additive, so the self-replace still refreshes the signed token.
73
+ - **Boot-time integration guards (issue #26).** Two silent first-run failures now
74
+ surface a clear warning. A host catch-all route (`match "*path", …`) that
75
+ shadows the engine's appended `POST /reactive/actions` (every reactive POST
76
+ 404s) is detected at boot via a route-recognition check
77
+ (`Phlex::Reactive.action_route_ok?`), logging how to exempt the path. And when
78
+ `data-controller="reactive"` elements are on the page but no controller
79
+ connected — the `lazyLoadControllersFrom` case where the gem's controller was
80
+ never registered — the client runtime logs a console warning naming the fix.
81
+ The README gains an "Integration troubleshooting" section covering both.
11
82
  - **Nested & array param types (issue #16).** Action param schemas can now
12
83
  declare arrays and nested hashes, not just scalars: wrap a type in an array
13
84
  (`bank_account_ids: [:integer]`) for an array param, or wrap a hash schema in
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
@@ -150,8 +191,8 @@ for broadcasting.
150
191
  │ rebuild component (record from DB)
151
192
  │ run the whitelisted action
152
193
  │ re-render → <turbo-stream replace id> (default; an action
153
- │ may return a Response — see "Controlling the action's reply")
154
- └──────── Turbo morphs it in ◀───────────────────────────────┘
194
+ │ may return reply.<verb> — see "Controlling the action's reply")
195
+ └──────── Turbo applies it in ◀──────────────────────────────┘
155
196
 
156
197
  ...and for OTHER tabs/users:
157
198
  model change → Component.broadcast_replace_to(stream) → pgbus SSE → same morph
@@ -251,11 +292,11 @@ The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
251
292
  | Method | Use |
252
293
  |---|---|
253
294
  | `#id` (you implement) | Stable DOM id == Turbo Stream target. Must match the root element's `id`. |
254
- | `.replace(model = nil, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component |
295
+ | `.replace(model = nil, morph: false, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component; `morph: true` adds `method="morph"` |
255
296
  | `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
256
- | `.broadcast_replace_to(*streamables, model:)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable) |
297
+ | `.broadcast_replace_to(*streamables, model:, morph: false)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable); `morph: true` morphs in place |
257
298
  | `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |
258
- | `#to_stream_replace` / `#to_stream_update` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `Response`) |
299
+ | `#to_stream_replace` / `#to_stream_morph` / `#to_stream_update` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `reply`); `#to_stream_morph` morphs in place |
259
300
 
260
301
  Use in controllers: `render turbo_stream: Counter.replace(counter)`.
261
302
 
@@ -269,6 +310,10 @@ 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. |
316
+ | `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` | Return from an action to control the reply (flash, remove, redirect, multi-stream). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
272
317
 
273
318
  Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
274
319
  in the schema is dropped before reaching your method.
@@ -337,40 +382,114 @@ div(**mix(reactive_attrs, id:, class: "card")) { ... }
337
382
  button(**on(:increment), data: { testid: "inc" }) { "+" }
338
383
  ```
339
384
 
340
- ### `Phlex::Reactive::Response` controlling the action's reply
385
+ **Binding inputs to action params (drop the magic `name:`).** A field's value
386
+ travels with an action only if its `name` equals the param. Hand-writing
387
+ `name: "value"` on every input is easy to forget — the action then silently gets
388
+ nothing. `reactive_input`/`reactive_select` emit the binding for you (the trigger
389
+ stays on the button, so focusing the field doesn't dispatch and collapse edit
390
+ mode):
341
391
 
342
- By default an action re-renders its component in place. **Return** a
343
- `Phlex::Reactive::Response` to do more (it governs only the actor's HTTP reply —
344
- cross-tab updates still use `broadcast_*_to(..., exclude: reactive_connection_id)`).
345
- Returning anything else keeps the default, so existing actions are unaffected.
392
+ ```ruby
393
+ action :save, params: { value: :string, status: :string }
346
394
 
347
- The snippets below alias the constant for brevity (`Response.replace(self)` won't
348
- resolve to `Phlex::Reactive::Response` inside a namespaced component — fully
349
- qualify it, or add the alias shown):
395
+ def view_template
396
+ span(id:, **reactive_attrs) do
397
+ reactive_input(:value, value: @record.name) # <input name="value" …>
398
+ reactive_select(:status) do # <select name="status">…</select>
399
+ %w[open closed].each { |s| option(value: s, selected: s == @record.status) { s } }
400
+ end
401
+ button(**mix(on(:save), data: { testid: "save" })) { "Save" }
402
+ end
403
+ end
404
+ ```
405
+
406
+ `reactive_field(:value, **attrs)` returns just the attribute hash if you'd rather
407
+ spread it onto a control yourself. An explicit `name:` still wins (escape hatch).
408
+
409
+ **Editing an associated record (`accepts_nested_attributes_for`).** `nested_update!`
410
+ maps a declared nested param straight onto `<assoc>_attributes` and carries the
411
+ existing record's id, so `update_only:` matches it in place instead of building a
412
+ second `has_one` (the boilerplate that's easy to get subtly wrong):
350
413
 
351
414
  ```ruby
352
- Response = Phlex::Reactive::Response # or qualify each call below
415
+ # Account has_one :address; accepts_nested_attributes_for :address, update_only: true
416
+ action :save, params: { address: { street: :string, city: :string } }
417
+
418
+ def save(address:)
419
+ nested_update!(:address, address) # update!(address_attributes: address.merge(id: @account.address&.id))
420
+ end
421
+ ```
422
+
423
+ `nested_attributes(:address, address)` returns the id-merged hash without
424
+ updating, if you need to combine it with other attributes.
425
+
426
+ ### `reply` — controlling the action's reply
427
+
428
+ By default an action re-renders its component in place. To do more, **return**
429
+ `reply.<verb>` — a subject-bound builder available in every component. It governs
430
+ only the actor's HTTP reply (cross-tab updates still use
431
+ `broadcast_*_to(..., exclude: reactive_connection_id)`). Returning anything else
432
+ keeps the default, so existing actions are unaffected.
433
+
434
+ `reply` reads cleanly: the component is the implicit subject (no `self` to
435
+ thread) and there's no constant to qualify (it's a method, so a namespaced
436
+ component needs no alias):
353
437
 
438
+ ```ruby
354
439
  def rename(title:)
355
- return Response.replace(self).flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
356
- Response.replace(self)
440
+ return reply.replace.flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
441
+ reply.replace
357
442
  end
358
443
 
359
- def approve = (@row.approve!; Response.remove(self)) # drop the element
360
- def publish = (@article.publish!; Response.redirect(article_url(@article))) # slug changed → Turbo.visit
361
- def add(item:) = Response.replace(self).stream(Totals.update(@order)) # multi-stream
444
+ def approve = (@row.approve!; reply.remove) # drop the element
445
+ def publish = (@article.publish!; reply.redirect(article_url(@article))) # slug changed → Turbo.visit
446
+ def add(item:) = reply.replace.stream(Totals.update(@order)) # multi-stream
447
+
448
+ # Per-field reactive editing (a "spreadsheet" grid): a debounced save fires
449
+ # while the user is still typing/tabbing. Morph in place so the focused <input>
450
+ # and its in-progress value survive the re-render (issue #28). Note the action is
451
+ # named `update`, yet `reply.morph` is unambiguous — the verb is on `reply`:
452
+ def update(name:) = (@row.update!(name:); reply.morph)
453
+
454
+ # Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
455
+ def rename(value:) = (@account.update!(name: value); reply.replace.also_update("page_heading", html: @account.name))
456
+
457
+ # Update ONLY part of the component (issue #30): re-stream just the total cell,
458
+ # NOT the whole row. reply.streams emits exactly your streams plus a tiny
459
+ # token-only refresh — no full-self replace — so a sibling <input> the user is
460
+ # mid-typing in is never torn down. The signed token still rolls forward.
461
+ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams(Totals.update(@item)))
362
462
  ```
363
463
 
364
464
  | Builder | Reply |
365
465
  |---|---|
366
- | `Response.replace(self)` / `.update(self)` | re-render in place (explicit default) |
367
- | `.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"`) |
368
- | `Response.remove(self)` | remove the element (backed by `Streamable#to_stream_remove`) |
369
- | `Response.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
370
- | `Response.with(*streams)` / `#stream(*more)` | multi-stream |
371
-
372
- `.flash`/`.stream` are additive on a self-replace, so the component's signed
373
- token always refreshes.
466
+ | `reply.replace` / `reply.update` | re-render in place (default; `replace` is an outerHTML swap, `update` morphs inner HTML) |
467
+ | `reply.morph` / `reply.replace(morph: true)` | re-render in place via Idiomorph (`method="morph"`) preserves the focused `<input>` + caret; for per-field reactive editing (issue #28) |
468
+ | `.also_update(target, html:)` | also re-render a companion element by DOM id; `html` is a plain string (escaped) or a Phlex component |
469
+ | `.also_replace(component, morph: false)` | also re-render another Streamable component, targeting its own `#id`; `morph: true` morphs it in place |
470
+ | `.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"`) |
471
+ | `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |
472
+ | `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
473
+ | `reply.streams(*streams)` | **partial update** — emit exactly these streams (no full-self replace) + a tiny token-only refresh, so live inputs survive; for per-field grid editing (issue #30) |
474
+ | `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |
475
+
476
+ `.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's
477
+ signed token always refreshes. **`reply.streams`** is the exception that proves
478
+ the rule: it deliberately skips the full-self replace (so your hand-built streams
479
+ update only the targets you name) and refreshes the token via a tiny inert
480
+ `reactive:token` stream instead — the token rolls forward without re-rendering
481
+ (and clobbering) the component's live inputs.
482
+
483
+ > **Under the hood.** `reply.<verb>` returns a `Phlex::Reactive::Response` — the
484
+ > immutable value object the endpoint reads. You can build one directly
485
+ > (`Phlex::Reactive::Response.replace(self)`) and it still works, but `reply` is
486
+ > the preferred surface; treat `Response` as an internal detail.
487
+ > **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
488
+ > **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
489
+ > `html: @account.name` is safe even for user-supplied values. To emit intentional
490
+ > markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —
491
+ > rendered and auto-escaped through the renderer — or an `html_safe` string for
492
+ > raw HTML you control.
374
493
 
375
494
  ### Configuration (`config/initializers/phlex_reactive.rb`)
376
495
 
@@ -78,6 +78,19 @@ module Phlex
78
78
  return [redirect_stream(result.redirect_url)] if result.redirect?
79
79
 
80
80
  streams = result.streams
81
+
82
+ # Partial update (Response.streams / reply.streams, issue #30): the action
83
+ # re-rendered only PART of the component and opted out of the full-self
84
+ # replace. Append a tiny token-only stream so the signed token still rolls
85
+ # forward WITHOUT re-rendering (and clobbering) the live inputs. Skip it
86
+ # only if the caller already supplied THIS component's token (idempotent) —
87
+ # the dedupe is scoped to the actor's own target, NOT a global substring,
88
+ # so a partial reply that legitimately includes another reactive
89
+ # component's stream (which carries its OWN token) still refreshes ours.
90
+ if result.refresh_token? && !carries_token_for?(streams, result.token_component)
91
+ return [*streams, result.token_component.to_stream_token]
92
+ end
93
+
81
94
  # Guarantee the component's signed identity token is refreshed unless the
82
95
  # Response opted out (remove/redirect navigate away — handled above). The
83
96
  # client reads the next token from the response body (#extractToken), so
@@ -94,6 +107,17 @@ module Phlex
94
107
  streams
95
108
  end
96
109
 
110
+ # True when one of `streams` already carries a fresh token TARGETING this
111
+ # component — i.e. the caller hand-built the actor's own token-bearing
112
+ # stream, so appending to_stream_token would double it. Scoped to the
113
+ # component's target id (not a global substring) so a sibling component's
114
+ # stream, which carries its OWN token for a DIFFERENT target, doesn't fool
115
+ # us into skipping this component's refresh.
116
+ def carries_token_for?(streams, component)
117
+ target = %(target="#{ERB::Util.html_escape(component.id)}")
118
+ streams.any? { |s| s.include?("data-reactive-token-value") && s.include?(target) }
119
+ end
120
+
97
121
  # A 200 turbo-stream carrying a namespaced custom action the client turns
98
122
  # into Turbo.visit — NOT an HTTP 3xx, which the client hard-bails on
99
123
  # (response.redirected). The matching client handler is registered in
@@ -4,7 +4,8 @@ import { Controller } from "@hotwired/stimulus"
4
4
  // replaces the per-feature Stimulus controllers you'd otherwise hand-write
5
5
  // for interactive components. A component declares its actions in Ruby (via
6
6
  // Phlex::Reactive::Component); this controller binds DOM events to a single
7
- // HTTP round trip and lets Turbo morph the re-rendered component back in.
7
+ // HTTP round trip and lets Turbo apply the re-rendered component back in
8
+ // (replace by default; method="morph" — Response.morph — preserves focus).
8
9
  //
9
10
  // Wire format (client -> server), POST <action path>, turbo-stream Accept:
10
11
  // { token: "<signed identity>", act: "<action>", params: {...} }
@@ -31,9 +32,81 @@ export function registerReactiveVisit() {
31
32
  if (url) window.Turbo.visit(url, { action: "advance" })
32
33
  }
33
34
  }
35
+
36
+ // Custom turbo-stream action: a TOKEN-ONLY refresh (issue #30). A partial
37
+ // update (Response.streams / reply.streams) re-renders only PART of a component
38
+ // — so there's no full-self replace to carry the next signed token. The server
39
+ // instead emits `<turbo-stream action="reactive:token" target="<id>"
40
+ // data-reactive-token-value="<fresh>">`. #perform's #extractToken already reads
41
+ // the token out of the response body for the NEXT queued request; this handler
42
+ // keeps the DOM in sync too, writing the attribute onto the root element so the
43
+ // `tokenValue` fallback stays fresh. It's a pure attribute set — no node is
44
+ // replaced — so a focused <input> + caret survive (the whole point: update a
45
+ // total cell without tearing down the field the user is typing in).
46
+ export function registerReactiveToken() {
47
+ const actions = window.Turbo?.StreamActions
48
+ if (!actions || actions["reactive:token"]) return
49
+ actions["reactive:token"] = function () {
50
+ const token = this.getAttribute("data-reactive-token-value")
51
+ const target = this.getAttribute("target")
52
+ if (!token || !target) return
53
+ const el = document.getElementById(target)
54
+ // Stimulus reads the token via the `token` value -> data-reactive-token-value.
55
+ if (el) el.setAttribute("data-reactive-token-value", token)
56
+ }
57
+ }
58
+
59
+ export function registerReactiveActions() {
60
+ registerReactiveVisit()
61
+ registerReactiveToken()
62
+ }
63
+
34
64
  if (typeof window !== "undefined") {
35
- if (window.Turbo) registerReactiveVisit()
36
- else document.addEventListener("turbo:load", registerReactiveVisit, { once: true })
65
+ if (window.Turbo) registerReactiveActions()
66
+ else document.addEventListener("turbo:load", registerReactiveActions, { once: true })
67
+ }
68
+
69
+ // --- Registration guard (issue #26 part 2) -------------------------------
70
+ // In a `lazyLoadControllersFrom("controllers", application)` app, only
71
+ // controllers under app/javascript/controllers/ are registered. This module
72
+ // lives outside that dir, so importing it isn't enough — `data-controller=
73
+ // "reactive"` does NOTHING until the host runs application.register("reactive",
74
+ // ...). The failure is silent: components render, but no action ever fires.
75
+ //
76
+ // We can't warn from connect() in that case (connect never runs). Instead, once
77
+ // the page is ready, if reactive elements exist but no controller has connected,
78
+ // the controller wasn't registered — so we warn, pointing at the fix.
79
+ let reactiveConnected = false
80
+
81
+ export function checkReactiveRegistration() {
82
+ if (reactiveConnected) return
83
+ if (typeof document === "undefined") return
84
+ const els = document.querySelectorAll('[data-controller~="reactive"]')
85
+ if (!els || els.length === 0) return
86
+ console.warn(
87
+ "[phlex-reactive] found " + els.length + ' element(s) with data-controller="reactive" ' +
88
+ "but the reactive controller never connected. It is loaded but not registered — " +
89
+ 'add `application.register("reactive", ReactiveController)` (importmap) or import it ' +
90
+ "into app/javascript/controllers/ for lazyLoadControllersFrom apps. See the README."
91
+ )
92
+ }
93
+
94
+ // Test seams (no-ops in production usage).
95
+ export function __resetReactiveRegistrationForTest() {
96
+ reactiveConnected = false
97
+ }
98
+ export function __markReactiveConnectedForTest() {
99
+ reactiveConnected = true
100
+ }
101
+
102
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
103
+ // Defer past initial controller connection (a microtask/tick after ready).
104
+ const scheduleCheck = () => setTimeout(checkReactiveRegistration, 0)
105
+ if (document.readyState === "loading") {
106
+ document.addEventListener("DOMContentLoaded", scheduleCheck, { once: true })
107
+ } else {
108
+ scheduleCheck()
109
+ }
37
110
  }
38
111
 
39
112
  // Register this controller eagerly (not lazily) so a click immediately after
@@ -47,6 +120,12 @@ export default class extends Controller {
47
120
  #tokenCache // freshest token, threaded synchronously across queued requests
48
121
  #debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
49
122
 
123
+ // Mark that a reactive controller actually connected, so the registration
124
+ // guard above knows the controller was registered (issue #26 part 2).
125
+ connect() {
126
+ reactiveConnected = true
127
+ }
128
+
50
129
  // Tear down any pending debounce timers when the controller leaves the DOM
51
130
  // (Turbo morph/navigation removes the element). Otherwise a timer that hasn't
52
131
  // fired yet would later call #enqueue on a disconnected controller — a round
@@ -173,8 +252,10 @@ export default class extends Controller {
173
252
  // Capture the new token from the response synchronously, so the next
174
253
  // queued request uses it without waiting for the async DOM morph.
175
254
  this.#currentToken = this.#extractToken(html) ?? this.#currentToken
176
- // Turbo applies the <turbo-stream> ops (replace/morph by id), preserving
177
- // focus/scroll/listeners on unchanged nodes.
255
+ // Turbo applies the <turbo-stream> ops by id. A plain replace is an
256
+ // outerHTML swap (focus on the replaced subtree is lost); a method="morph"
257
+ // replace (Response.morph) or an update morphs in place, preserving the
258
+ // focused input + caret on unchanged nodes — see issue #28.
178
259
  window.Turbo.renderStreamMessage(html)
179
260
  } catch (error) {
180
261
  console.error("[phlex-reactive] action error", error)
@@ -5,8 +5,10 @@ module Phlex
5
5
  # Component turns a self-contained Phlex component into a Livewire-style
6
6
  # reactive unit: declare actions in Ruby, and the generic `reactive`
7
7
  # Stimulus controller wires clicks/inputs to an HTTP round trip that
8
- # re-renders the component and morphs it back into the DOM. No per-feature
9
- # Stimulus controllers, no hand-picked Turbo targets.
8
+ # re-renders the component and applies it back into the DOM (a plain replace
9
+ # by default; return Response.morph(self) to morph in place and keep the
10
+ # focused input — issue #28). No per-feature Stimulus controllers, no
11
+ # hand-picked Turbo targets.
10
12
  #
11
13
  # Include alongside Phlex::Reactive::Streamable (which provides #id and the
12
14
  # re-render machinery).
@@ -167,6 +169,22 @@ module Phlex
167
169
  Phlex::Reactive.current_connection_id
168
170
  end
169
171
 
172
+ # Subject-bound reply builder — the preferred way to control an action's
173
+ # reply. `reply.replace.flash(:error, msg)` reads cleaner than
174
+ # `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
175
+ # component is the implicit subject (no `self` to thread) and there's no
176
+ # constant to qualify (reply is a method, so a namespaced component needs
177
+ # no `Response = …` alias). It returns the same immutable Response the
178
+ # endpoint reads, so chaining and the legacy return-value contract are
179
+ # unchanged. See Phlex::Reactive::Reply.
180
+ #
181
+ # def archive = reply.remove
182
+ # def go_home = reply.redirect("/todos")
183
+ # def update(name:) = (@account.update!(name:); reply.morph)
184
+ def reply
185
+ Phlex::Reactive::Reply.new(self)
186
+ end
187
+
170
188
  # Root-element attributes: marks the element reactive and carries the
171
189
  # signed identity token. Spread onto the root:
172
190
  # div(id:, **reactive_attrs) { ... }
@@ -206,8 +224,72 @@ module Phlex
206
224
  attrs
207
225
  end
208
226
 
227
+ # Bind a form control's `name` to an action param so its value travels with
228
+ # the action — instead of hand-writing the magic `name: "value"` on every
229
+ # input and silently getting no params when you forget it (issue #23).
230
+ # Returns a Phlex attributes hash to spread onto any control:
231
+ # input(**reactive_field(:value, value: @record.name))
232
+ # select(**reactive_field(:status)) { ... }
233
+ # Extra attrs merge over the binding; an explicit name: still wins (escape
234
+ # hatch). The trigger (on(:save)) stays on the button, not the field — so
235
+ # focusing the input doesn't dispatch and collapse edit mode.
236
+ def reactive_field(param, **attrs)
237
+ {name: param.to_s, **attrs}
238
+ end
239
+
240
+ # Render an <input> already bound to an action param (issue #23). Sugar for
241
+ # input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
242
+ # reactive_input(:value, value: @record.name, type: "text")
243
+ def reactive_input(param, **attrs)
244
+ input(**reactive_field(param, **attrs))
245
+ end
246
+
247
+ # Render a <select> bound to an action param (issue #23). The options block
248
+ # is the element's content, so the awkward FormBuilder positional split
249
+ # (where name: lands after the options/html-options args) goes away:
250
+ # reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
251
+ def reactive_select(param, **attrs, &block)
252
+ select(**reactive_field(param, **attrs), &block)
253
+ end
254
+
255
+ # Map a declared nested param onto Rails' <assoc>_attributes, carrying the
256
+ # existing associated record's id so accepts_nested_attributes_for matches
257
+ # it IN PLACE instead of building a second one (issue #24). Returns the
258
+ # update hash; pass it to update!:
259
+ # def save(address:) = nested_update!(:address, address)
260
+ # The id is only added when the association already exists, so the first
261
+ # save (no associated record yet) creates one cleanly. The given attrs are
262
+ # not mutated.
263
+ def nested_attributes(association, attrs)
264
+ merged = attrs.dup
265
+ existing = reactive_record_for_nested.public_send(association)
266
+ merged[:id] = existing.id if existing
267
+
268
+ {"#{association}_attributes": merged}
269
+ end
270
+
271
+ # Map a nested param onto <assoc>_attributes (with id preservation) AND
272
+ # apply it to the component's record in one call (issue #24). Extra keyword
273
+ # attributes update alongside the association.
274
+ # def save(address:, name:) = nested_update!(:address, address, name:)
275
+ def nested_update!(association, attrs, **extra)
276
+ reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
277
+ end
278
+
209
279
  private
210
280
 
281
+ # The component's record, for the nested-attributes helpers. Requires a
282
+ # declared reactive_record (the nested helper only makes sense for a
283
+ # record-backed component).
284
+ def reactive_record_for_nested
285
+ key = self.class.reactive_record_key
286
+ unless key
287
+ raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes"
288
+ end
289
+
290
+ instance_variable_get(:"@#{key}")
291
+ end
292
+
211
293
  # Signed identity payload: the class name plus whichever identity pieces
212
294
  # the component declares — a record GlobalID (`gid`), signed state (`s`),
213
295
  # 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
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # A component-bound facade over Response — the surface an action body uses to
6
+ # control its reply. Component#reply returns one, so an action writes
7
+ #
8
+ # reply.replace.flash(:error, msg)
9
+ #
10
+ # instead of
11
+ #
12
+ # Phlex::Reactive::Response.replace(self).flash(:error, msg)
13
+ #
14
+ # Two warts disappear: there is no constant to qualify (reply is a method,
15
+ # resolved on the component — so a namespaced component needs no
16
+ # `Response = …` alias) and no `self` to thread (the component is bound).
17
+ #
18
+ # Reply is NOT a Response and does NOT subclass one: each verb forwards to
19
+ # the Response class method, supplying the bound component as the subject, and
20
+ # returns the real, frozen Response value the endpoint already honors. So
21
+ # immutability, chaining (.flash/.stream/.also_update/.also_replace), and
22
+ # render_self? are inherited untouched — and there is no "verb after a chained
23
+ # Response" asymmetry, because the chain is plain Response all the way down.
24
+ #
25
+ # Response remains the public value object (it's what the endpoint reads); it
26
+ # is simply an internal detail you rarely name directly now.
27
+ class Reply
28
+ def initialize(component)
29
+ @component = component
30
+ end
31
+
32
+ # Self-targeting builders — the bound component is supplied implicitly.
33
+
34
+ # Re-render in place. `morph: true` morphs the subtree (preserves the
35
+ # focused input + caret) instead of an outerHTML swap — see #morph.
36
+ def replace(morph: false)
37
+ Response.replace(@component, morph:)
38
+ end
39
+
40
+ # Re-render in place via Idiomorph (method="morph") — keeps focus + caret.
41
+ def morph
42
+ Response.morph(@component)
43
+ end
44
+
45
+ # Morph only inner HTML (preserves the root element + its token attr).
46
+ def update
47
+ Response.update(@component)
48
+ end
49
+
50
+ # Remove the component's element from the DOM (render_self false).
51
+ def remove
52
+ Response.remove(@component)
53
+ end
54
+
55
+ # Subject-free builders — pass straight through so they read naturally.
56
+
57
+ # Client-side full navigation (Turbo.visit). Pass a *_url.
58
+ def redirect(url)
59
+ Response.redirect(url)
60
+ end
61
+
62
+ # Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
63
+ def with(*strings)
64
+ Response.with(*strings)
65
+ end
66
+
67
+ # Self-targeting again: emit exactly these streams with a TOKEN-ONLY refresh
68
+ # (issue #30) — partial/per-field update, NO full-self replace, so the
69
+ # component's live inputs survive. The bound component supplies the token.
70
+ def streams(*strings)
71
+ Response.streams(@component, *strings)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -12,15 +12,28 @@ 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
18
19
  class Response
19
- attr_reader :streams, :redirect_url
20
+ attr_reader :streams, :redirect_url, :token_component
20
21
 
21
22
  class << self
22
23
  # Re-render the component in place (explicit form of today's default).
23
- def replace(component) = new(streams: [component.to_stream_replace])
24
+ # `morph: true` morphs the subtree (preserves the focused input + caret)
25
+ # instead of an outerHTML swap — see .morph (issue #28).
26
+ def replace(component, morph: false)
27
+ new(streams: [morph ? component.to_stream_morph : component.to_stream_replace])
28
+ end
29
+
30
+ # Re-render the component in place via Idiomorph (issue #28). Emits
31
+ # `<turbo-stream action="replace" method="morph">`, so Turbo 8 morphs the
32
+ # subtree — the focused <input> + caret survive the save. Use this for
33
+ # per-field reactive editing (a "spreadsheet" grid where a debounced save
34
+ # fires while the user is still typing/tabbing). The morphed root still
35
+ # carries the fresh signed token, so the next action verifies.
36
+ def morph(component) = new(streams: [component.to_stream_morph])
24
37
 
25
38
  # Morph only inner HTML (preserves the root element + its token attr).
26
39
  def update(component) = new(streams: [component.to_stream_update])
@@ -38,14 +51,49 @@ module Phlex
38
51
  # Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
39
52
  def with(*strings) = new(streams: strings.flatten)
40
53
 
54
+ # Partial / per-field update with a TOKEN-ONLY refresh (issue #30). Emits
55
+ # EXACTLY the given streams — no forced full-self replace — but binds
56
+ # `component` so the endpoint appends its tiny `to_stream_token` stream.
57
+ # So the signed token rolls forward (the next action verifies) while the
58
+ # component's own live inputs are never torn down: ideal for a
59
+ # spreadsheet-like grid where a debounced save re-streams only a total
60
+ # cell and the user is still typing in a sibling field.
61
+ #
62
+ # Response.streams(self, Totals.update(@invoice)) # update only the totals
63
+ #
64
+ # render_self is false (we do NOT inject the full replace); the token is
65
+ # refreshed by the bound component's token stream instead.
66
+ def streams(component, *strings)
67
+ new(streams: strings.flatten, render_self: false, token_component: component)
68
+ end
69
+
41
70
  # Build a flash turbo-stream that appends `content` into a host-app
42
71
  # container. `content` is a Phlex component instance (rendered through
43
72
  # the configured renderer so t()/url_for work) or a ready HTML string —
44
73
  # supplied by the caller because the render context is off-request
45
74
  # (there is no Rails `flash`).
46
75
  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)
76
+ Phlex::Reactive.flash_builder.append(target, html: render_html(content))
77
+ end
78
+
79
+ # Build a turbo-stream that updates an arbitrary target id with `content`
80
+ # (a Phlex component instance or an HTML string). Used by #also_update to
81
+ # re-render a companion element that isn't itself a Streamable component.
82
+ def update_stream(target, content)
83
+ Phlex::Reactive.flash_builder.update(target, html: render_html(content))
84
+ end
85
+
86
+ # Resolve `content` to the HTML for a turbo-stream's `html:`. Two forms,
87
+ # both SAFE against injection by default:
88
+ # * a Phlex component instance — rendered through the configured
89
+ # renderer, which auto-escapes interpolated values.
90
+ # * any other value — coerced with to_s and handed to Turbo's
91
+ # TagBuilder, which HTML-ESCAPES a plain String. So a model value
92
+ # (`html: @record.name`) cannot inject markup. To emit intentional
93
+ # raw HTML, pass an `html_safe` String (Turbo leaves those verbatim)
94
+ # or a Phlex component. Same contract as the pre-existing flash_stream.
95
+ def render_html(content)
96
+ content.is_a?(::Phlex::SGML) ? Phlex::Reactive.render(content) : content.to_s
49
97
  end
50
98
  end
51
99
 
@@ -53,10 +101,16 @@ module Phlex
53
101
  # GUARANTEES the component's own replace is present so its
54
102
  # data-reactive-token-value refreshes (the client extracts the next token
55
103
  # from the response HTML). remove/redirect set it false (nothing stays).
56
- def initialize(streams: [], redirect_url: nil, render_self: true)
104
+ #
105
+ # token_component: set by .streams (issue #30) — a partial update that opts
106
+ # OUT of the full-self replace but still needs the token refreshed. The
107
+ # endpoint appends this component's tiny to_stream_token instead, so the
108
+ # token rolls forward without re-rendering (and clobbering) the children.
109
+ def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil)
57
110
  @streams = streams.freeze
58
111
  @redirect_url = redirect_url
59
112
  @render_self = render_self
113
+ @token_component = token_component
60
114
  freeze
61
115
  end
62
116
 
@@ -66,7 +120,8 @@ module Phlex
66
120
  self.class.new(
67
121
  streams: @streams + more.flatten,
68
122
  redirect_url: @redirect_url,
69
- render_self: @render_self
123
+ render_self: @render_self,
124
+ token_component: @token_component
70
125
  )
71
126
  end
72
127
 
@@ -76,8 +131,36 @@ module Phlex
76
131
  stream(self.class.flash_stream(level, content, target:))
77
132
  end
78
133
 
134
+ # Also re-render a COMPANION element alongside self — a page heading, a
135
+ # summary card, a badge that recomputes from the saved value (issue #25).
136
+ # `target` is the sibling element's DOM id. `html` is either:
137
+ # * a plain String — HTML-ESCAPED by Turbo, so a model value is safe:
138
+ # Response.replace(self).also_update("page_heading", html: @record.name)
139
+ # * a Phlex component — rendered + auto-escaped through the renderer (use
140
+ # this when the companion has its own markup), or an `html_safe` String
141
+ # for intentional raw HTML.
142
+ # Returns a NEW Response (immutable). The common "re-render self + N
143
+ # siblings" case no longer needs raw turbo_stream_builder.
144
+ def also_update(target, html:)
145
+ stream(self.class.update_stream(target, html))
146
+ end
147
+
148
+ # Like #also_update, but renders ANOTHER Streamable component and replaces
149
+ # it by its own #id — for a companion that is itself a component.
150
+ # Response.replace(self).also_replace(SummaryCard.new(order: @order))
151
+ # `morph: true` morphs the companion in place (issue #28) — use it when the
152
+ # companion also holds focusable inputs that must survive the re-render.
153
+ def also_replace(component, morph: false)
154
+ stream(morph ? component.to_stream_morph : component.to_stream_replace)
155
+ end
156
+
79
157
  def redirect? = !@redirect_url.nil?
80
158
  def render_self? = @render_self
159
+
160
+ # True when a partial update (.streams) opted out of the full-self replace
161
+ # but still needs the token rolled forward — the endpoint appends the bound
162
+ # component's tiny token-only stream (issue #30).
163
+ def refresh_token? = !@token_component.nil?
81
164
  end
82
165
  end
83
166
  end
@@ -64,9 +64,13 @@ module Phlex
64
64
  renderer.render(component, layout: false)
65
65
  end
66
66
 
67
- def replace(model = nil, **options)
67
+ # `morph: true` emits `<turbo-stream action="replace" method="morph">` so
68
+ # Turbo 8's bundled Idiomorph morphs the subtree in place — preserving a
69
+ # focused <input> + caret — instead of an outerHTML swap (issue #28).
70
+ # Default (morph: false) is the unchanged plain replace.
71
+ def replace(model = nil, morph: false, **options)
68
72
  component = build(model, options)
69
- turbo_stream_builder.replace(component.id, html: render_component(component))
73
+ turbo_stream_builder.replace(component.id, html: render_component(component), **morph_method(morph))
70
74
  end
71
75
 
72
76
  def update(model = nil, **options)
@@ -103,11 +107,15 @@ module Phlex
103
107
  # connection id — pass it to suppress the actor's own echo (the actor
104
108
  # already got the action's HTTP response). With Action Cable these are
105
109
  # ignored; with pgbus they reach the dispatcher. See docs/broadcasting.
106
- def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
110
+ # `morph: true` makes the live cross-tab update morph in place (issue #28),
111
+ # so a peer tab keeps its focus/caret on the morphed row. The broadcast
112
+ # path takes EXTRA <turbo-stream> attributes via `attributes:` (not the
113
+ # TagBuilder's `method:` kwarg), so the morph flag rides there.
114
+ def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
107
115
  component = build(model, options)
108
116
  ::Turbo::StreamsChannel.broadcast_replace_to(
109
117
  *streamables, target: component.id, html: render_component(component),
110
- **broadcast_transport_opts(exclude:, visible_to:)
118
+ **morph_attributes(morph), **broadcast_transport_opts(exclude:, visible_to:)
111
119
  )
112
120
  end
113
121
 
@@ -149,6 +157,20 @@ module Phlex
149
157
  new(**(model ? component_args(model, options) : options))
150
158
  end
151
159
 
160
+ # The TagBuilder (the .replace class builder + to_stream_morph) takes
161
+ # `method: :morph` to emit the `method="morph"` attribute. Pass it ONLY
162
+ # when morphing, so the default call produces today's plain replace.
163
+ def morph_method(morph)
164
+ morph ? {method: :morph} : {}
165
+ end
166
+
167
+ # The BROADCAST path renders extra <turbo-stream> attributes through
168
+ # `attributes:` (it has no `method:` kwarg — that would fall into the
169
+ # render args and be dropped). Same wire result: method="morph".
170
+ def morph_attributes(morph)
171
+ morph ? {attributes: {method: "morph"}} : {}
172
+ end
173
+
152
174
  # Only include transport opts that were actually given, so on Action
153
175
  # Cable (which doesn't accept them) the common no-opts call is unchanged.
154
176
  def broadcast_transport_opts(exclude:, visible_to:)
@@ -184,10 +206,38 @@ module Phlex
184
206
  self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self))
185
207
  end
186
208
 
209
+ # Render THIS instance as a MORPHING replace (issue #28):
210
+ # `<turbo-stream action="replace" method="morph">`. Turbo 8's bundled
211
+ # Idiomorph morphs the subtree in place — preserving the focused <input> +
212
+ # caret across the re-render — while still carrying the root's fresh
213
+ # data-reactive-token-value (so the signed token refreshes). Used by
214
+ # Response.morph / Response.replace(self, morph: true).
215
+ def to_stream_morph
216
+ self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self), method: :morph)
217
+ end
218
+
187
219
  def to_stream_update
188
220
  self.class.turbo_stream_builder.update(id, html: self.class.render_component(self))
189
221
  end
190
222
 
223
+ # Render a TOKEN-ONLY refresh stream (issue #30): a tiny
224
+ # `<turbo-stream action="reactive:token">` carrying the component's fresh
225
+ # signed token, with NO rendered body. It lets an action update only PART
226
+ # of a component (its own hand-built streams) while still rolling the
227
+ # signed identity token forward — the client reads the next token from this
228
+ # attribute (#extractToken) and an inert client action writes it onto the
229
+ # root (a pure attribute set, so a focused <input> + caret survive). Unlike
230
+ # to_stream_replace, it does NOT re-render the children, so a live input
231
+ # the user is typing into is never torn down. Used by Response.streams.
232
+ #
233
+ # The component carries its token via Component#reactive_token; a Streamable
234
+ # that isn't a Component (no token) simply has nothing to refresh — guarded
235
+ # by respond_to? so the primitive stays usable on a bare Streamable.
236
+ def to_stream_token
237
+ token = respond_to?(:reactive_token) ? reactive_token : nil
238
+ %(<turbo-stream action="reactive:token" target="#{ERB::Util.html_escape(id)}" data-reactive-token-value="#{ERB::Util.html_escape(token)}"></turbo-stream>)
239
+ end
240
+
191
241
  # Render THIS instance as a remove stream. The component already knows its
192
242
  # own #id, so no record/class reconstruction is needed (works for record-
193
243
  # and state-backed components alike). Used by Response.remove.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.2.8"
5
+ VERSION = "0.3.0"
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.8
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -129,6 +129,7 @@ files:
129
129
  - lib/phlex/reactive.rb
130
130
  - lib/phlex/reactive/component.rb
131
131
  - lib/phlex/reactive/engine.rb
132
+ - lib/phlex/reactive/reply.rb
132
133
  - lib/phlex/reactive/response.rb
133
134
  - lib/phlex/reactive/streamable.rb
134
135
  - lib/phlex/reactive/version.rb