Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,50 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **Reducer-emitted client ops (`$ops`), a `submit` op, and declarative
`reactive_on_complete` (#226).** The "normalize on input, commit when
complete" field (a one-time-code entry being the canonical case) is now
declarable end to end, single- or multi-input, with no bespoke controller:
- **`js.submit(to = :root)`** joins the op vocabulary: requestSubmit the
target's own form (the target itself when it *is* a form, `input.form`
for a control, else `closest("form")`). A real cancelable `submit` event
fires, so it composes with native/Turbo forms AND
`on(:save, event: "submit")` interception —
`select(**on_client(:change, js.submit("form")))` is the one-line general
autosubmit. **Actor-only like focus**: refused in `broadcast_to(js:)`
(allowed in `reply.js`); `on_client(:submit, js.submit)` raises (it would
re-fire itself).
- **The reserved `$ops` reducer output**: a `reactive_compute` reducer may
return `$ops` holding an op chain — built with the new immutable `ops`
builder exported from `phlex/reactive/compute` (verbs mirror the wire op
names) or a raw `[[op, args], …]` list — run through the same frozen
client-op whitelist as a final phase after the write set settles.
**Rising-edge, keyed on chain content, event-gated**: an identical chain
never re-fires (a capped 7th keystroke can't re-submit), a changed chain
fires again (per-digit focus advance across split boxes), and the
connect/morph seed pass arms without firing (a validation-error re-render
with a complete value never self-submits).
- **`{ length: … }` in the ONE conditions language**: exact
(`{ length: 6 }`) or Integer-Range length predicates for
`reactive_show` / `reactive_show_targets` / `reactive_on_complete`,
counted in **codepoints** on both sides (Ruby `String#length`, client
`[...value].length`) — the shared parity fixture gains a multibyte proof
vector.
- **`reactive_on_complete`** — the zero-JavaScript declarative twin: the
same `if:`/`if_any:`/`unless:` kwargs as `reactive_show` plus `run:` (a
`js` chain, now buildable at class level, or an allowlist-checked raw
list), emitted as one root attr and evaluated by the generic controller
with the same rising-edge/arming semantics as `$ops`.
`reactive_on_complete if: { code: { length: 6 } },
run: js.dispatch("code:complete")` + `on(:verify, event: "code:complete")`
is a complete auto-committing code field with no reducer at all.
- Dummy flagships: `VerificationCodeComponent` (single input + `$ops`
submit), `SplitCodeComponent` (six boxes: paste redistribution,
reducer-driven focus advance, hidden joined-code output),
`CodeCompleteComponent` (declarative, zero JS), and the
`AutosubmitFilterComponent` select-driven GET filter — each with a
real-browser system spec.

- **Instance-dynamic wire names — keyword escape hatches on the
field-compiling helpers (#224).** A form builder's wire name is computed per
instance (`user[tags]`), which the class-level `reactive_scope` compile can't
Expand Down Expand Up @@ -240,6 +284,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **`rake bench` ran the removed `broadcast_replace_to` API and exited 1.**
`benchmark/micro/broadcast.rb` migrated to the #185 spellings
(`broadcast_to(*key, replace:)` / `broadcast_to(each:, replace:)`); the
transport double is unchanged.

- **`reactive_nested_remove(confirm:)` now interpolates `%{field}` on client-added
rows (#222).** A row added in the browser via `reactive_nested_add` is a
`cloneNode` of the `<template>`, and the clone path (`#renumberNestedRow` /
Expand Down
136 changes: 135 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,16 @@ button(**on_client(:click, js
another component or a plain Stimulus controller can react to a client-only
interaction — `to:` picks the element (default: the component root), `detail:`
is the payload.
- **`submit(to = :root)`** commits the **target's own form** via
`requestSubmit()`: the target itself when it *is* a form, its form owner for a
control (`input.form`, honoring a `form=` attribute), else the nearest ancestor
form. Constraint validation runs and a **real cancelable `submit` event**
fires, so it composes with both a native/Turbo form *and* an
`on(:save, event: "submit")` interception. **Actor-only like focus**: allowed
from `on_client` / `reply.js` / a reducer's `$ops`, refused in
`broadcast_to(js:)` (a broadcast would force-submit every subscriber's form).
Binding a submit op to the `submit` event itself raises at render — it would
re-fire itself forever.
- **`transition: { during:, from:, to: }`** on `show`/`hide`/`toggle` animates the
visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next
frame, and the helper classes are cleaned up on `animationend` (with a timeout
Expand All @@ -863,6 +873,27 @@ button(**on_client(:click, js
modifiers: the dropdown above closes on any click outside the component, and
window-bound triggers never `preventDefault`, so links elsewhere keep working.

**The general autosubmit story.** With `submit` in the vocabulary, the classic
`onchange="this.form.requestSubmit()"` filter form is one declared line — no
bespoke controller, no reactive action, and Turbo Drive turns the resulting
submit into a normal visit:

```ruby
form(action: "/products", method: "get") do
select(name: "sort", **mix(on_client(:change, js.submit("form")), data: { testid: "sort" })) do
option(value: "name") { "Name" }
option(value: "price") { "Price" }
end
end
```

Use `change`-bound autosubmit for discrete controls (selects, radios,
checkboxes). For a **text** field that should commit "when the value is
complete," an unconditional `on_client(:input, js.submit)` would fire on every
keystroke — that conditional case is exactly what a reducer's
[`$ops`](#client-side-computes-reactive_compute--reactive_text) and
[`reactive_on_complete`](#declarative-completion-reactive_on_complete) are for.

**Client ops are ephemeral UI — the one contract to internalize.** Any server
re-render of the component (an action reply, a broadcast, a morph) rebuilds
from server state and resets whatever the ops toggled: the menu closes, the tab
Expand Down Expand Up @@ -958,7 +989,11 @@ end
- **The value language**: a **Hash is an AND** (multiple keys ANDed), an
**Array is membership**, a **Range is a threshold** (`10..` ≥ 10, `..10` ≤ 10,
`...10` < 10, `10..20` between), `true`/`false` compare a checkbox's checked
state, `nil` matches blank. `unless:` **negates** and composes with `if:`.
state, `nil` matches blank, and **`{ length: … }` compares the value's
length** — exact (`{ length: 6 }`) or an Integer Range (`{ length: 6.. }`,
`{ length: 4..8 }`). Length counts **codepoints** on both sides (Ruby
`String#length`, client `[...value].length`), so multibyte input agrees; a
blank field has length 0. `unless:` **negates** and composes with `if:`.
Never an expression — every term is a declared literal, so there is no eval
surface. A blank/non-numeric value fails a numeric term **closed** (hidden).
- **OR-of-AND** — `if_any:` takes an array of AND-hashes (`if_any: [{ director:
Expand Down Expand Up @@ -1097,6 +1132,105 @@ setComputeReducer("preview", ({ title }) => ({
a later morph repaints stale text — the same reconcile contract the whole
new-vs-persisted split relies on.

**Reducer-emitted ops (`$ops`) — commit when complete.** A reducer's outputs
write fields and text; the reserved **`$ops`** key lets it emit a **conditional
side effect** — the missing piece that used to force a bespoke controller next
to an otherwise-declarative compute. Return an op chain (the `ops` builder
mirrors the Ruby `js` verbs, or use a raw `[[op, args], …]` array) and the
controller runs it through the **same frozen op whitelist** `on_client` uses,
as a final phase **after** the field writes, text sinks, and their dispatched
`input` events settle. The canonical one-time-code field:

```js
import { setComputeReducer, ops } from "phlex/reactive/compute"

setComputeReducer("otp", ({ code }) => {
const digits = code.replace(/\D/g, "").slice(0, 6)
return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }
})
```

```ruby
form(action: "/verify", method: "post",
**mix(reactive_root(compute: :otp), on(:verify, event: "submit"))) do
input(name: "code", inputmode: "numeric", autocomplete: "one-time-code")
end
```

Typing, pasting `123-456`, or platform SMS autofill all arrive as `input`
events → the reducer normalizes, and at six digits `submit` requestSubmits the
form — which `on(:verify, event: "submit")` intercepts into **one signed action
POST**. The contract that makes this safe:

- **Rising edge, keyed on content.** The chain runs only when it **differs**
from the previous pass's chain (including from "absent"). Returning the same
chain again is settled — a 7th keystroke capped back to the same six digits
can't re-submit — while a **different** chain fires again (a multi-box
reducer advancing focus emits a new `ops.focus` target per digit). A pass
returning `null`/no `$ops` re-arms.
- **Event-gated.** The connect/morph **seed pass arms without firing** — a form
re-rendered with an already-complete value (a validation-error morph, a
browser restore) never auto-fires, which is what breaks the
submit → error re-render → re-seed → submit loop.
- **Whitelisted.** `$ops` is consumed before the write phases (never painted as
a field/text/mirror), and unknown ops warn-and-skip while siblings apply.
- **Multi-input works with the same machinery**: declare all boxes as inputs,
join + redistribute in the reducer (a paste into any box fans out one digit
per box), mirror the joined value into a hidden field, advance focus with a
per-digit `ops.focus`, and `ops.submit()` on completion. See
`spec/dummy/app/components/split_code_component.rb` for the full six-box
example.

When you *don't* want to auto-submit, the same slot dispatches a completion
event (`ops.dispatch("code:complete")`) for a sibling to react to, or enables
the submit button (`ops.remove_attr("[type=submit]", "disabled")`) and leaves
the commit to the user.

### Declarative completion (`reactive_on_complete`)

The `$ops` escape hatch puts the condition in the reducer; when the condition
is expressible in the [conditions language](#value-conditional-visibility-reactive_show),
`reactive_on_complete` declares the whole binding in Ruby — **zero JavaScript,
no reducer**:

```ruby
class CodeCompleteComponent < ApplicationComponent
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component

reactive_state :code
action :verify, params: { code: :string }

reactive_on_complete if: { code: { length: 6 } }, run: js.dispatch("code:complete")

def view_template
div(**mix(reactive_root, on(:verify, event: "code:complete"))) do
input(name: "code")
end
end
end
```

The generic controller evaluates the conditions over the owned fields on every
`input`/`change` (scope-aware, same resolver as `reactive_show`) and runs the
declared ops on the **rising edge** — once, when the conditions first become
true; going false re-arms; the connect/morph pass arms **without** firing, so a
re-render with already-satisfied conditions never self-fires. The pieces:

- **Conditions** are the same `if:` / `if_any:` / `unless:` kwargs
`reactive_show` takes — including the `length:` form above, which is what
makes "exactly six characters" declarable.
- **`run:`** is a `js` chain (available at class level) or a raw op list
(re-checked through the attribute allowlist). `run: js.submit` auto-commits;
`run: js.dispatch(...)` lets a sibling `on(:action, event: "...")` turn
completion into a signed action, as above.
- **Several bindings** coexist under names:
`reactive_on_complete :commit, if: …, run: js.submit` — each latches
independently; redeclaring a name overrides it (normal registry inheritance).
- Prefer `$ops` when completion needs **normalization first** (strip
separators, cap length) — the reducer already knows the cleaned value;
prefer `reactive_on_complete` when the raw field value is the truth.

### Cross-root mirrors (`mirror:`) — painting a recap outside the root

`reactive_text` is deliberately **root-isolated** (a nested component's nodes are
Expand Down
152 changes: 152 additions & 0 deletions app/javascript/phlex/reactive/compute.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,39 @@
// the re-entrant `changed === "field_c"` pass derives field_a back to the
// value it already holds — no write, no event, settled in one bounce.

// THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a
// reducer may return the reserved key `$ops` holding a chain of client DOM
// ops — built with the `ops` builder below, or a raw [[name, args], ...]
// array. The controller consumes it as a PHASE 4 of the single-pass write set:
// the ops run AFTER the field writes, text sinks, and phase-3 input dispatches
// settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an
// unknown op warns + is skipped). `null`/`undefined`/absent = no effect.
//
// RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it
// DIFFERS from the previous pass's chain (including from "absent"), and only
// on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no
// re-fire (a 7th keystroke capped back to the same complete value can't
// re-submit), mirroring the change-guarded field writes. Returning a
// DIFFERENT chain fires again — a multi-box reducer advancing focus
// box-by-box emits a new focus target per digit, each a new intent. The
// connect/morph SEED pass (issue #199 — recompute with no event) ARMS the
// latch but never fires — a form re-rendered with an already-complete value
// (a validation-error morph, a browser restore) must not auto-fire; that is
// what breaks the submit → error re-render → re-seed → submit loop. A later
// pass returning no $ops re-arms. The canonical use — a one-time-code field
// that normalizes on input and commits when complete:
//
// import { setComputeReducer, ops } from "phlex/reactive/compute"
// setComputeReducer("otp", ({ code }) => {
// const digits = code.replace(/\D/g, "").slice(0, 6)
// return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }
// })
//
// `submit` commits the target's own form via requestSubmit() — the real submit
// event fires, so an on(:verify, event: "submit") interception (or a native/
// Turbo form) handles it exactly like a user submit. submit/focus are
// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.

const reducers = new Map()

// Register (or replace) the reducer for `key`. `fn` is
Expand All @@ -91,3 +124,122 @@ export function computeReducer(key) {
export function __resetComputeRegistryForTest() {
reducers.clear()
}

// --- The reducer-side op-chain builder (issue #226) --------------------------
//
// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder: verbs carry
// the WIRE op names (snake_case) and append [name, args] pairs; every verb
// returns a NEW instance, so a chain held in a constant can never be mutated by
// later use. `.ops` exposes the raw [[name, args], ...] list the controller
// interprets (and toJSON serializes it, so a chain can also feed a hand-built
// ops attr). Targets: a CSS selector string, or omit for "@root" (the
// component's own root). No build-time attr validation here — the interpreter's
// allowlist (guardAttr) is the enforcement point; this builder only shapes the
// wire.
const ROOT_SENTINEL = "@root"

function targetArgs(to, { global, transition } = {}) {
const args = { to: to ?? ROOT_SENTINEL }
if (global) args.global = true
if (transition) args.transition = normalizeTransition(transition)
return args
}

// Named legs { during, from, to } → the [during, from, to] wire array (the
// issue #186 vocabulary). Loud at authoring time, like the Ruby builder.
// Frozen, like every nested payload — the chain's immutability contract must
// hold all the way down (the Ruby twin freezes its legs array too).
function normalizeTransition(transition) {
const named =
transition && typeof transition === "object" && !Array.isArray(transition) &&
["during", "from", "to"].every((k) => k in transition)
if (!named) throw new Error("[phlex-reactive] ops transition takes named legs { during, from, to }")
return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])
}

class OpsChain {
constructor(list = Object.freeze([])) {
this.ops = list
Object.freeze(this)
}

show(to, opts) {
return this.#append("show", targetArgs(to, opts))
}

hide(to, opts) {
return this.#append("hide", targetArgs(to, opts))
}

toggle(to, opts) {
return this.#append("toggle", targetArgs(to, opts))
}

add_class(to, classes, opts) {
return this.#append("add_class", classArgs(to, classes, opts))
}

remove_class(to, classes, opts) {
return this.#append("remove_class", classArgs(to, classes, opts))
}

toggle_class(to, classes, opts) {
return this.#append("toggle_class", classArgs(to, classes, opts))
}

set_attr(to, name, value, opts) {
return this.#append("set_attr", { ...targetArgs(to, opts), name: String(name), value: String(value) })
}

remove_attr(to, name, opts) {
return this.#append("remove_attr", { ...targetArgs(to, opts), name: String(name) })
}

toggle_attr(to, name, opts) {
return this.#append("toggle_attr", { ...targetArgs(to, opts), name: String(name) })
}

focus(to, opts) {
return this.#append("focus", targetArgs(to, opts))
}

focus_first(to, opts) {
return this.#append("focus_first", targetArgs(to, opts))
}

text(to, value, opts) {
return this.#append("text", { ...targetArgs(to, opts), value: String(value ?? "") })
}

dispatch(name, { to, detail, global } = {}) {
const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }
if (global) args.global = true
return this.#append("dispatch", args)
}

submit(to, opts) {
return this.#append("submit", targetArgs(to, opts))
}

toJSON() {
return this.ops
}

#append(name, args) {
return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))
}
}

// classes: one class string or an array of them (never whitespace-split — a
// classList token can't contain spaces, so splitting would only mask a bug).
// Loud on an empty/missing list, and frozen (the Ruby twin freezes its class
// list too) — a chain held in a constant must stay immutable all the way down.
function classArgs(to, classes, opts) {
const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)
if (list.length === 0) throw new Error("[phlex-reactive] a class op needs at least one class")
return { ...targetArgs(to, opts), classes: Object.freeze(list) }
}

// The shared empty chain — start every reducer effect from here:
// $ops: done ? ops.dispatch("code:complete").submit() : null
export const ops = new OpsChain()
Loading
Loading