feat(client+component): reducer $ops, submit op, length: predicate, reactive_on_complete (#226) - #227
Conversation
…226) ## Summary Adds `js.submit(to = :root)` to the op builder and `submit` to the client CLIENT_OPS whitelist: resolve the target, then requestSubmit() its OWN form (the target itself when it IS a form, its form owner via input.form, else closest("form")). requestSubmit fires a real cancelable submit event, so it composes with on(:action, event: "submit") interception AND native/Turbo forms — select(**on_client(:change, js.submit("form"))) is the one-line general autosubmit story. Actor-only like focus: BROADCAST_REFUSED_OPS gains "submit" (a broadcast would force-submit every subscriber's form); reply.js still allows it. on_client(:submit, js.submit) raises at render — requestSubmit dispatches the very event that trigger is bound to (infinite loop). ## Test Coverage - js_spec: submit wire format, :root default, global:, target validation - component_spec: submit-event self-loop guard (bare + buried in a chain), change-bound autosubmit allowed, non-submit ops on submit event allowed - js_broadcast_spec: broadcast_to(js:) refuses submit (chain + raw array) - response_spec: reply.js allows submit (actor-scoped, mirroring focus) - reactive_submit_op.test.js: form-control/form/closest resolution, no-form no-op, known-op (no default-deny warn), chain composition - autosubmit_filter_spec (system): select change submits the GET form via Turbo Drive, no full reload (window marker survives) ## Verification - [x] bundle exec rubocop passes - [x] bundle exec rspec spec/phlex spec/requests (1392 examples) - [x] bun test spec/javascript (539 tests) - [x] bundle exec rspec spec/system/autosubmit_filter_spec.rb
benchmark/micro/broadcast.rb still called broadcast_replace_to / broadcast_replace_to_each, removed in issue #185 — every `rake bench` run exited 1 via the guided NoMethodError. Same shape, #185 spellings: broadcast_to(*key, replace: nil) and broadcast_to(each: keys, replace: nil). The Turbo::StreamsChannel transport double is unchanged (the new path still lands on the same channel entry points).
## Summary A reactive_compute reducer may now return the reserved key `$ops` holding a client-op chain — built with the new immutable `ops` builder exported from phlex/reactive/compute (verbs mirror the Ruby js DSL + wire names), or a raw [[name, args], ...] array. The controller consumes it as PHASE 4 of the #183 single-pass write set: ops run through the same frozen CLIENT_OPS whitelist after the field writes, text sinks, and phase-3 input dispatches settle. Rising-edge, event-gated: ops fire only on the transition from "$ops absent last pass" to "present this pass", and only on event-driven passes — the connect/morph seed pass (#199) arms the latch without firing, which is what breaks the submit → error re-render → re-seed → submit loop. A pass with no $ops re-arms. `$ops` is consumed, never written as a field/text sink/mirror. The canonical use ships as the dummy flagship: a one-time-code field whose reducer normalizes on input and returns `$ops: ops.submit()` at 6 digits — requestSubmit fires the real submit event, on(:verify, event: "submit") intercepts it into ONE signed action POST. ## Test Coverage - reactive_compute_ops.test.js (13): builder immutability + wire shapes, rising edge, no re-fire while complete, re-arm, seed-arms-without-firing, raw array + missing to: defaults to @root, outputs:-declared $ops never written, unknown-op warn-skip with surviving siblings, non-chain value default-deny, ops run after phase-3 dispatches - verification_code_spec (system, real browser): dirty paste → normalize → exactly ONE POST, no navigation; post-replace seed never self-fires; extra digit capped with no re-fire; incomplete → complete fires once more ## Verification - [x] bun test spec/javascript (552 tests) - [x] bundle exec rspec spec/phlex spec/requests (1392 examples) - [x] bundle exec rspec spec/system/verification_code_spec.rb - [x] bundle exec rubocop passes
…dvance (#226) ## Summary The $ops rising-edge latch now compares the serialized chain against the previous pass instead of mere presence. An identical chain is settled — no re-fire (the capped-7th-digit and post-replace-seed safety all hold) — while a CHANGED chain fires again. That unlocks the multi-box OTP shape: one reducer joins six boxes (a paste into any box redistributes one digit per box), mirrors the joined code into a hidden field, advances focus to the first empty box with a per-digit focus op (a different target each pass), and submits on completion. Ships SplitCodeComponent + split_code_reducer.js as the multi-input dummy flagship beside the single-input VerificationCodeComponent. ## Test Coverage - reactive_compute_ops.test.js: a changed chain fires per pass while an unchanged pass stays settled (all presence-latch tests pass unchanged) - split_code_spec (system): dirty paste into box 1 redistributes + commits exactly once; typing advances focus box-by-box (hidden-code barrier) and the sixth digit submits exactly once ## Verification - [x] bun test spec/javascript (553 tests) - [x] bundle exec rspec spec/system (the three #226 specs) - [x] vendored copies re-synced (sync spec green) - [x] bundle exec rubocop passes
…#226) ## Summary Two halves of the declarative completion story: 1. The ONE ShowConditions language gains a length: value form — { length: 6 } (exact, len_eq) and Integer Ranges (len_gte/len_lte/len_lt), with exact De Morgan complements under unless:. Length is counted in CODEPOINTS on BOTH sides (Ruby String#length, client [...value].length — NOT UTF-16 .length); the shared parity fixture gains 9 vectors including the multibyte emoji proof. reactive_show gets length: for free. 2. reactive_on_complete — a class-level declarative completion binding: the same if:/if_any:/unless: kwargs reactive_show takes, plus run: (a js chain, now buildable at class level via a class-side `js` helper, or a raw op list re-checked through the attr allowlist). Emitted by reactive_attrs as ONE data-reactive-on-complete JSON attr (per-class memo keyed on the registry generation — the reactive_effect_attrs precedent; byte-stable wire when undeclared). The client gates on the attr, listens input/change/turbo:morph-element, evaluates each binding's DNF with the scope-aware show-sync field resolver, and runs its ops on the RISING EDGE — connect/morph passes arm without firing; going false re-arms. The dummy CodeCompleteComponent proves the zero-JavaScript path end to end: a Ruby-only declaration dispatches code:complete at exactly six characters, intercepted by on(:verify, event: "code:complete") into one signed POST. ## Test Coverage - show_conditions_spec: length compile forms, range legs, unless complements, validation raises, codepoint evaluation, blank-length-0 - show_predicate_vectors.json: 9 new vectors, both parity loops green - component_spec: registry/inheritance/naming, class-level js, run: validation (type, empty, hostile raw list), conditions required, wire emission + byte-stable-when-undeclared - reactive_on_complete.test.js (9): arm-without-firing on connect/morph, rising edge, re-arm, independent latches, scope-aware resolution, selector-target ops, malformed default-deny, teardown - code_complete_spec (system): fires exactly once at six chars, seventh char re-arms (condition false), trim back to six fires again ## Verification - [x] bundle exec rspec spec/phlex spec/requests (1428 examples) - [x] bun test spec/javascript (571 tests) - [x] bundle exec rspec spec/system/code_complete_spec.rb - [x] bundle exec rubocop (298 files, clean)
README: submit bullet in the op vocabulary + the general autosubmit story; length: in the reactive_show value language; the $ops contract (rising edge keyed on content, event-gated seed, multi-box recipe) in the compute section; a new "Declarative completion (reactive_on_complete)" section. Docs site: example_client_ops (vocabulary + autosubmit + on_complete), example_payment_split (a dedicated $ops section), actions_events (on_client submit note), security (the actor-only op tier under Rule 2). CHANGELOG: the #226 feature set under Added; the broadcast micro-bench migration under Fixed.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThis PR adds reducer-emitted ChangesReactive client orchestration
Broadcast benchmark update
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant ReactiveController
participant Reducer
participant Form
User->>ReactiveController: Enter or change value
ReactiveController->>Reducer: Run reactive compute
Reducer-->>ReactiveController: Normalized fields and $ops
ReactiveController->>Form: Apply updates, then requestSubmit()
Possibly related PRs
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Warning Tools execution failed with the following error: Failed to run tools: 13 INTERNAL: Received RST_STREAM with code 2 (Internal server error) Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
spec/system/code_complete_spec.rb (1)
13-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the duplicated
install_post_counterhelper into a shared system-spec support module.The exact same
install_post_countermethod (identical JS heredoc and body) is copy-pasted across three system specs. One shared helper (e.g. inspec/support/) would avoid drift if the fetch-counting logic ever needs to change.
spec/system/code_complete_spec.rb#L13-L23: remove this local definition and include the shared support helper instead.spec/system/split_code_spec.rb#L11-L21: remove this local definition and include the shared support helper instead.spec/system/verification_code_spec.rb#L15-L25: remove this local definition and include the shared support helper instead.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@spec/system/code_complete_spec.rb` around lines 13 - 23, Extract the duplicated install_post_counter helper into one shared system-spec support module, preserving its existing JavaScript behavior. Remove the local definitions and include the shared helper in spec/system/code_complete_spec.rb lines 13-23, spec/system/split_code_spec.rb lines 11-21, and spec/system/verification_code_spec.rb lines 15-25.lib/phlex/reactive/component/dsl.rb (1)
548-568: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueInserting
normalize_on_complete_opshere detached thenormalize_compute_inputsdoc comment from its method.The pre-existing block at Lines 535–547 ("Split the
inputs:argument… THREE shapes…") documentsnormalize_compute_inputs(Line 568), but the newnormalize_on_complete_ops(Lines 548–565) now sits between them — so that detailed doc reads as if it belongs tonormalize_on_complete_ops, andnormalize_compute_inputsis left with only the terse# Named after the shape it normalizes.line. Consider movingnormalize_on_complete_ops(with its own comment) to sit alongside the other completion helpers (nearreactive_on_complete_attr), or relocate thenormalize_compute_inputsdoc block back above its method.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/phlex/reactive/component/dsl.rb` around lines 548 - 568, Restore the documentation association for normalize_compute_inputs by moving its detailed “inputs:” shape comment immediately above that method, or move normalize_on_complete_ops alongside the other completion helpers near reactive_on_complete_attr. Keep normalize_on_complete_ops’s own validation comment with that method and retain the existing terse shape comment only where appropriate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/phlex/reactive/component/dsl.rb`:
- Around line 487-488: Define the missing OnCompleteDefinition class used by
reactive_on_complete, with accessible conditions and ops fields matching the
arguments it receives. Ensure reactive_on_complete_attr can read both fields and
the DSL loads without raising NameError.
In `@spec/dummy/public/vendor/compute.js`:
- Line 1: Update the source operation builder, especially transition creation in
K and class normalization in D, to freeze the nested transition and classes
arrays before storing them in operation payloads. Preserve the existing
validation and builder behavior, then regenerate the bundled compute.js fixture
so ops.ops and toJSON() cannot mutate those arrays after construction.
In `@spec/dummy/public/vendor/reactive_controller.js`:
- Line 1: Simple reactive_show bindings in nX omit the length predicates defined
by aX, so len_eq/len_gte/len_gt/len_lte/len_lt are ignored. Extend nX to iterate
aX, coerce each predicate attribute value to an integer, and evaluate it through
rX while preserving existing numeric predicate handling; then regenerate the
bundled reactive_controller.js artifact.
---
Nitpick comments:
In `@lib/phlex/reactive/component/dsl.rb`:
- Around line 548-568: Restore the documentation association for
normalize_compute_inputs by moving its detailed “inputs:” shape comment
immediately above that method, or move normalize_on_complete_ops alongside the
other completion helpers near reactive_on_complete_attr. Keep
normalize_on_complete_ops’s own validation comment with that method and retain
the existing terse shape comment only where appropriate.
In `@spec/system/code_complete_spec.rb`:
- Around line 13-23: Extract the duplicated install_post_counter helper into one
shared system-spec support module, preserving its existing JavaScript behavior.
Remove the local definitions and include the shared helper in
spec/system/code_complete_spec.rb lines 13-23, spec/system/split_code_spec.rb
lines 11-21, and spec/system/verification_code_spec.rb lines 15-25.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 26e5ccab-1682-4975-840e-f5f69e848b5a
⛔ Files ignored due to path filters (4)
app/javascript/phlex/reactive/compute.min.jsis excluded by!**/*.min.jsapp/javascript/phlex/reactive/compute.min.js.mapis excluded by!**/*.map,!**/*.min.js.mapapp/javascript/phlex/reactive/reactive_controller.min.jsis excluded by!**/*.min.jsapp/javascript/phlex/reactive/reactive_controller.min.js.mapis excluded by!**/*.map,!**/*.min.js.map
📒 Files selected for processing (41)
CHANGELOG.mdREADME.mdapp/javascript/phlex/reactive/compute.jsapp/javascript/phlex/reactive/reactive_controller.jsbenchmark/micro/broadcast.rbdocs/app/views/docs/pages/actions_events.rbdocs/app/views/docs/pages/example_client_ops.rbdocs/app/views/docs/pages/example_payment_split.rbdocs/app/views/docs/pages/security.rblib/phlex/reactive/component.rblib/phlex/reactive/component/dsl.rblib/phlex/reactive/component/helpers.rblib/phlex/reactive/component/registry.rblib/phlex/reactive/js.rblib/phlex/reactive/show_conditions.rblib/phlex/reactive/streamable.rbspec/dummy/app/components/autosubmit_filter_component.rbspec/dummy/app/components/code_complete_component.rbspec/dummy/app/components/split_code_component.rbspec/dummy/app/components/verification_code_component.rbspec/dummy/app/controllers/demos_controller.rbspec/dummy/app/views/layouts/application.html.erbspec/dummy/config/routes.rbspec/dummy/public/vendor/compute.jsspec/dummy/public/vendor/otp_reducer.jsspec/dummy/public/vendor/reactive_controller.jsspec/dummy/public/vendor/split_code_reducer.jsspec/fixtures/show_predicate_vectors.jsonspec/javascript/reactive_compute_ops.test.jsspec/javascript/reactive_on_complete.test.jsspec/javascript/reactive_submit_op.test.jsspec/phlex/reactive/client_bindings_spec.rbspec/phlex/reactive/component_spec.rbspec/phlex/reactive/js_spec.rbspec/phlex/reactive/response_spec.rbspec/phlex/reactive/show_conditions_spec.rbspec/requests/js_broadcast_spec.rbspec/system/autosubmit_filter_spec.rbspec/system/code_complete_spec.rbspec/system/split_code_spec.rbspec/system/verification_code_spec.rb
The compute-ops and on-complete tests replaced globalThis.CustomEvent with a bare recorder class (no cancelable, no preventDefault) and never restored it. bun runs the whole suite in ONE process, so any later file whose events need real semantics inherited the stub — in CI's file order that was reactive_lifecycle_events.test.js (four failures: "event.preventDefault is not a function"); locally reactive_effects.test.js happened to run in between and re-set a working happy-dom CustomEvent, masking the leak. The tests only read event.type/detail, which bun's native CustomEvent already carries — so the stubs are simply removed rather than snapshot-restored.
PR #227 review: the chain froze its outer containers but left the classes and transition arrays reachable-mutable through ops.ops/toJSON(), so a chain held in a constant could drift after construction. Frozen now, mirroring the Ruby builder's .freeze on the same payloads; a deep-immutability unit test locks it. Vendored min build re-synced.
The spec drove the paste with Capybara's .set, whose semantics are driver-version-dependent (the gem root's lockfile is gitignored, so local and CI resolve different capybara-playwright builds). On CI, set interleaved its input events with the reducer's per-event redistribution writes and the six digits landed scrambled (deterministic 498765 on all four matrix jobs) — the per-character flow, which is the TYPING contract the adjacent spec already covers with explicit per-box keystrokes. A paste is ONE input event carrying the full dirty string; the spec now dispatches exactly that, which is what the test was always asserting.
Summary
Implements the full plan from #226 (comment) — the "normalize on input, commit when complete" field is now declarable end to end, single- or multi-input, with no bespoke Stimulus controller. Three composable pieces:
1.
submitclient op (js.submit)requestSubmit()the target's own form: the target itself when it is a form,input.formfor a control (honorsform=), elseclosest("form"). A real cancelablesubmitevent fires, so it composes with native/Turbo forms ANDon(:save, event: "submit")interception.select(**on_client(:change, js.submit("form")))— one declared line, Turbo Drive handles the visit (dummyAutosubmitFilterComponent+ system spec).BROADCAST_REFUSED_OPSgainssubmit(a broadcast would force-submit every subscriber's form);reply.jsstill allows it.on_client(:submit, js.submit)raises at render — it would re-fire itself forever.2. Reserved
$opsreducer output + clientopsbuilderreactive_computereducer may return$opsholding an op chain — the new immutableopsbuilder exported fromphlex/reactive/compute(verbs mirror the wire op names), or a raw[[op, args], …]list. Run through the same frozen CLIENT_OPS whitelist ason_client, as phase 4 of the API simplification, part 2.3: reactive_compute — scoped names, root binding, order-independent writes #183 single-pass write set — after field writes, text sinks, and phase-3inputdispatches settle.submit → validation-error re-render → re-seed → submitloop; returningnullre-arms.VerificationCodeComponent(single input: dirty paste123-456→ normalize → exactly ONE signed POST, no navigation) andSplitCodeComponent(six boxes: a paste into any box redistributes one digit per box, reducer-driven focus advance per digit, hidden joined-codeoutput carries the submit payload).3.
length:predicate + declarativereactive_on_complete{ length: 6 }/ Integer-Range length predicates (len_eq/len_gte/len_gt/len_lte/len_lt), with exact De Morgan complements underunless:. Length counts codepoints on both sides (RubyString#length, client[...value].length— NOT UTF-16.length); the shared parity fixture gains 9 vectors including the multibyte emoji proof.reactive_showgetslength:for free.reactive_on_complete(name = :default, run:, **conditions)— the sameif:/if_any:/unless:kwargs asreactive_show,run:ajschain (now buildable at class level) or an allowlist-re-checked raw list. Emitted byreactive_attrsas onedata-reactive-on-completeJSON attr (per-class memo keyed on the registry generation; byte-stable wire when undeclared). The client evaluates each binding with the scope-aware show-sync field resolver and fires on the rising edge with the same arming semantics as$ops. Works onClientBindings(tokenless) components.CodeCompleteComponentproves the zero-JavaScript path in the browser: a Ruby-only declaration dispatchescode:completeat exactly six characters, intercepted into one signed POST; a 7th character re-arms (condition false) and trimming back to six fires again.Test coverage
js_spec(submit wire),component_spec(on_client self-loop guard;reactive_on_completeregistry/inheritance/validation/wire; byte-stable when undeclared),show_conditions_spec(+16 length examples), parity fixture 35 → 44 vectors,response_spec(reply.js allows submit),client_bindings_spec(tokenless on_complete). Fast suite: 1429 examples.js_broadcast_spec— broadcast refuses submit (chain + raw array).reactive_submit_op.test.js(6),reactive_compute_ops.test.js(14),reactive_on_complete.test.js(9) — 571 tests across 49 files.rake spec:system_servers).Performance
reactive_attrs(render hot path) gains onerespond_to?+ memoized nil read. Same-machine before/after (same checkout,git switch --detach main, serial):render_componentrender_componentallocationsto_stream_replaceMethod-level framing: no measurable change, zero new allocations, zero retained. The client
recomputeaddition is one reserved-key read + one JSON.stringify of a tiny chain per pass, only on compute roots.Verification
bundle exec rspec spec/phlex spec/requests— 1429 examples, greenbun test spec/javascript— 571 tests, greenbundle exec rake spec:system_servers— 113 examples green under Puma AND Falconbundle exec rubocop— 298 files, clean;cd docs && bundle exec rubocop <edited pages>— cleanbundle exec rake build:js_check— committed min builds match fresh builds; vendored copies byte-identical (sync spec)rake benchbefore/after captured (table above)Deviations & judgment calls
$opslatch is keyed on chain CONTENT, not presence (the plan comment said "presence"). Prompted by the mid-flight question about multi-box OTP: a presence latch swallows per-keystroke focus advance (present → present never re-fires even when the focus target changed). Content-keyed is strictly more expressive with identical safety — an unchanged chain is settled (capped 7th digit, post-replace seed), a changed chain is a new intent. All presence-semantics tests pass unchanged.SplitCodeComponent(six boxes) added beyond the plan's single-input flagship — to prove the multi-input story end to end (paste redistribution, focus advance, hidden joined output).rake benchwas broken on main:benchmark/micro/broadcast.rbstill calledbroadcast_replace_to, removed in API simplification, part 2.5: one broadcast_to — verbs as kwargs, components as payloads #185 — every bench run exited 1. Fixed as a drive-by (migrated tobroadcast_to(replace:)/each:); the transport double is unchanged.if: { a: { weird: 1 } }silently compiled to an equals-on-stringified-hash term; adding thelength:Hash form makes any other Hash key raise at render. Strictly tighter — a nonsense binding now fails loudly instead of never matching.form(**mix(reactive_root(compute:), on(:verify, event: "submit")))) so the default@rootsubmit target resolves directly; the six-box and filter demos exercise theclosest("form")and selector-target paths.Closes #226
Summary by CodeRabbit
js.submit(...)for native/Turbo-compatible form submission, plus safeguards against recursive submit triggering.reactive_on_completecompletion bindings with edge-triggered, non-self-firing behavior.$opschains for conditional auto-commit/dispatch ordering.reactive_showwith codepoint-based length predicates (exact and ranges).rake benchexit code and updated micro-benchmarks to the current broadcast API.submit,$ops, andreactive_on_complete, plus length predicate vectors.