Skip to content

feat(client+component): clipboard-source paste_into op — read the clipboard into a bound field (#228) - #229

Merged
mhenrixon merged 2 commits into
mainfrom
issue-228-clipboard-paste-trigger
Jul 16, 2026
Merged

feat(client+component): clipboard-source paste_into op — read the clipboard into a bound field (#228)#229
mhenrixon merged 2 commits into
mainfrom
issue-228-clipboard-paste-trigger

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

A reactive field sometimes hides its real <input> (an OTP cell UI painted by a reactive_compute reducer) — 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:

button(hidden: true, **on_client(:click, js.paste_into("[name=code]"))) { "Paste code" }

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 bubbling input event (compute reducers, reactive_show, reactive_on_complete all 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: applyOps stays sync, chained siblings never wait.

Availability gateon_client auto-marks a paste trigger with data-reactive-clipboard; on connect (and every turbo:morph-element) the controller sets hidden = !available on owned markers. Author the trigger hidden and it is revealed only where the Async Clipboard API exists — a dead button never shows in insecure contexts/webviews.

Actor-only, default-denypaste_into joins focus/focus_first/submit in BROADCAST_REFUSED_OPS: broadcast_to(js:) raises (a broadcast that reads every subscriber's clipboard would be hostile); reply.js and 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 with NoMethodError — 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 to broadcast_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/change listeners on the root, and a native paste fires a real input event. Nothing inspects inputType/isTrusted. paste_into exists purely for the mouse path.

Closes #228

Test plan

Layer Coverage
Unit (Ruby) wire shape, global:, loud :root/field-only refusal; on_client marker emission (string "true", chain-buried, byte-stable non-paste wire); reply.js allows (actor-scoped)
Request broadcast refusal: JS chain + raw-array escape hatch, both doors (class-level and module-level broadcast_to); module-level allowed-op emission pinned
JS (bun) ordered value → input → focus contract (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
System (Playwright, real browser) reveal-on-connect and post-replace re-reveal, dirty-paste ("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 script
  • bundle exec rubocop (gem, 299 files) + docs-app rubocop — clean
  • bundle exec rspec spec/phlex spec/requests — 1442 passed
  • bun test spec/javascript — 584 passed
  • bundle exec rake spec:system_servers — 118 examples × Puma AND Falcon, 0 failures
  • rake build:js + both vendored copies re-synced (byte-identity guards green)

Deviations & judgment calls

  • Availability gate is bidirectional, marker-driven. The issue's parenthetical suggests "reveal a hidden trigger on connect when available". Implemented as: the gate OWNS hidden on 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 by on_client when the chain contains paste_into — no new keyword. Documented consequence: don't also bind reactive_show to the trigger element (the two passes would fight over hidden).
  • Empty clipboard text is a no-op. 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.
  • No event restriction on 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 explicit paste_into(:root) IS refused loudly — pasting into the root div is always a call-site bug (review finding).
  • compute.js ops builder does NOT gain paste_into. A reducer runs on every input event; a clipboard read per keystroke (each changed $ops chain fires) would spam permission prompts. $ops can 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.
  • DECLINED a client-side broadcast refusal. The reactive:js interpreter cannot distinguish an actor reply's stream from a broadcast's, and reply.js legitimately carries paste_into — a client filter would break the allowed path. Server-side build-time refusal stays the single gate, same posture as focus/submit.
  • DECLINED an always-on morph listener for late-introduced triggers. A trigger first INTRODUCED by an in-place morph stays hidden until a full replace re-connects — the same connect-decided posture as every sibling gate (show/filter/tags); documented in the connect() comment ("render the paste trigger unconditionally"). An always-on listener would cost every component a per-morph query.
  • DECLINED an isConnected guard 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.
  • Correction from adversarial review — the browser gesture gate is NOT universal defense-in-depth. Chromium's clipboard-read is 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.
  • Fix beyond the issue — module-level broadcast_to(js:) crashed instead of refusing (pre-existing, failed closed via NoMethodError). Moved the serializer to the module singleton so BOTH broadcast doors reach the one refusal; pinned with request specs.
  • Discovery — several vocabulary mirrors were already stale from reactive_compute: let a reducer emit an effect (dispatch/submit) when a computed condition is met #226 (README quick-ref js row, the broadcast-refusal paragraph, broadcasting.rb's verb table + callout, example_notifications.rb — all missing submit). Fixed while adding paste_into.

Summary by CodeRabbit

  • New Features
    • Added paste_into client-side action to paste clipboard text into hidden or specialized input fields.
    • Paste flows through the normal input pipeline (reactive updates, validation/normalization, bubbling input), focuses the target, and supports partial paste continuation.
    • Paste triggers only appear when browser clipboard support is available; denied/empty reads fail silently.
  • Security
    • Clipboard pasting is actor-only; broadcasts/referrals cannot initiate clipboard reads.
  • Documentation
    • Updated API and examples to describe paste_into, its gating behavior, and broadcast/actor-only rules.
  • Tests
    • Added coverage for async clipboard behavior, availability gating, and end-to-end system flows.

…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)
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 38ed933d-2aef-4fbc-8e58-9617dbcb5eaf

📥 Commits

Reviewing files that changed from the base of the PR and between 9cfd4d9 and a7e59b4.

⛔ Files ignored due to path filters (2)
  • app/javascript/phlex/reactive/reactive_controller.min.js is excluded by !**/*.min.js
  • app/javascript/phlex/reactive/reactive_controller.min.js.map is excluded by !**/*.map, !**/*.min.js.map
📒 Files selected for processing (5)
  • CHANGELOG.md
  • app/javascript/phlex/reactive/reactive_controller.js
  • docs/app/views/docs/pages/example_client_ops.rb
  • spec/dummy/public/vendor/reactive_controller.js
  • spec/javascript/reactive_paste_op.test.js
🚧 Files skipped from review as they are similar to previous changes (4)
  • docs/app/views/docs/pages/example_client_ops.rb
  • CHANGELOG.md
  • app/javascript/phlex/reactive/reactive_controller.js
  • spec/javascript/reactive_paste_op.test.js

📝 Walkthrough

Walkthrough

Adds js.paste_into(selector) for clipboard-to-field input, including target validation, actor-only broadcast enforcement, clipboard availability gating, normal input-pipeline dispatch, focus continuation, documentation, verification-code integration, and unit, request, component, and system coverage.

Changes

Clipboard paste trigger

Layer / File(s) Summary
Operation contract and broadcast enforcement
lib/phlex/reactive/js.rb, lib/phlex/reactive/streamable.rb, spec/phlex/reactive/js_spec.rb, spec/phlex/reactive/response_spec.rb, spec/requests/js_broadcast_spec.rb
Adds paste_into serialization and validation, permits actor replies, and rejects broadcast usage.
Client interpretation and availability gate
app/javascript/phlex/reactive/reactive_controller.js, app/javascript/phlex/reactive/compute.js, spec/javascript/reactive_paste_op.test.js
Reads clipboard text asynchronously, dispatches bubbling input, focuses the target, handles failures as no-ops, and gates trigger visibility by Clipboard API availability.
Trigger wiring and end-to-end behavior
lib/phlex/reactive/component/helpers.rb, spec/dummy/app/components/verification_code_component.rb, spec/system/paste_into_spec.rb, spec/phlex/reactive/component_spec.rb, spec/dummy/public/vendor/reactive_controller.js
Marks paste triggers, adds the verification-code affordance, and tests normalization, submission, partial paste, denied reads, and unavailable APIs.
Public documentation and release notes
README.md, docs/app/views/docs/pages/*, CHANGELOG.md
Documents paste behavior, reducer-builder exclusion, actor-only restrictions, availability gating, security rules, and examples.

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
Loading

Possibly related PRs

Suggested labels: enhancement

Poem

A bunny taps “Paste” with care,
Clipboard carrots hop through air.
The input blooms, reducers run,
A tiny code submits—done!
If denied, I nibble on.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the new clipboard-source paste_into op and its bound-field behavior.
Linked Issues check ✅ Passed The changes implement clipboard paste, input dispatch, focus, silent no-ops, hidden-trigger gating, broadcast refusal, and selector scoping for #228.
Out of Scope Changes check ✅ Passed The docs, tests, and bundle regeneration all support the paste_into feature and its shared broadcast behavior.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added documentation Improvements or additions to documentation enhancement New feature or request labels Jul 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 8cac406 and 9cfd4d9.

⛔ Files ignored due to path filters (3)
  • app/javascript/phlex/reactive/compute.min.js.map is excluded by !**/*.map, !**/*.min.js.map
  • app/javascript/phlex/reactive/reactive_controller.min.js is excluded by !**/*.min.js
  • app/javascript/phlex/reactive/reactive_controller.min.js.map is excluded by !**/*.map, !**/*.min.js.map
📒 Files selected for processing (22)
  • CHANGELOG.md
  • README.md
  • app/javascript/phlex/reactive/compute.js
  • app/javascript/phlex/reactive/reactive_controller.js
  • docs/app/views/docs/pages/actions_events.rb
  • docs/app/views/docs/pages/broadcasting.rb
  • docs/app/views/docs/pages/example_client_ops.rb
  • docs/app/views/docs/pages/example_notifications.rb
  • docs/app/views/docs/pages/example_payment_split.rb
  • docs/app/views/docs/pages/examples_overview.rb
  • docs/app/views/docs/pages/security.rb
  • lib/phlex/reactive/component/helpers.rb
  • lib/phlex/reactive/js.rb
  • lib/phlex/reactive/streamable.rb
  • spec/dummy/app/components/verification_code_component.rb
  • spec/dummy/public/vendor/reactive_controller.js
  • spec/javascript/reactive_paste_op.test.js
  • spec/phlex/reactive/component_spec.rb
  • spec/phlex/reactive/js_spec.rb
  • spec/phlex/reactive/response_spec.rb
  • spec/requests/js_broadcast_spec.rb
  • spec/system/paste_into_spec.rb

Comment thread app/javascript/phlex/reactive/reactive_controller.js
Comment thread docs/app/views/docs/pages/example_client_ops.rb Outdated
Comment thread docs/app/views/docs/pages/example_client_ops.rb
Comment thread spec/dummy/public/vendor/reactive_controller.js Outdated
Comment thread spec/javascript/reactive_paste_op.test.js Outdated
@mhenrixon mhenrixon self-assigned this Jul 16, 2026
- 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
@coderabbitai coderabbitai Bot removed the documentation Improvements or additions to documentation label Jul 16, 2026
@mhenrixon
mhenrixon merged commit 45942d7 into main Jul 16, 2026
11 checks passed
@mhenrixon
mhenrixon deleted the issue-228-clipboard-paste-trigger branch July 16, 2026 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClientBindings: clipboard-source trigger — read the clipboard into a bound field on click

1 participant