feat(client+component): clipboard-source paste_into op — read the clipboard into a bound field (#228) - #229
Conversation
…pboard into a bound field (#228) ## Summary A field whose real input is visually hidden (OTP cell UIs) has no mouse-reachable paste path. js.paste_into(selector) adds the explicit affordance: on a user gesture, navigator.clipboard.readText() feeds the field through the NORMAL input pipeline (set .value, bubbling input, focus) — reducers/show/on_complete run exactly as if typed. A denied read, empty text, or missing API is a silent no-op; on_client marks the trigger data-reactive-clipboard and the controller sets hidden = !available on connect + morph, so a dead button never shows. Actor-only: paste_into joins BROADCAST_REFUSED_OPS. Also moves the ops serializer to the module singleton so the MODULE-LEVEL broadcast door reaches the same refusal (it crashed NoMethodError before, unpinned). ## Test Coverage - js_spec: wire shape, global:, loud :root/field-only refusal - component_spec: on_client marker emission (string "true", chain-buried, byte-stable non-paste wire) - js_broadcast_spec: chain + raw-array refusal, BOTH broadcast doors (class + module level) - response_spec: reply.js allows paste_into (actor-scoped) - reactive_paste_op.test.js (bun): ordered value→input→focus contract, empty/rejected/missing no-ops, chain composition, connect/morph gate, ownership, teardown - paste_into_spec.rb (Playwright): reveal-on-connect + post-replace re-reveal, dirty-paste auto-commit (1 POST), partial-paste focus, denied-read no-op, API-missing hide ## Verification - [x] bundle exec rubocop (gem + docs app) passes - [x] bundle exec rspec spec/phlex spec/requests — 1442 passed - [x] bun test spec/javascript — 584 passed - [x] rake spec:system_servers — 118 examples × puma AND falcon, 0 failures - [x] rake build:js + vendored copies re-synced (byte-identity guards green)
|
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 ignored due to path filters (2)
📒 Files selected for processing (5)
🚧 Files skipped from review as they are similar to previous changes (4)
📝 WalkthroughWalkthroughAdds ChangesClipboard paste trigger
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant ReactiveController
participant Clipboard
participant OTPField
participant ReactivePipeline
User->>ReactiveController: click Paste code
ReactiveController->>Clipboard: readText()
Clipboard-->>ReactiveController: clipboard text
ReactiveController->>OTPField: set value and dispatch input
OTPField->>ReactivePipeline: normalize and evaluate completion
ReactiveController->>OTPField: focus field
ReactivePipeline-->>User: auto-submit when complete
Possibly related PRs
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 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 `@app/javascript/phlex/reactive/reactive_controller.js`:
- Around line 1788-1804: Update the connection logic around
`#clipboardGateEnabled` and `#syncClipboardTriggers` so the turbo:morph-element
listener is installed whenever the controller may receive paste triggers, even
when no trigger exists during connect; keep the initial clipboard support probe
and synchronization behavior intact. Ensure newly introduced authored-hidden
triggers are synchronized after later morphs, and add a regression test covering
the first trigger being introduced through a morph.
In `@docs/app/views/docs/pages/example_client_ops.rb`:
- Around line 110-118: Update the `paste_into(to)` documentation to describe
`navigator.clipboard.readText()` as fire-and-forget: the click handler returns
immediately, while the value assignment, bubbling `input` dispatch, and focus
happen asynchronously when the read resolves. Remove wording that implies the
click awaits the clipboard read or blocks chained sibling operations.
- Around line 51-56: Update the documentation paragraph around the `focus`,
`text`, `dispatch`, `submit`, and `paste_into` descriptions so `submit` is
explicitly separated from the claim that these operations perform only local DOM
mutations. State that `submit` may enter the native/Turbo or intercepted
action-submit path and issue navigation or a POST, while `paste_into` remains
the clipboard-reading exception among the local operations.
In `@spec/dummy/public/vendor/reactive_controller.js`:
- Line 1: Update the clipboard-trigger gating in the reactive controller,
specifically `#jZ` and `#VX`, to include this.element when it itself matches
[data-reactive-clipboard] while preserving descendant filtering through `#Z`.
Ensure the root trigger’s hidden state is synchronized when Clipboard API
support is unavailable, add a regression spec covering a clipboard marker on the
reactive root, and rebuild the bundled vendor output.
In `@spec/javascript/reactive_paste_op.test.js`:
- Around line 33-39: Update the test setup around REAL_NAVIGATOR and its
afterEach cleanup to snapshot the original fetch, document, and window globals
alongside navigator, then restore all four globals after every test. Ensure the
cleanup covers the stubs installed by the affected test sections and preserves
the existing navigator restoration behavior.
🪄 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: bec63cfe-d824-4712-ae79-906e97275826
⛔ Files ignored due to path filters (3)
app/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 (22)
CHANGELOG.mdREADME.mdapp/javascript/phlex/reactive/compute.jsapp/javascript/phlex/reactive/reactive_controller.jsdocs/app/views/docs/pages/actions_events.rbdocs/app/views/docs/pages/broadcasting.rbdocs/app/views/docs/pages/example_client_ops.rbdocs/app/views/docs/pages/example_notifications.rbdocs/app/views/docs/pages/example_payment_split.rbdocs/app/views/docs/pages/examples_overview.rbdocs/app/views/docs/pages/security.rblib/phlex/reactive/component/helpers.rblib/phlex/reactive/js.rblib/phlex/reactive/streamable.rbspec/dummy/app/components/verification_code_component.rbspec/dummy/public/vendor/reactive_controller.jsspec/javascript/reactive_paste_op.test.jsspec/phlex/reactive/component_spec.rbspec/phlex/reactive/js_spec.rbspec/phlex/reactive/response_spec.rbspec/requests/js_broadcast_spec.rbspec/system/paste_into_spec.rb
- Gate a clipboard marker on the ROOT itself (a button-only component mixing on_client(paste_into) onto reactive_root) — the #dirtyTrackingEnabled root-then-descendants precedent; two bun regression tests (reveal + hide) - Restore fetch/document/window globals (not just navigator) after every paste-op test — bun runs the suite in one process - Docs: name submit and paste_into as the TWO deliberate exceptions to the local-only claim; describe the clipboard read as fire-and-forget (chained siblings never wait), not awaited — also in the CHANGELOG
Summary
A reactive field sometimes hides its real
<input>(an OTP cell UI painted by areactive_computereducer) — so right-click → Paste can never reach the editable input, and mouse-first users have no paste path at all. This PR adds the declarative affordance the issue asks for:The op — the one async, value-reading member of the vocabulary: on the user's gesture it awaits
navigator.clipboard.readText()(the permission UX is the browser's own) and replicates exactly what a native Cmd/Ctrl+V does to a focused field — set.value, dispatch a bubblinginputevent (compute reducers,reactive_show,reactive_on_completeall run as if typed), then focus the field so a partial paste continues from the caret. A denied/dismissed read, empty text, or a missing API is a silent no-op. Fire-and-forget:applyOpsstays sync, chained siblings never wait.Availability gate —
on_clientauto-marks a paste trigger withdata-reactive-clipboard; on connect (and everyturbo:morph-element) the controller setshidden = !availableon owned markers. Author the triggerhiddenand it is revealed only where the Async Clipboard API exists — a dead button never shows in insecure contexts/webviews.Actor-only, default-deny —
paste_intojoinsfocus/focus_first/submitinBROADCAST_REFUSED_OPS:broadcast_to(js:)raises (a broadcast that reads every subscriber's clipboard would be hostile);reply.jsand gesture paths stay allowed.Hardening beyond the issue (pre-existing, surfaced by the channel audit): the module-level
Phlex::Reactive.broadcast_to(js:)door crashed withNoMethodError— the ops serializer was a private method on the includer class, so the actor-only refusal was unreachable there and unpinned by specs. The serializer now lives on the module singleton next tobroadcast_component(ONE enforcement point both broadcast doors funnel through), pinned by request specs for both the allowed and refused cases.Cmd/Ctrl+V, for the record: keyboard paste already worked — the whole pipeline hangs off delegated
input/changelisteners on the root, and a native paste fires a realinputevent. Nothing inspectsinputType/isTrusted.paste_intoexists purely for the mouse path.Closes #228
Test plan
global:, loud:root/field-only refusal;on_clientmarker emission (string"true", chain-buried, byte-stable non-paste wire);reply.jsallows (actor-scoped)broadcast_to); module-level allowed-op emission pinnedvalue → input → focuscontract (value visible at dispatch time), empty/rejected/missing-API silent no-ops, chain composition (sync siblings don't wait), connect reveal/hide, morph re-sync, nested-root ownership, no-marker zero-cost, disconnect teardown"987-654") through the otp reducer → exactly 1 signed POST, no navigation; partial-paste focus + continue-typing; denied-read silent no-op (field untouched, 0 POSTs); API-missing hide via init scriptbundle exec rubocop(gem, 299 files) + docs-app rubocop — cleanbundle exec rspec spec/phlex spec/requests— 1442 passedbun test spec/javascript— 584 passedbundle exec rake spec:system_servers— 118 examples × Puma AND Falcon, 0 failuresrake build:js+ both vendored copies re-synced (byte-identity guards green)Deviations & judgment calls
hiddenon marker elements (el.hidden = !available), so an author-hidden trigger is revealed when the API exists (the issue's "a dead button never shows") AND an author-visible trigger is hidden in clipboard-less contexts. The marker (data-reactive-clipboard="true") is auto-emitted byon_clientwhen the chain containspaste_into— no new keyword. Documented consequence: don't also bindreactive_showto the trigger element (the two passes would fight overhidden).readText()resolving to""does NOT clear the field ("paste nothing" must not destroy a partially-typed code). The issue specified only the rejection case; chose conservative for the unspecified one.paste_into. A non-gesture binding degrades to the browser's own rejection → silent no-op; no render-time event whitelist (click/keydown/pointerup are all gestures). But an explicitpaste_into(:root)IS refused loudly — pasting into the root div is always a call-site bug (review finding).opsbuilder does NOT gainpaste_into. A reducer runs on every input event; a clipboard read per keystroke (each changed$opschain fires) would spam permission prompts.$opscan still carry a raw[["paste_into", …]]pair (shared whitelist). The "mirrors the Ruby js verbs" claims (README, payment-split page, compute.js header) now name this deliberate omission — they silently overclaimed before.reactive:jsinterpreter cannot distinguish an actor reply's stream from a broadcast's, andreply.jslegitimately carriespaste_into— a client filter would break the allowed path. Server-side build-time refusal stays the single gate, same posture as focus/submit.isConnectedguard on the async write. If a replace lands while the permission prompt is open, the captured field is detached and the paste is lost either way; the guard would only skip dead work at the cost of a subtle fake-node test requirement.clipboard-readis a persistent per-origin permission: once granted (the legit paste button itself induces that),readText()succeeds with no gesture. Only Safari (per-call activation) and Firefox (paste picker) gate each read. Comments and docs now say the server-side refusal is the real gate.broadcast_to(js:)crashed instead of refusing (pre-existing, failed closed viaNoMethodError). Moved the serializer to the module singleton so BOTH broadcast doors reach the one refusal; pinned with request specs.jsrow, the broadcast-refusal paragraph, broadcasting.rb's verb table + callout, example_notifications.rb — all missingsubmit). Fixed while addingpaste_into.Summary by CodeRabbit
paste_intoclient-side action to paste clipboard text into hidden or specialized input fields.input), focuses the target, and supports partial paste continuation.paste_into, its gating behavior, and broadcast/actor-only rules.