fix(core): add clipboard fallback support - #219
Conversation
childrentime
left a comment
There was a problem hiding this comment.
Thanks for picking this up, and for the unusually thorough PR description — the direction is right and the change is well scoped. I pulled the branch and confirmed your numbers locally: pnpm lint clean, tsc --noEmit clean, full core suite green (63 suites / 336 tests).
Two things to fix before merge, and one thing you can simply delete.
1. The fallback loses text (blocking)
document.execCommand('copy') dispatches a bubbling copy event, which lands on this hook's own copy/cut listener. The handler then reads the selection one microtask too late:
const updateTextFromEvent = useCallback(async () => {
if (!await updateText()) // updateText() returns false synchronously here,
updateSelectedText() // but `await` still defers this to a microtask
}, [updateSelectedText, updateText])By then copyWithExecCommand's finally has already removed the textarea and the selection has collapsed. I probed both moments:
| observed at | document.getSelection().toString() |
|---|---|
synchronously, during the copy event |
"copied text" |
in a microtask (where the await lands) |
"" |
So updateSelectedText() always writes an empty string — and it wins, because it runs last.
I verified end-to-end with Playwright (this branch's real hook, real React 19, delete Navigator.prototype.clipboard to simulate a non-secure context, real click for user activation). There's a second, independent case too — a genuine Ctrl+X, where Firefox clears the selection as part of the cut before the microtask runs:
| scenario (no Clipboard API) | PR as-is | with the fix below | VueUse 14.4.0 |
|---|---|---|---|
copy() → execCommand fallback |
✗ chromium, ✗ firefox, ✗ webkit | ✓ ✓ ✓ | ✓ ✓ ✓ |
user presses Ctrl+X |
✗ firefox | ✓ ✓ ✓ | ✓ ✓ ✓ |
user presses Ctrl+C |
✓ ✓ ✓ | ✓ ✓ ✓ | ✓ ✓ ✓ |
In the first row the clipboard itself is correct — I pasted it back with a real Ctrl+V and got "copied text" — only the returned text is empty. That's exactly the non-secure-context path this PR exists to fix, so it'd ship broken.
Worth noting VueUse runs the same legacyCopy → synthetic-copy-event sequence and is fine, because its updateText() has no await in front of the legacy branch:
if (useLegacy) text.value = legacyRead() // synchronous — textarea still attachedThat single await is the whole difference.
Suggested fix — capture the selection synchronously, use it in the async continuation:
const updateTextFromEvent = useCallback(() => {
// Must read synchronously: by the time an awaited continuation runs, a programmatic
// execCommand copy has torn its textarea down, and Firefox has already cleared the
// selection on `cut`.
const selected = defaultDocument?.getSelection?.()?.toString() ?? ''
void updateText().then((ok) => {
if (!ok && selected)
setText(selected)
})
}, [updateText])updateSelectedText then goes away. I verified this in all three engines against all three scenarios above. (I also tried an isCopyingRef guard that ignores self-fired events — it fixes the first row but leaves the Firefox cut case broken, so the synchronous read is the better call.)
One thing to flag about the tests: the existing 7 specs pass identically against the broken and the fixed version, so they can't catch this. The execCommand mock is a bare jest.fn(() => true) that neither dispatches a copy event nor models the textarea lifecycle. Could you make it dispatch new Event('copy', { bubbles: true }) so the regression is actually covered?
2. pnpm gend wasn't run — the site's API table is stale (blocking)
packages/website-docusaurus/api/useClipboard-README{,-zhHans,-zhHant}.md are generated from interface.ts, and despite the path they're what the Astro site injects at %%API%% (website-astro/src/plugins/remark-api-inject.mjs, wired into astro.config.mjs). Right now the prose says [text, copy, isSupported] while the API table directly below still renders:
`readonly [string, (txt: string) => Promise<void>]`: Returns a readonly tuple.
pnpm --filter @reactuses/core gend fixes it — I ran it and it touches exactly those 3 files, no other drift.
3. The docusaurus docs don't need touching — please revert them
Heads-up that we should have documented better: the docusaurus site is deprecated, so these 6 files are wasted effort and can be dropped from the PR:
packages/website-docusaurus/docs/browser/useClipboard.mdx
packages/website-docusaurus/docs/browser/useCopyToClipboard.mdx
packages/website-docusaurus/i18n/**/{useClipboard,useCopyToClipboard}.mdx
packages/website-astro/** is the only site that ships. Sorry you spent time on those — and note this does not include website-docusaurus/api/ from point 2, which is still live despite sitting in the same package. That layout is genuinely confusing and it's on us to move it.
Non-blocking
- Restore the selection and focus after the fallback.
textarea.select()wipes whatever the user had selected and drops focus to<body>— I confirmed both. VueUse pays the same cost but keeps it behind an opt-inlegacy: false; since this PR enables the fallback unconditionally, saving/restoring thegetSelection()ranges and the previousactiveElementin thefinallyis worth doing. - Don't swallow the Clipboard API error. The empty
catch {}aroundwriteTextdrops the real reason (usuallyNotAllowedError); ifexecCommandthen also fails, the caller only sees a genericFailed to copy text to clipboard. Aconsole.warnof the original would help. - Minor:
setText(txt)fires before the copy is known to have succeeded. Pre-existing, not a regression — just easy to fold in.
Nit
The @en / @zh / @zh-Hant JSDoc on the tuple's third element doesn't render anywhere — ts-document doesn't read tuple-member comments (I checked the generated output). Harmless, but dead weight.
Sorry for the long review — the fallback is the trickiest part of this hook and the await timing isn't something you'd catch by reading. Fix 1 + 2, drop 3, and I'm happy to merge. Thanks again for tracking down the original issue and following through with a patch.
c33d2b4 to
f044445
Compare
childrentime
left a comment
There was a problem hiding this comment.
LGTM — thanks for the quick and careful turnaround.
I re-ran the browser verification against f044445 (real hook, real React, three engines, Clipboard API removed). All nine cases now pass and match VueUse exactly:
| scenario (no Clipboard API) | before | after |
|---|---|---|
copy() → execCommand fallback |
✗ chromium, ✗ firefox, ✗ webkit | ✓ ✓ ✓ |
user Ctrl+X |
✗ firefox | ✓ ✓ ✓ |
user Ctrl+C |
✓ ✓ ✓ | ✓ ✓ ✓ |
I also checked that the new spec actually has teeth: run it against the old implementation and 4 of the 8 tests fail. That's a real regression test now — nice work on the execCommand mock.
The regenerated api/*.md match pnpm gend byte for byte, and dropping the docusaurus mdx files is exactly right.
One thing that's on me: the snippet I handed you used .then((ok) => {, but this repo's eslint config wants .then(ok => { (style/arrow-parens), so pnpm run lint fails on line 70. My mistake for not linting the suggestion before posting it — don't worry about pushing another commit, I'll fix it on main right after merging.
Merging now. Thanks for finding the original issue and seeing the fix all the way through.
The fix merged in #219 used `.then((ok) => …)`, which trips the repo's `style/arrow-parens` rule and fails `pnpm run lint` on main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
reactuse.com has been served from packages/website-astro for a while; the docusaurus package was dead weight, and PR #219 wasted effort updating its mdx files. Its 10 blog posts all exist in the astro blog already. The one live piece inside it was `api/` — generated from each hook's `interface.ts` by `pnpm --filter @reactuses/core gend`, and read by the astro `%%API%%` remark plugin plus `packages/mcp`. That moves to `packages/website-astro/api/`, next to the site that renders it, and the three consumers are repointed. Verified: gend regenerates in place with no drift, `eslint .` clean, core typecheck + 63 suites / 337 tests, core build, mcp build + 25 tests, and the astro build emits 546 pages with API sections still injected on 331 of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Description
Fixes #218.
useClipboardnow exposes the Clipboard API support status and remains usable whennavigator.clipboardis unavailable. Copying falls back todocument.execCommand("copy")with a temporary textarea, and copy/cut events fall back to the current document selection when Clipboard API reads are unavailable or rejected.The related
useCopyToClipboardalias shares this implementation. Its maintained documentation examples now reflect the three-item tuple and fallback behavior.Type of Change
Checklist
@reactuses/ts-documentsuite fails while loading its existing Jest 27 environment)Verification
pnpm --filter @reactuses/core test --runInBand(63 suites, 336 tests passed)pnpm --filter @reactuses/mcp build && pnpm --filter @reactuses/mcp test(2 files, 25 tests passed)pnpm --filter @reactuses/core typecheckpnpm --filter @reactuses/core buildpnpm --filter website-astro buildpnpm --filter @reactuses/ts-document exec jest --passWithNoTestsfails before executing tests:TypeError: Cannot read properties of undefined (reading 'testEnvironmentOptions')from its Jest 27 Node environment.