Skip to content

fix(core): add clipboard fallback support - #219

Merged
childrentime merged 2 commits into
childrentime:mainfrom
tanukihee:fix/use-clipboard-fallback-218
Aug 17, 2026
Merged

fix(core): add clipboard fallback support#219
childrentime merged 2 commits into
childrentime:mainfrom
tanukihee:fix/use-clipboard-fallback-218

Conversation

@tanukihee

@tanukihee tanukihee commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #218.

useClipboard now exposes the Clipboard API support status and remains usable when navigator.clipboard is unavailable. Copying falls back to document.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 useCopyToClipboard alias shares this implementation. Its maintained documentation examples now reflect the three-item tuple and fallback behavior.

Type of Change

  • Bug fix
  • Enhancement to existing hook
  • Documentation update

Checklist

  • I have read the Contributing Guide
  • My code follows the project's coding style
  • I have added tests for my changes
  • All existing tests pass (the unrelated @reactuses/ts-document suite fails while loading its existing Jest 27 environment)
  • I have updated the documentation

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 typecheck
  • pnpm --filter @reactuses/core build
  • pnpm --filter website-astro build
  • pnpm --filter @reactuses/ts-document exec jest --passWithNoTests fails before executing tests: TypeError: Cannot read properties of undefined (reading 'testEnvironmentOptions') from its Jest 27 Node environment.

@childrentime childrentime left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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 attached

That 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-in legacy: false; since this PR enables the fallback unconditionally, saving/restoring the getSelection() ranges and the previous activeElement in the finally is worth doing.
  • Don't swallow the Clipboard API error. The empty catch {} around writeText drops the real reason (usually NotAllowedError); if execCommand then also fails, the caller only sees a generic Failed to copy text to clipboard. A console.warn of 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.

@tanukihee
tanukihee force-pushed the fix/use-clipboard-fallback-218 branch from c33d2b4 to f044445 Compare August 17, 2026 11:53

@childrentime childrentime left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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.

@childrentime
childrentime merged commit 203210e into childrentime:main Aug 17, 2026
3 checks passed
@tanukihee
tanukihee deleted the fix/use-clipboard-fallback-218 branch August 17, 2026 12:16
childrentime added a commit that referenced this pull request Aug 17, 2026
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>
childrentime added a commit that referenced this pull request Aug 17, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] useClipboard / Clipboard API 无法在浏览器的非安全上下文下使用

2 participants