phlex-reactive 0.4.6 → 0.4.8

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: 57f49d14a96bb92d0e65f8b6dc8331390538331630858145ed6a42ad80cc4ba5
4
- data.tar.gz: 5709021682d0741191df8000fd1b90c10927d1e9bb0f59a8e459728e39a39bc6
3
+ metadata.gz: 2858c45de31c08fa46914064af8c8177e0cecf507c7e908dc4239858f5c078b6
4
+ data.tar.gz: 540758b5f0c04562edfe8a41719b129c863fe5079475a6cd114818804d4c337f
5
5
  SHA512:
6
- metadata.gz: d69d38d3f339502c69caad92d5730c43f2b995d6e12202823ac62da69e02967e14eaa46dfb57536f74c11f5bd8181c1ef8bc9b4cc12067d2f7eb072b4d996cc2
7
- data.tar.gz: 30e84a8827a9cfdd515d7a2f9daec8c70b76ea5faffd6bf302f406b6aafc00daddfbf2ba0f4a1e070421e44e34ad3525fe09ecd692b5a009bcd4b0473f1eb087
6
+ metadata.gz: 052426b076385bc6c23c04e9a5b9c0d15106c514ea7b7c640e4e407cd0517e8910b005038d62156121a43125babbc402806a7a7544b14be76da7b50993c3ab3e
7
+ data.tar.gz: 10ec149142e82a0e3627d060fea0ed267bcc8ff42f0799fbe22ae95d12119ff4a4f500eb6e6ad6ec05591adfb93dd5530b37a044949b614acdcac60fc589d74a
data/CHANGELOG.md CHANGED
@@ -8,6 +8,76 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Combobox keyboard navigation — `on(:search, …, listnav: "[role=option]")` (#72).**
12
+ A search/combobox trigger can now declare client-side list navigation: Arrow
13
+ Up/Down move a highlight among the option elements IN-BROWSER (no round trip),
14
+ Enter picks the highlighted option by clicking its own `on(:select)` trigger
15
+ (so the selection stays a normal signed reactive action), and Escape clears —
16
+ all without a bespoke Stimulus controller. `listnav:` appends Stimulus's native
17
+ keyboard filters (`keydown.down/up/enter/esc->reactive#listnav*`) to the input's
18
+ `data-action` and marks the option selector; the generic controller's `listnav*`
19
+ handlers own the ephemeral highlight (a `data-reactive-highlighted` attribute,
20
+ never shipped as trusted state), mirroring `#recompute`. Only the highlight is
21
+ client-side — selection is still a default-deny, signed action. No new client
22
+ module (the handlers live in the existing controller); covered by unit (JS),
23
+ request, and real-browser system specs green under Puma AND Falcon.
24
+
25
+ - **Keyboard triggers on `on(...)` via `event:` — Enter-to-submit /
26
+ Escape-to-cancel with no client JavaScript.** `event:` is interpolated straight
27
+ into the Stimulus action descriptor, so **Stimulus's native keyboard filters
28
+ just work**: `on(:add, event: "keydown.enter")` emits
29
+ `keydown.enter->reactive#dispatch` and the action fires only on Enter, not on
30
+ every keypress. `event: "keydown.esc"` gives Escape-to-cancel. No new option to
31
+ learn (it's Stimulus's own filter syntax), no client change, no vendored-client
32
+ re-sync — and, deliberately, **no reserved `key:` keyword**, so `key` stays a
33
+ normal action-param name (`on(:switch, key: "pgbus")` keeps passing `key`
34
+ through as a param — no backward-incompatibility). Because a keyboard trigger
35
+ isn't a click, it does not get the `type="button"` a click trigger does. One
36
+ action per element still holds — bind Enter-save and Escape-cancel to separate
37
+ elements. README documents it under "Keyboard triggers".
38
+ - **`reactive_compute` — client-side data bindings (no round trip).** A component
39
+ can now declare a client-side computation that recomputes derived fields
40
+ IN-BROWSER on `input`, with NO server round trip — the "instant" half of a
41
+ new/unpersisted-record UX that previously required a hand-written Stimulus
42
+ controller (e.g. an order calculator that rebalances a payment split as you
43
+ type). Declare the binding in Ruby and register the matching reducer once in JS:
44
+
45
+ ```ruby
46
+ reactive_compute :payment_split,
47
+ inputs: %i[allowance cash leasing total], # fields the reducer reads
48
+ outputs: %i[allowance cash leasing] # fields it writes (no POST)
49
+ # in the view: div(**mix(reactive_root, reactive_compute_attrs(:payment_split)))
50
+ # on the edited field: data-action="input->reactive#recompute"
51
+ ```
52
+
53
+ ```js
54
+ import { setComputeReducer } from "phlex/reactive/compute"
55
+ setComputeReducer("payment_split", ({ allowance, cash, leasing, total }) => ({
56
+ allowance, leasing, cash: total - allowance - leasing,
57
+ }))
58
+ ```
59
+
60
+ The generic controller runs the named reducer on `input`, writes only the
61
+ declared outputs (leaving the edited field + caret alone), and fires `input` on
62
+ each field it sets so a chained summary repaints — matching the server's
63
+ `set_value` + `dispatch("input")` contract. A missing/unregistered reducer is a
64
+ no-op (a page never breaks because a binding wasn't wired up). When the same
65
+ component ALSO carries `on(...)` (a persisted record, or a draft you sync), that
66
+ debounced POST still fires and the server reply reconciles — so `reactive_compute`
67
+ is the optimistic client paint, the server round trip is the source of truth.
68
+ One math contract, two execution sites. New client module
69
+ `phlex/reactive/compute` (auto-pinned by the engine like `confirm`).
70
+
71
+ - **Draft (unpersisted-record) tokens — `reactive_record` no longer crashes on a
72
+ `new_record?`.** A record-backed component may now render an UNSAVED record (an
73
+ order the user is building before it's saved). `reactive_token` omits the `gid`
74
+ when the record isn't persisted (`to_gid` would raise `MissingModelIdError`) and
75
+ relies on the declared `reactive_state` as the draft seed, so the token still
76
+ signs cleanly and the client controller mounts. The draft is driven client-side
77
+ (`reactive_compute`) until it's saved; once persisted, a re-render signs the
78
+ `gid` as before. Combined with `reactive_compute`, this is the "persisted → server,
79
+ new → in-browser" split as a first-class capability instead of a hand-rolled one.
80
+
11
81
  - **Overridable / async confirm resolver — reuse your themed dialog (#55).**
12
82
  Follow-up to #52. The `confirm:` gate was hardcoded to the synchronous,
13
83
  browser-native `window.confirm`, so a reactive trigger was the one interaction
@@ -55,6 +125,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55
125
  server coercion (request specs), the FormData wire shape (bun unit tests), and a
56
126
  real browser upload under Puma + Falcon (system spec).
57
127
 
128
+ ### Documentation
129
+
130
+ - **The auto-collected-params contract, spelled out (#64, #65, #66, #67).** Four
131
+ gaps surfaced from building one model-scoped form (numeric fields that rebalance
132
+ live). No behavior changed — the README now documents what the code already
133
+ does:
134
+ - **#67** — a **flat** param schema silently drops **bracketed** field names.
135
+ Because the endpoint expands `invoice[date]` to `{ "invoice" => { … } }`
136
+ *before* matching the schema, a flat `{ date: … }` matches nothing and the
137
+ action gets keyword defaults with no error. The "Model-scoped form fields"
138
+ section now warns to nest the schema under the model key to match the names.
139
+ - **#65** — auto-collected sibling fields are read **at dispatch time**, not
140
+ from a pre-event snapshot: a `change`/`input` trigger sees its own new value
141
+ and every peer's current DOM value. Documented in a new "Auto-collected
142
+ sibling fields — the read contract" subsection.
143
+ - **#66** — reactive collection **includes `disabled` fields**, deliberately
144
+ unlike a native `<form>` submit, so a read-only computed field (a synced
145
+ `total`) reaches the action. Documented as intentional, with the `readonly`
146
+ vs `disabled` guidance for form-submit parity.
147
+ - **#64** — a `reactive_record` action can use the record for **identity +
148
+ authorization only** and compute over live, unsaved params, returning
149
+ `reply.streams(...)` to stream a partial update with **no persist and no
150
+ broadcast**. Documented as a first-class "record-authorized, transient-state
151
+ action" pattern in the `reply` section.
152
+
153
+ The demo app (`docs/`) gains a **live payment-split rebalancer** example — three
154
+ amounts that always sum to a total, editing one rebalances the peers — that
155
+ makes #64–#67 browsable (model-scoped bracketed params, a disabled computed
156
+ field the action still reads, siblings collected at dispatch, transient compute
157
+ with no persist/broadcast). The **todo** and **inline-edit** examples gain
158
+ Enter-to-add / Enter-to-save / Escape-to-cancel via the new `key:` filter,
159
+ covered by request specs and a real-browser (Playwright) Enter-keypress test.
160
+ Combobox keyboard navigation is tracked separately (#72) for the
161
+ minimal-client-seam work.
162
+
58
163
  ### Changed
59
164
 
60
165
  - **Linter: Standard → RuboCop.** The gem now lints with RuboCop (all new cops
@@ -101,6 +206,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
101
206
 
102
207
  ### Fixed
103
208
 
209
+ - **`reactive_controller.js` used a relative `./confirm.js` import that 404'd under
210
+ importmap-rails + Propshaft — taking down every Stimulus controller on the page (#57).**
211
+ The #55 confirm resolver added `import { confirmResolver } from "./confirm.js"` to the
212
+ client controller. Under importmap + Propshaft the controller is served at its
213
+ **digested** URL, and a relative sibling import is left untouched (Propshaft's JS
214
+ compiler rewrites only `RAILS_ASSET_URL(...)`, and the import map resolves **only**
215
+ bare specifiers, never relative-resolved URLs). So the browser resolved `./confirm.js`
216
+ against the digested controller URL and requested an **undigested**
217
+ `/assets/phlex/reactive/confirm.js` → **404**. The throwing import meant
218
+ `reactive_controller.js` never evaluated, and in an app that eagerly registers it the
219
+ whole controllers entrypoint died — **every** Stimulus controller on the page stopped,
220
+ with no obvious link to phlex-reactive. The fix imports the **bare** specifier the
221
+ engine already pins (`phlex/reactive/confirm`), which resolves to the digested asset
222
+ through the import map and mirrors how the gem already expects apps to import the
223
+ module (`import { setConfirmResolver } from "phlex/reactive/confirm"`). Bundlers
224
+ (esbuild/webpack/bun) resolve the bare specifier the same way they already resolve
225
+ `phlex/reactive/reactive_controller`; the gem's bun JS suite resolves it via a new
226
+ `tsconfig.json` `paths` alias. Covered by a bun unit test (the bare import resolves,
227
+ and the source no longer carries the relative form). 0.4.5 was unaffected (inline
228
+ `window.confirm`, no relative import).
229
+
104
230
  - **Client mirror of #44: collections of *reactive* rows were STILL add-once-only in
105
231
  the browser — `#extractToken` read the FIRST token in the response, not this
106
232
  controller's own (#46).** The server fix in 0.4.2 (#44) made the `add` response
data/README.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  [![CI](https://github.com/mhenrixon/phlex-reactive/actions/workflows/main.yml/badge.svg)](https://github.com/mhenrixon/phlex-reactive/actions/workflows/main.yml)
4
4
  [![Gem Version](https://img.shields.io/gem/v/phlex-reactive)](https://rubygems.org/gems/phlex-reactive)
5
- [![Docs](https://img.shields.io/badge/docs-mhenrixon.github.io-blue)](https://mhenrixon.github.io/phlex-reactive)
5
+ [![Docs](https://img.shields.io/badge/docs-phlex--reactive.zoolutions.llc-blue)](https://phlex-reactive.zoolutions.llc)
6
6
 
7
7
  **Reactive [Phlex](https://www.phlex.fun) components for Rails — Livewire-style
8
8
  actions and live cross-tab updates, without writing Stimulus controllers or
9
9
  hand-picking Turbo Stream targets.**
10
10
 
11
- 📖 **[Full documentation](https://mhenrixon.github.io/phlex-reactive)**
11
+ 📖 **[Full documentation](https://phlex-reactive.zoolutions.llc)**
12
12
 
13
13
  ```ruby
14
14
  class Counter < ApplicationComponent
@@ -130,7 +130,7 @@ application.register("reactive", ReactiveController)
130
130
 
131
131
  The JS ships at `app/javascript/phlex/reactive/reactive_controller.js` in the
132
132
  gem; point your bundler at the gem path or copy it in. See
133
- [docs/installation.md](docs/installation.md).
133
+ [docs/installation.md](https://phlex-reactive.zoolutions.llc/docs/installation).
134
134
  </details>
135
135
 
136
136
  **Requirements:** Rails 7.1+, Phlex 2 (`phlex-rails`), Turbo 8+ (for morphing),
@@ -265,7 +265,7 @@ action. Keep state small and JSON-serializable.
265
265
  reactive_state :count, :step # signed; rebuilt on each action
266
266
  ```
267
267
 
268
- The [inline edit example](docs/examples/inline_edit.md) combines both: a
268
+ The [inline edit example](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) combines both: a
269
269
  `reactive_record :record` plus `reactive_state :attribute, :editing`.
270
270
 
271
271
  ---
@@ -274,14 +274,15 @@ The [inline edit example](docs/examples/inline_edit.md) combines both: a
274
274
 
275
275
  | Example | What it shows |
276
276
  |---|---|
277
- | [Counter](docs/examples/counter.md) | State-backed, the smallest reactive component |
278
- | [Cross-tab chat](docs/examples/chat.md) | Record-backed action **+ pgbus broadcast** live sync across tabs/browsers |
279
- | [Live todo list](docs/examples/todo_list.md) | Per-row components, add/toggle/rename/delete, broadcast on change |
280
- | [Inline edit](docs/examples/inline_edit.md) | Show edit mode toggle, replacing a Stimulus controller + 3 routes |
281
- | [Notifications / badges](docs/examples/notifications.md) | Pure broadcast (no client action) a job pushes a re-render |
277
+ | [Counter](https://phlex-reactive.zoolutions.llc/docs/example-counter) | State-backed, the smallest reactive component |
278
+ | [Payment split](https://phlex-reactive.zoolutions.llc/docs/example-payment-split) | Live sum-to-total rebalancer nested bracketed params, a disabled computed field, auto-collected siblings (#64–#67) |
279
+ | [Cross-tab chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) | Record-backed action **+ pgbus broadcast** live sync across tabs/browsers |
280
+ | [Live todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) | Per-row components, add/toggle/rename/delete, Enter-to-add, broadcast on change |
281
+ | [Inline edit](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) | Show edit mode toggle, replacing a Stimulus controller + 3 routes |
282
+ | [Notifications / badges](https://phlex-reactive.zoolutions.llc/docs/example-notifications) | Pure broadcast (no client action) — a job pushes a re-render |
282
283
 
283
284
  The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
284
- [docs/examples/chat.md](docs/examples/chat.md).
285
+ [docs/examples/chat.md](https://phlex-reactive.zoolutions.llc/docs/example-chat).
285
286
 
286
287
  ---
287
288
 
@@ -311,7 +312,9 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
311
312
  | `reactive_attrs` | Marks an element reactive + carries the signed token (no `id`). Spread alongside `id:` on the **same** element: `div(id:, **reactive_attrs)`. Prefer `reactive_root`, which can't split them. |
312
313
  | `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
313
314
  | `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
315
+ | `on(:action, event: "keydown.enter")` | Fire only on a specific key — Enter-to-submit / Escape-to-cancel — via Stimulus's native keyboard filter (`event:` passes straight through). See [Keyboard triggers](#keyboard-triggers-enter-to-submit--escape-to-cancel). |
314
316
  | `on(:action, confirm: "Sure?")` | Gate a destructive trigger behind a confirmation. Defaults to `window.confirm`; override the dialog with [`setConfirmResolver`](#custom-confirmation-dialogs-setconfirmresolver). |
317
+ | `on(:search, listnav: "[role=option]")` | Add combobox keyboard navigation — Arrow keys move a client-side highlight, Enter picks (clicks the option's own trigger), Escape clears. See [Combobox keyboard navigation](#combobox-keyboard-navigation-listnav). |
315
318
  | `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
316
319
  | `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
317
320
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
@@ -395,6 +398,16 @@ action :save, params: {invoice: {date: :string, status: :string}}
395
398
  # client posts { "invoice[date]": "…", "invoice[status]": "…" } → save(invoice: { date:, status: })
396
399
  ```
397
400
 
401
+ > **A flat schema silently drops bracketed names (issue #67).** The schema must
402
+ > mirror the field *names*, not the conceptual params. Because the endpoint
403
+ > expands `invoice[date]` to `{ "invoice" => { "date" => … } }` **before**
404
+ > matching the schema, a flat `params: { date: :string }` matches nothing — the
405
+ > top-level key is now `invoice`, not `date`. There is no error: the action just
406
+ > receives its keyword defaults (`date` never set). If your inputs are named
407
+ > `invoice[…]` (any `Form(model:)`-style form), nest the schema under `invoice:`
408
+ > to match. When in doubt, read a field's real `name` attribute and shape the
409
+ > schema to it.
410
+
398
411
  **Nested reactive components compose.** A reactive component rendered inside
399
412
  another is its own root — field collection stops at nested
400
413
  `data-controller="reactive"` roots, so an outer action collects only *its own*
@@ -413,6 +426,90 @@ Omit `debounce:` for the immediate-dispatch default.
413
426
  input(**mix(on(:update, event: "input", debounce: 300), name: "quantity", value: @item.quantity))
414
427
  ```
415
428
 
429
+ **Auto-collected sibling fields — the read contract.** A reactive action doesn't
430
+ just receive its own trigger's value: the client gathers **every named control**
431
+ in the reactive root (`input[name]`, `select[name]`, `textarea[name]`, and named
432
+ rich-text/`contenteditable` editors) and merges them under the action's params,
433
+ so one action reads the whole form. Explicit `on(:act, x: …)` params win over a
434
+ collected field of the same name; collection stops at nested reactive roots (see
435
+ *Nested reactive components compose* above). Two things worth pinning down:
436
+
437
+ - **Timing — params reflect the DOM at dispatch, not a pre-event snapshot
438
+ (issue #65).** Field values are read when the request is sent (after the
439
+ debounce quiet period, if any), so a `change`/`input` trigger sees **its own
440
+ field's new value and every peer's current value.** There is no capture of the
441
+ values as they were *before* the interaction — if your computation needs a
442
+ peer's prior value (e.g. a spill-back that folds an overflow into the edited
443
+ field), that peer's current DOM value *is* the prior value only because nothing
444
+ else has changed it yet. Read at dispatch time, trust the current DOM.
445
+ - **Disabled fields ARE collected (issue #66) — deliberately different from a
446
+ native form.** A `<form>` submit omits `disabled` controls; reactive collection
447
+ does **not** check `disabled`, so a disabled field that carries a
448
+ computed/display value (a read-only `total` the client keeps in sync) reaches
449
+ the action. This is intentional — it's what makes "read a computed disabled
450
+ field" work. If you need form-submit parity (drop the disabled value), give the
451
+ control no `name`, or make it `readonly` instead of `disabled` when you *do*
452
+ want it collected by both paths.
453
+
454
+ **Keyboard triggers (Enter-to-submit / Escape-to-cancel).** `event:` is
455
+ interpolated straight into the Stimulus action descriptor, so any Stimulus event
456
+ string works — including its **native keyboard filters**. Pass `event:
457
+ "keydown.enter"` to fire only on Enter, `event: "keydown.esc"` for Escape — the
458
+ classic "Enter adds the row", "Escape cancels the edit" interactions. The action
459
+ runs *only* on that key, not on every keypress — no client JavaScript, no
460
+ `event.key` check of your own, and no new option to learn (it's Stimulus's own
461
+ [keyboard-filter syntax](https://stimulus.hotwired.dev/reference/actions#keyboardevent-filter)):
462
+
463
+ ```ruby
464
+ # Enter in the composer adds the todo (same action as the Add button).
465
+ input(**mix(on(:add, event: "keydown.enter"), name: "title", placeholder: "New todo…"))
466
+
467
+ # Inline editor: Enter on the field saves; a separate control cancels on Escape.
468
+ input(**mix(on(:save, event: "keydown.enter"), name: "title", value: @todo.title))
469
+ button(**on(:cancel, event: "keydown.esc")) { "Cancel" }
470
+ ```
471
+
472
+ The filter tokens are Stimulus's (`enter`, `esc`, `space`, `up`, `down`, a bare
473
+ letter, …). Because a keyboard trigger isn't a click, it does **not** get the
474
+ `type="button"` a click trigger does. Folding the key into `event:` keeps `key`
475
+ free as an ordinary action-param name (`on(:switch, key: "pgbus")` still passes
476
+ `key` through as a param).
477
+
478
+ > **One action per element.** Each trigger element carries a single reactive
479
+ > action (its `data-reactive-action-param`), so you can't put `on(:save, event:
480
+ > "keydown.enter")` *and* `on(:cancel, event: "keydown.esc")` on the **same**
481
+ > input — the second would overwrite the first's action name. Bind each key
482
+ > trigger to its own element (the field saves on Enter; a Cancel button — or the
483
+ > field's own blur — handles Escape), as above.
484
+
485
+ ### Combobox keyboard navigation (`listnav:`)
486
+
487
+ A searchable list needs Arrow keys to move a highlight, Enter to pick, Escape to
488
+ close — interactions that are *ephemeral client UI state* (a highlight per
489
+ keystroke would be absurd as a server round trip). Pass `listnav:` (a CSS
490
+ selector for the option elements) to a search trigger and the generic controller
491
+ handles all of it client-side, with no bespoke Stimulus controller:
492
+
493
+ ```ruby
494
+ # The search input: debounced live search + keyboard list navigation.
495
+ input(**mix(
496
+ on(:search, event: "input", debounce: 200, listnav: "[role=option]"),
497
+ name: "query", value: @query
498
+ ))
499
+
500
+ # Each option is BOTH a listnav target (role=option) and its own reactive
501
+ # select trigger — Enter just clicks the highlighted one.
502
+ button(**mix(on(:select, name: opt), role: "option")) { opt }
503
+ ```
504
+
505
+ `listnav:` appends Stimulus's native keyboard filters
506
+ (`keydown.down/up/enter/esc`) to the input's `data-action`. Arrow Up/Down move a
507
+ `data-reactive-highlighted` marker among the options **with no round trip**;
508
+ Enter **clicks the highlighted option** — so selection runs through its normal
509
+ `on(:select)` reactive action (signed, default-deny, authorized like any other);
510
+ Escape clears the highlight. Only the highlight is client-side — the selection
511
+ stays a real signed action, and the highlight is never shipped as trusted state.
512
+
416
513
  **Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
417
514
  a hash that includes a `data:` key. Spreading them *and* passing another `data:`
418
515
  (or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge. For the
@@ -567,6 +664,40 @@ update only the targets you name) and refreshes the token via a tiny inert
567
664
  `reactive:token` stream instead — the token rolls forward without re-rendering
568
665
  (and clobbering) the component's live inputs.
569
666
 
667
+ #### Record-authorized, transient-state actions (issue #64)
668
+
669
+ A `reactive_record` component isn't obligated to persist or broadcast — the
670
+ record can be there purely for **identity + authorization** while the action's
671
+ real job is to recompute **live, unsaved form values** the user is mid-edit. The
672
+ record is re-located and instantiated on each action (`from_identity`), never
673
+ auto-saved and never auto-broadcast; persistence and cross-tab broadcast are both
674
+ opt-in (you call `record.update!` / `broadcast_*_to` yourself). Pair that with
675
+ `reply.streams` and you get a first-class "authorize via the row, compute over
676
+ the params, stream a partial update, touch neither the DB nor peer tabs" action:
677
+
678
+ ```ruby
679
+ class Invoice::PaymentFields < ApplicationComponent
680
+ include Phlex::Reactive::Streamable
681
+ include Phlex::Reactive::Component
682
+
683
+ reactive_record :invoice # identity + authorization ONLY — not persisted here
684
+ action :rebalance, params: { invoice: { field_a: :integer, field_b: :integer,
685
+ field_c: :integer, total: :integer } }
686
+
687
+ def rebalance(invoice:)
688
+ authorize! @invoice, :update? # the token proves identity, not permission
689
+ result = recompute(invoice) # pure computation over the collected params
690
+ reply.streams(*set_value_streams(result)) # NO persist, NO broadcast
691
+ end
692
+ end
693
+ ```
694
+
695
+ This is deliberate, not a misuse: `reply.streams` is exactly the reply for "emit
696
+ these targeted updates, roll the token forward, and leave everything else — the
697
+ DB, the other tabs, the sibling inputs the user is typing in — untouched."
698
+ Broadcasting is deliberately omitted so peer tabs with their own in-flight edits
699
+ aren't clobbered. Authorize the record as always — identity is never permission.
700
+
570
701
  > **Under the hood.** `reply.<verb>` returns a `Phlex::Reactive::Response` — the
571
702
  > immutable value object the endpoint reads. You can build one directly
572
703
  > (`Phlex::Reactive::Response.replace(self)`) and it still works, but `reply` is
@@ -696,7 +827,7 @@ real, so read this once.
696
827
  but if you have *public* reactive components, ensure the action path isn't
697
828
  force-redirected to a login page for logged-out users.
698
829
 
699
- See [docs/security.md](docs/security.md) for the threat model and a checklist.
830
+ See [docs/security.md](https://phlex-reactive.zoolutions.llc/docs/security) for the threat model and a checklist.
700
831
 
701
832
  ---
702
833
 
@@ -800,24 +931,24 @@ end
800
931
  replayed, not lost.
801
932
  - **No Redis, no Action Cable.**
802
933
 
803
- See [docs/broadcasting.md](docs/broadcasting.md) and
804
- [docs/transport-pgbus.md](docs/transport-pgbus.md).
934
+ See [docs/broadcasting.md](https://phlex-reactive.zoolutions.llc/docs/broadcasting) and
935
+ [docs/transport-pgbus.md](https://phlex-reactive.zoolutions.llc/docs/transport-pgbus).
805
936
 
806
937
  ---
807
938
 
808
939
  ## Documentation
809
940
 
810
- - [Installation & bundler setups](docs/installation.md)
811
- - [Mental model & architecture](docs/architecture.md)
812
- - [Security & threat model](docs/security.md)
813
- - [Broadcasting & live updates](docs/broadcasting.md)
814
- - [Transport: pgbus vs Action Cable](docs/transport-pgbus.md)
815
- - [Testing reactive components](docs/testing.md)
816
- - [Performance & benchmarking](docs/performance.md)
817
- - Examples: [counter](docs/examples/counter.md) ·
818
- [chat](docs/examples/chat.md) · [todo list](docs/examples/todo_list.md) ·
819
- [inline edit](docs/examples/inline_edit.md) ·
820
- [notifications](docs/examples/notifications.md)
941
+ - [Installation & bundler setups](https://phlex-reactive.zoolutions.llc/docs/installation)
942
+ - [Mental model & architecture](https://phlex-reactive.zoolutions.llc/docs/architecture)
943
+ - [Security & threat model](https://phlex-reactive.zoolutions.llc/docs/security)
944
+ - [Broadcasting & live updates](https://phlex-reactive.zoolutions.llc/docs/broadcasting)
945
+ - [Transport: pgbus vs Action Cable](https://phlex-reactive.zoolutions.llc/docs/transport-pgbus)
946
+ - [Testing reactive components](https://phlex-reactive.zoolutions.llc/docs/testing)
947
+ - [Performance & benchmarking](https://phlex-reactive.zoolutions.llc/docs/performance)
948
+ - Examples: [counter](https://phlex-reactive.zoolutions.llc/docs/example-counter) ·
949
+ [chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) · [todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) ·
950
+ [inline edit](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) ·
951
+ [notifications](https://phlex-reactive.zoolutions.llc/docs/example-notifications)
821
952
 
822
953
  ## Credits & prior art
823
954
 
@@ -0,0 +1,48 @@
1
+ // The client-side compute (data-binding) registry — the "instant" half of the
2
+ // new/unpersisted-record UX.
3
+ //
4
+ // A record-backed reactive component round-trips every change to the server
5
+ // (the signed identity re-finds the record; the server re-renders). A NEW,
6
+ // unpersisted record has no such server truth to re-render against on every
7
+ // keystroke — the classic answer is a bespoke Stimulus controller doing the math
8
+ // in the browser (carlqvist's new_order_controller.js). This registry lets that
9
+ // math be a DECLARED part of the component instead: `reactive_compute :name,
10
+ // inputs:, outputs:` (Ruby) names a reducer registered here, and the generic
11
+ // reactive controller runs it on `input` — writing the outputs with NO round
12
+ // trip. When the component ALSO carries on(...) (a persisted record, or a draft
13
+ // you sync), the debounced POST reconciles from the authoritative server reply.
14
+ //
15
+ // The seam mirrors confirm.js: a settable registry with a lookup the controller
16
+ // calls. Register once at boot:
17
+ //
18
+ // import { setComputeReducer } from "phlex/reactive/compute"
19
+ // setComputeReducer("payment_split", ({ allowance, cash, leasing, total }) => ({
20
+ // allowance, leasing, cash: total - allowance - leasing,
21
+ // }))
22
+ //
23
+ // The reducer receives a plain object of { inputName: Number } and returns a
24
+ // plain object of { outputName: value } — only the outputs it names are written,
25
+ // so it can leave the edited field (and its caret) untouched. Returning the SAME
26
+ // value it read is a no-op write; the controller still fires `input` on any field
27
+ // it sets so a chained summary repaints, matching the server's set_value +
28
+ // dispatch("input") contract.
29
+
30
+ const reducers = new Map()
31
+
32
+ // Register (or replace) the reducer for `key`. `fn` is
33
+ // (values: Record<string, number>) => Record<string, unknown>.
34
+ export function setComputeReducer(key, fn) {
35
+ reducers.set(key, fn)
36
+ }
37
+
38
+ // Look up a registered reducer; undefined when none — the controller then makes
39
+ // #recompute a no-op rather than throwing (a missing reducer must not break the
40
+ // page; it just means no client-side binding for that root).
41
+ export function computeReducer(key) {
42
+ return reducers.get(key)
43
+ }
44
+
45
+ // Test seam: clear the registry so a reducer registered in one test can't leak.
46
+ export function __resetComputeRegistryForTest() {
47
+ reducers.clear()
48
+ }
@@ -1,5 +1,18 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { confirmResolver } from "./confirm.js"
2
+ // Import the BARE specifier the engine already pins (phlex/reactive/confirm),
3
+ // NOT a relative "./confirm.js" (issue #57). Under importmap-rails + Propshaft
4
+ // the controller is served at its DIGESTED url; a relative sibling import is
5
+ // left untouched (Propshaft rewrites only RAILS_ASSET_URL(...), and the import
6
+ // map resolves ONLY bare specifiers), so "./confirm.js" resolves against the
7
+ // digested controller url → an undigested /assets/.../confirm.js that 404s, and
8
+ // the throwing import takes down every Stimulus controller on the page. The
9
+ // bare specifier resolves to the digested asset through the import map, and
10
+ // bundlers/bun resolve it the same way they already resolve
11
+ // "phlex/reactive/reactive_controller" (see tsconfig.json paths for the tests).
12
+ import { confirmResolver } from "phlex/reactive/confirm"
13
+ // Client-side computes (data bindings): the reducer registry behind
14
+ // reactive_compute. Bare specifier for the same import-map reason as confirm.
15
+ import { computeReducer } from "phlex/reactive/compute"
3
16
 
4
17
  // The ONE generic controller behind every reactive Phlex component. It
5
18
  // replaces the per-feature Stimulus controllers you'd otherwise hand-write
@@ -209,6 +222,131 @@ export default class extends Controller {
209
222
  })
210
223
  }
211
224
 
225
+ // Client-side compute (data binding). Wired by reactive_compute: an `input`
226
+ // trigger (input->reactive#recompute) runs a REGISTERED JS reducer over the
227
+ // named input fields and writes the named output fields WITH NO ROUND TRIP —
228
+ // the "instant" half of the new/unpersisted-record UX. If the field ALSO
229
+ // carries on(...) (a persisted record, or a synced draft), that debounced POST
230
+ // still fires and the server reply reconciles; recompute just paints first.
231
+ //
232
+ // Reads inputs/outputs/reducer from the root's data-reactive-compute-* attrs
233
+ // (set once by reactive_compute_attrs). A missing/unregistered reducer is a
234
+ // no-op — a page must never break because a binding wasn't wired up.
235
+ recompute() {
236
+ const key = this.element.getAttribute("data-reactive-compute-reducer-param")
237
+ if (!key) return
238
+ const reduce = computeReducer(key)
239
+ if (!reduce) return
240
+
241
+ const inputs = this.#parseComputeList("data-reactive-compute-inputs-param")
242
+ const outputs = this.#parseComputeList("data-reactive-compute-outputs-param")
243
+
244
+ const values = {}
245
+ for (const name of inputs) values[name] = this.#numericFieldValue(name)
246
+
247
+ const result = reduce(values) || {}
248
+ for (const name of outputs) {
249
+ if (!(name in result)) continue
250
+ const field = this.#ownedField(name)
251
+ // Setting .value fires the field's own `input` listeners (a chained summary
252
+ // repaint), matching the server's set_value + dispatch("input") contract.
253
+ if (field) field.value = result[name]
254
+ }
255
+ }
256
+
257
+ // Client-side list navigation (combobox keyboard nav, issue #72). Wired by
258
+ // on(:search, …, listnav: "[role=option]"), which appends keyboard filters to
259
+ // the input's data-action (keydown.down/up/enter/esc->reactive#listnav*) and
260
+ // sets data-reactive-listnav-option-param. Arrow keys move a highlight among
261
+ // the options WITH NO ROUND TRIP; Enter picks the highlighted option by
262
+ // CLICKING IT (so its own on(:select) reactive trigger fires — selection stays
263
+ // a signed action); Escape clears. Ephemeral highlight state lives on the DOM
264
+ // (data-reactive-highlighted), never shipped to the client as trusted state.
265
+ listnavNext(event) {
266
+ this.#moveHighlight(event, +1)
267
+ }
268
+
269
+ listnavPrev(event) {
270
+ this.#moveHighlight(event, -1)
271
+ }
272
+
273
+ // Enter: activate the highlighted option (fires its reactive select). No-op if
274
+ // nothing is highlighted, and in that case DON'T preventDefault — Enter falls
275
+ // through (there's no selection to make).
276
+ listnavPick(event) {
277
+ const options = this.#listnavOptions(event)
278
+ const current = options.findIndex((el) => el.hasAttribute("data-reactive-highlighted"))
279
+ if (current < 0) return
280
+ event.preventDefault()
281
+ options[current].click()
282
+ }
283
+
284
+ listnavClose(event) {
285
+ for (const el of this.#listnavOptions(event)) el.removeAttribute("data-reactive-highlighted")
286
+ }
287
+
288
+ // Move the highlight by `step` (with wrap-around) among THIS root's options.
289
+ // preventDefault stops Arrow keys from moving the caret in the search input.
290
+ #moveHighlight(event, step) {
291
+ const options = this.#listnavOptions(event)
292
+ if (!options.length) return
293
+ event.preventDefault()
294
+
295
+ const current = options.findIndex((el) => el.hasAttribute("data-reactive-highlighted"))
296
+ // From nothing: Down highlights the first option, Up the last.
297
+ const next = current < 0 ? (step > 0 ? 0 : options.length - 1) : (current + step + options.length) % options.length
298
+
299
+ for (const el of options) el.removeAttribute("data-reactive-highlighted")
300
+ const chosen = options[next]
301
+ chosen.setAttribute("data-reactive-highlighted", "true")
302
+ chosen.scrollIntoView?.({ block: "nearest" })
303
+ }
304
+
305
+ // The option elements this root owns (skips nested reactive roots, issue #15),
306
+ // per the selector on data-reactive-listnav-option-param. The attr rides on the
307
+ // TRIGGER element (the search input on(...) is spread onto), read from the
308
+ // event; the options are still scoped to this controller's root. Empty when
309
+ // unset. Falls back to the root for a directly-invoked call (unit tests).
310
+ #listnavOptions(event) {
311
+ const trigger = event?.currentTarget ?? event?.target ?? this.element
312
+ const selector =
313
+ trigger.getAttribute?.("data-reactive-listnav-option-param") ??
314
+ this.element.getAttribute("data-reactive-listnav-option-param")
315
+ if (!selector) return []
316
+ const nodes = this.element.querySelectorAll(selector)
317
+ return Array.from(nodes).filter((el) => this.#ownsField(el))
318
+ }
319
+
320
+ // Parse a JSON string list from a root data attr; [] on absence/parse error so
321
+ // a malformed binding degrades to "no fields" rather than throwing on input.
322
+ #parseComputeList(attr) {
323
+ const raw = this.element.getAttribute(attr)
324
+ if (!raw) return []
325
+ try {
326
+ const list = JSON.parse(raw)
327
+ return Array.isArray(list) ? list : []
328
+ } catch {
329
+ return []
330
+ }
331
+ }
332
+
333
+ // The first named control owned by THIS root (skips nested reactive roots,
334
+ // issue #15) — used by recompute to read inputs and write outputs.
335
+ #ownedField(name) {
336
+ const nodes = this.element.querySelectorAll(`[name="${name}"]`)
337
+ for (const el of nodes) if (this.#ownsField(el)) return el
338
+ return null
339
+ }
340
+
341
+ // A field's value as a Number, treating blank/absent/NaN as 0 — mirroring the
342
+ // nanToZero coercion the hand-written calculators use, so a reducer never sees
343
+ // "" or NaN for an empty field.
344
+ #numericFieldValue(name) {
345
+ const field = this.#ownedField(name)
346
+ const n = Number(field?.value)
347
+ return Number.isFinite(n) ? n : 0
348
+ }
349
+
212
350
  // Enqueue the action — debounced if a debounce window is set, else immediately.
213
351
  // Split out of dispatch so both the no-confirm fast path and the post-confirm
214
352
  // microtask share one place (issue #55). `target` is captured up front because
@@ -62,6 +62,13 @@ module Phlex
62
62
  # A declared, client-invokable action and its param schema.
63
63
  Action = Data.define(:name, :params)
64
64
 
65
+ # A declared client-side computation (data binding). `inputs`/`outputs` are
66
+ # the action-param names of the fields the reducer reads/writes; `reducer`
67
+ # is the key a JS function is registered under (Reactive.compute(key, fn)).
68
+ # The generic controller runs the reducer on `input` — writing outputs with
69
+ # NO round trip — then the debounced POST reconciles from the server reply.
70
+ ComputeDefinition = Data.define(:name, :inputs, :outputs, :reducer)
71
+
65
72
  # A declared add/remove-row collection (issue #35): the list contract tied
66
73
  # into one unit — the per-row item component, the container DOM id rows
67
74
  # live in, an optional companion count id, an optional empty-state
@@ -175,6 +182,36 @@ module Phlex
175
182
  reactive_collections.key?(name.to_sym)
176
183
  end
177
184
 
185
+ # Declare a client-side computation, OR (called with just a name) read
186
+ # one back. Dual-purpose so a component reads `reactive_compute :split,
187
+ # inputs: …, outputs: …` and the endpoint/helpers read
188
+ # `reactive_compute(:split)` — mirroring how `on`/`reactive_field` keep a
189
+ # tight surface. `reducer:` defaults to the compute name.
190
+ #
191
+ # reactive_compute :payment_split,
192
+ # inputs: %i[allowance cash leasing total], # fields the JS reducer reads
193
+ # outputs: %i[allowance cash leasing] # fields it writes (no round trip)
194
+ #
195
+ # Register the matching JS once at boot:
196
+ # import { setComputeReducer } from "phlex/reactive/compute"
197
+ # setComputeReducer("payment_split", ({ allowance, cash, leasing, total }) => ({ … }))
198
+ def reactive_compute(name, inputs: nil, outputs: nil, reducer: nil)
199
+ return reactive_computes[name.to_sym] if inputs.nil? && outputs.nil?
200
+
201
+ reactive_computes[name.to_sym] = ComputeDefinition.new(
202
+ name: name.to_sym, inputs: Array(inputs).map(&:to_sym),
203
+ outputs: Array(outputs).map(&:to_sym), reducer: (reducer || name).to_s
204
+ )
205
+ end
206
+
207
+ def reactive_computes
208
+ @reactive_computes ||= superclass.respond_to?(:reactive_computes) ? superclass.reactive_computes.dup : {}
209
+ end
210
+
211
+ def reactive_compute?(name)
212
+ reactive_computes.key?(name.to_sym)
213
+ end
214
+
178
215
  # The record's instance-variable symbol (e.g. :@todo), computed once.
179
216
  # reactive_token reads it on every render; interpolating :"@#{key}" each
180
217
  # time would allocate a symbol per render. Nil when record-less. Memoized
@@ -313,22 +350,53 @@ module Phlex
313
350
  # enqueue/debounce if the user declines (and prevents the native default so
314
351
  # a `submit` trigger can't navigate on cancel). Omit it for no prompt.
315
352
  # button(**on(:destroy, confirm: "Really delete this item?")) { "Delete" }
353
+ #
354
+ # `event:` is interpolated verbatim into the Stimulus action descriptor
355
+ # (`#{event}->reactive#dispatch`), so any Stimulus event string works —
356
+ # including its native KEYBOARD FILTERS. Pass `event: "keydown.enter"` for
357
+ # Enter-to-submit or `event: "keydown.esc"` for Escape-to-cancel, and the
358
+ # action fires only on that key — no separate option, no client code, and
359
+ # `key` stays free as an ordinary action-param name (on(:switch, key: …)):
360
+ # input(**on(:add, event: "keydown.enter")) # Enter submits
361
+ # button(**on(:cancel, event: "keydown.esc")) # Escape cancels
362
+ #
363
+ # `listnav:` (a CSS selector for the option elements) adds keyboard list
364
+ # navigation to a search/combobox trigger (issue #72). It appends Stimulus
365
+ # keyboard filters to the SAME element's data-action so Arrow Up/Down move a
366
+ # client-side highlight among the options, Enter picks the highlighted one
367
+ # (clicking its own reactive trigger — so selection stays a signed action),
368
+ # and Escape clears — all with NO server round trip for the highlight (the
369
+ # controller's listnav* handlers, like #recompute). Omit it for no nav.
370
+ # input(**on(:search, event: "input", debounce: 300, listnav: "[role=option]"))
316
371
  # The verbatim JSON for an empty explicit-params payload. The common
317
372
  # trigger (on(:increment), no params) hits this on EVERY render — skipping
318
373
  # params.to_json (which re-serializes {} to the same "{}" each time) avoids
319
374
  # a per-render allocation while keeping the wire format byte-identical.
320
375
  EMPTY_PARAMS_JSON = "{}"
321
376
 
322
- def on(action_name, event: "click", debounce: nil, confirm: nil, **params)
377
+ # The keyboard filters appended to a listnav trigger's data-action. Each is
378
+ # a client-only handler (no POST) except Enter, which clicks the highlighted
379
+ # option's own reactive trigger. Stimulus binds these natively.
380
+ LISTNAV_ACTIONS = [
381
+ "keydown.down->reactive#listnavNext",
382
+ "keydown.up->reactive#listnavPrev",
383
+ "keydown.enter->reactive#listnavPick",
384
+ "keydown.esc->reactive#listnavClose"
385
+ ].freeze
386
+
387
+ def on(action_name, event: "click", debounce: nil, confirm: nil, listnav: nil, **params)
388
+ action = "#{event}->reactive#dispatch"
389
+ action = "#{action} #{LISTNAV_ACTIONS.join(" ")}" if listnav
323
390
  attrs = {
324
391
  data: {
325
- action: "#{event}->reactive#dispatch",
392
+ action:,
326
393
  reactive_action_param: action_name.to_s,
327
394
  reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
328
395
  }
329
396
  }
330
397
  attrs[:data][:reactive_debounce_param] = debounce if debounce
331
398
  attrs[:data][:reactive_confirm_param] = confirm if confirm
399
+ attrs[:data][:reactive_listnav_option_param] = listnav if listnav
332
400
  attrs[:type] = "button" if event == "click"
333
401
  attrs
334
402
  end
@@ -353,6 +421,28 @@ module Phlex
353
421
  input(**reactive_field(param, **attrs))
354
422
  end
355
423
 
424
+ # Data attributes declaring a client-side compute for the root element.
425
+ # Spread ALONGSIDE reactive_root so the generic controller can find the
426
+ # reducer and the named input/output fields inside this root:
427
+ # div(**mix(reactive_root, reactive_compute_attrs(:payment_split))) { … }
428
+ #
429
+ # It emits the reducer key plus the input/output field names as JSON so the
430
+ # client runs the reducer on `input`, writes the outputs with no round trip,
431
+ # then the debounced POST reconciles from the server reply. Raises for an
432
+ # undeclared compute — a silent no-op would leave the field wiring dead.
433
+ def reactive_compute_attrs(name)
434
+ definition = self.class.reactive_compute(name)
435
+ raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition
436
+
437
+ {
438
+ data: {
439
+ reactive_compute_reducer_param: definition.reducer,
440
+ reactive_compute_inputs_param: definition.inputs.map(&:to_s).to_json,
441
+ reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
442
+ }
443
+ }
444
+ end
445
+
356
446
  # Render a <select> bound to an action param (issue #23). The options block
357
447
  # is the element's content, so the awkward FormBuilder positional split
358
448
  # (where name: lands after the options/html-options args) goes away:
@@ -403,12 +493,23 @@ module Phlex
403
493
  # (e.g. which column an inline_edit may write) tamper-proof alongside the
404
494
  # record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
405
495
  # unchanged.
496
+ #
497
+ # A record that is NOT YET PERSISTED (new_record?) has no id → no GlobalID
498
+ # (to_gid raises MissingModelIdError). A record-backed component may render
499
+ # such a draft (an unsaved order the user is building): we OMIT gid and rely
500
+ # on the declared state (reactive_state) as the draft seed, so the token
501
+ # still signs cleanly and the client controller mounts. The draft is then
502
+ # driven client-side (reactive_compute) until it's saved; once persisted, a
503
+ # re-render signs the gid as usual. If the record is unsaved AND no state is
504
+ # declared, the token carries just {c} — enough to mount, but with no
505
+ # identity to round-trip, so declare reactive_state for a draft you sync.
406
506
  def reactive_token
407
507
  klass = self.class
408
508
  payload = { "c" => klass.name }
409
509
 
410
510
  if (record_ivar = klass.reactive_record_ivar)
411
- payload["gid"] = instance_variable_get(record_ivar).to_gid.to_s
511
+ record = instance_variable_get(record_ivar)
512
+ payload["gid"] = record.to_gid.to_s unless record.respond_to?(:persisted?) && !record.persisted?
412
513
  end
413
514
 
414
515
  state_ivars = klass.reactive_state_ivars
@@ -27,6 +27,7 @@ module Phlex
27
27
  it.config.assets.precompile += %w[
28
28
  phlex/reactive/reactive_controller.js
29
29
  phlex/reactive/confirm.js
30
+ phlex/reactive/compute.js
30
31
  ]
31
32
  end
32
33
  end
@@ -42,13 +43,28 @@ module Phlex
42
43
  preload: true
43
44
  )
44
45
  # The overridable confirm resolver (issue #55). reactive_controller.js
45
- # imports it relatively (./confirm.js), and an app reuses its themed
46
- # dialog via `import { setConfirmResolver } from "phlex/reactive/confirm"`.
46
+ # imports it by this BARE specifier `import { confirmResolver } from
47
+ # "phlex/reactive/confirm"` NOT a relative "./confirm.js" (issue #57:
48
+ # a relative sibling import inside the digested controller resolves to
49
+ # an undigested /assets/.../confirm.js that 404s under Propshaft). This
50
+ # pin maps the bare specifier to the digested asset, so the controller's
51
+ # own import AND an app's `import { setConfirmResolver } from
52
+ # "phlex/reactive/confirm"` both resolve through the import map.
47
53
  it.importmap.pin(
48
54
  "phlex/reactive/confirm",
49
55
  to: "phlex/reactive/confirm.js",
50
56
  preload: true
51
57
  )
58
+ # The client-side compute (data-binding) registry behind
59
+ # reactive_compute. reactive_controller.js imports it by this bare
60
+ # specifier (same import-map rationale as confirm above), and an app
61
+ # registers reducers via `import { setComputeReducer } from
62
+ # "phlex/reactive/compute"` — both resolve through this pin.
63
+ it.importmap.pin(
64
+ "phlex/reactive/compute",
65
+ to: "phlex/reactive/compute.js",
66
+ preload: true
67
+ )
52
68
  end
53
69
  end
54
70
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.4.6"
5
+ VERSION = "0.4.8"
6
6
  end
7
7
  end
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.4.6
4
+ version: 0.4.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -117,6 +117,7 @@ files:
117
117
  - LICENSE.txt
118
118
  - README.md
119
119
  - app/controllers/phlex/reactive/actions_controller.rb
120
+ - app/javascript/phlex/reactive/compute.js
120
121
  - app/javascript/phlex/reactive/confirm.js
121
122
  - app/javascript/phlex/reactive/reactive_controller.js
122
123
  - lib/generators/phlex/reactive/component/USAGE