Skip to content

fix(portal): show when Copy Markdown fails - #419

Merged
mehnoorsiddiqui merged 15 commits into
devfrom
fix/portal-copy-markdown-failure
Oct 6, 2026
Merged

mehnoorsiddiqui merged 15 commits into
devfrom
fix/portal-copy-markdown-failure

Conversation

@mehnoorsiddiqui

@mehnoorsiddiqui mehnoorsiddiqui commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #413 (review comment on Copy Markdown failures).

The bug

Since fumadocs-ui 16.15.16, MarkdownCopyButton throws when the page's .md request fails, which keeps an error page off the clipboard. But its useCopyButton hook has no rejection path, so a failed copy is an unhandled rejection: no check mark, no error, and the clipboard keeps what it had. A denied clipboard write fails the same way.

Upstream closed fuma-nama/fumadocs#3634 as not planned, and suggested owning the button instead.

The change

The content route now renders the portal's own MarkdownCopyButton (src/components/markdown-copy-button.tsx), with the same look as fumadocs':

fumadocs-ui portal
Failed fetch or clipboard write nothing shown, unhandled rejection Copy failed with an ✕, back to Copy Markdown after 1.5 s for a retry; the cause is logged to the console
Success Copied Markdown for 1.5 s same
Screen readers aria-live on the button the result goes to a visually hidden <output>, since a focused button's own text change isn't reliably announced
Labels useTranslations, note page actions written in English, like the portal's other components, since the portal supplies no translations
Base path withBasePath reads Vite's BASE_URL the route passes Vite's BASE_URL in as a prop, so the button doesn't read it itself
Successful response cached until a full reload not cached: every click fetches, so portal serve copies the Markdown as it is after an edit
Moving to another page n/a (the check mark clears after 1.5 s) the button is keyed by its Markdown URL, so it starts fresh: no Copy failed from the last page, and a copy still running there doesn't show Copied Markdown here

The fetch and the clipboard write live in src/lib/copy-markdown.ts. The fetch is handed to ClipboardItem unresolved, as fumadocs does, so Safari still treats the write as part of the click.

ViewOptionsPopover still comes from fumadocs-ui.

Tests

  • New test/portal-template/copy-markdown.test.ts checks copyMarkdown with fetch, ClipboardItem and navigator.clipboard stubbed:
    • it fetches under the base path (/docs/ and /) and writes the Markdown;
    • on a 404, or a request that gets no response, it rejects and leaves the clipboard unchanged;
    • when the clipboard refuses the write, it rejects with the browser's error;
    • when the clipboard refuses before a failed fetch settles, it rejects with the refusal and leaves no rejection unhandled;
  • The button's states aren't covered by an automated test, because the suite has no DOM. Check them by hand with the steps below.

How to test

  1. Run pnpm install and pnpm build, then node bin/run.js portal serve --input <copy of test/resources/portal-inputs/default>.
  2. Open a content page, such as /guides/intro, click Copy Markdown and paste. You should get the page's Markdown, and the button should show Copied Markdown.
  3. In DevTools → Console, make .md requests return 404:
    const realFetch = window.fetch;
    window.fetch = (url, init) =>
      String(url).endsWith('.md')
        ? Promise.resolve(new Response('<html>Not Found</html>', { status: 404 }))
        : realFetch(url, init);
  4. On a page you haven't copied yet, click Copy Markdown. Expected: Copy failed, the console logs Failed to fetch …: 404, the clipboard is unchanged, and 1.5 s later the button reads Copy Markdown again.
  5. Run window.fetch = realFetch and click again on the same page. Expected: Copied Markdown, and pasting gives that page's Markdown.
  6. Run the step 3 snippet again, click Copy Markdown on another page you haven't copied, and pick a different page from the sidebar before the 1.5 s pass. Expected: the new page's button reads Copy Markdown, not Copy failed.

Checked

  • pnpm test (tsc, mocha, ESLint): 1570 passing, 46 pending.
  • APIMATIC_E2E=1 portal build suite: 46 passing, including "type-checks against the packages it is built with".
  • SonarCloud's one issue on this PR (S6819, prefer <output> to role="status") is addressed.

🤖 Generated with Claude Code

fumadocs-ui's MarkdownCopyButton rejects on an error response, but its
useCopyButton has no rejection path, so a failed copy shows nothing.
Upstream won't change it (fuma-nama/fumadocs#3634), so the portal owns
the button: a failed fetch or clipboard write shows "Failed. Try Again."
and leaves the button enabled for the retry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
mehnoorsiddiqui and others added 8 commits October 5, 2026 14:45
ESLint's no-undef rejected the bare globals, failing posttest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A focused button's own text change isn't reliably announced, so the
result goes to a visually hidden status region instead of aria-live on
the button.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Matches fumadocs' button: later clicks copy the cached Markdown without
fetching it again. A failed fetch isn't cached, so the next click retries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A refused write rejects with the browser's error, for both a fresh and a
cached copy, and Markdown fetched before the refusal is kept, so the next
click copies it without fetching again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The portal's button hard-coded its labels in English. It now reads them
through @fuma-translate/react under fumadocs' own keys and note, so
translations given to fumadocs reach it too.

The package is pinned to the version fumadocs-ui resolves, so both share
one copy and one translation context, and it is linked into the build
project like the template's other dependencies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
SonarCloud (S6819) prefers the element to the status role; <output> is a
live region with that role, so screen readers announce it the same way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
It is appended to the base path as is, so a relative URL would build a
broken one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@mrafnadeem-apimatic mrafnadeem-apimatic 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.

The fix works: checked in Chrome against portal serve with a copy of test/resources/portal-inputs/default. Success, failure, the reset on moving to another page, and the screen-reader status all behave as the description says. Requesting changes for the following, each with an inline suggestion:

  1. A failed fetch can still be an unhandled rejection when the clipboard refuses the write before reading it (copy-markdown.ts), with a test for it.
  2. Reset "Copy failed" back to "Copy Markdown" after 1.5 s, like "Copied Markdown", so the reader can see they can try again. The screen-reader status clears with it (markdown-copy-button.tsx).
  3. Drop @fuma-translate/react. The portal supplies no translations, so it only adds a dependency and a version rule nothing checks (markdown-copy-button.tsx).
  4. Comments: three are longer than the one line of why that .ai/instructions.md allows.

The description needs to follow:

  • The table's failure row ("button stays enabled for a retry") should say the button returns to Copy Markdown after 1.5 s.
  • How to test: step 6 says "click again" after step 5 has moved to another page, so it copies a page that never failed. Retrying on the page that failed is never exercised by hand. Suggested steps 4–6:
    4. On a page you haven't copied yet, click Copy Markdown. Expected: Copy failed, the console logs Failed to fetch …: 404, the clipboard is unchanged, and 1.5 s later the button reads Copy Markdown again.
    5. Run window.fetch = realFetch and click again on the same page. Expected: Copied Markdown, and pasting gives that page's Markdown.
    6. Run the step 3 snippet again, click Copy Markdown on another page you haven't copied, and pick a different page from the sidebar before the 1.5 s pass. Expected: the new page's button reads Copy Markdown, not Copy failed.
  • If (3) is taken, remove the "New dependency: @fuma-translate/react" section.

With all the suggestions applied: the copy-markdown and packaging tests pass (28 passing), the new test fails on the current code, and in Chrome a failure resets to Copy Markdown after 1.5 s, with no unhandled rejection in the blocked-clipboard case.

Comment thread portal-template/src/lib/copy-markdown.ts Outdated
Comment thread test/portal-template/copy-markdown.test.ts Outdated
Comment thread portal-template/src/components/markdown-copy-button.tsx Outdated
Comment thread portal-template/src/components/markdown-copy-button.tsx Outdated
Comment thread portal-template/src/components/markdown-copy-button.tsx Outdated
Comment thread portal-template/src/routes/$.tsx Outdated
Comment thread portal-template/src/lib/copy-markdown.ts Outdated
mehnoorsiddiqui and others added 4 commits October 6, 2026 12:25
…te first

When the clipboard refused the write without reading the item, nothing
listened to the fetch, so a failed fetch was an unhandled rejection.
Awaiting both rejects with whichever fails first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…@fuma-translate/react

The portal supplies no translations, so the package only added a
dependency, a linked build dependency and a version rule nothing checked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e "Copied Markdown"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mehnoorsiddiqui

Copy link
Copy Markdown
Contributor Author

@mrafnadeem-apimatic thanks for the review. All of it is addressed:

  1. Unhandled rejection: the fetch is awaited together with the write, with your test (67321c0).
  2. "Copy failed" reset: it goes back to Copy Markdown after 1.5 s, like Copied Markdown (d75b5e0).
  3. @fuma-translate/react: dropped. The labels are a module-level LABELS map, and the package is out of package.json, the lockfile and LINKED_DEPENDENCIES (96cc4f8).
  4. Comments: each is one line of why (96cc4f8, a95ceec).

The description follows: the failure row says the button returns to Copy Markdown after 1.5 s, the Labels row says they're written in English, the "New dependency" section is gone, and How to test uses your steps 4–6.

pnpm test: 1570 passing. APIMATIC_E2E=1 portal build suite: 46 passing, including the type-check.

}

async function fetchMarkdown(url: string): Promise<string> {
const response = await fetch(url);

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.

Raw fetch can throw and should be wrapped in a try-catch block. The catch case indicates some network error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A failed `fetch` is already handled: it rejects `fetchMarkdown`, `Promise.all` passes that rejection on to `copyMarkdown`, and the button's rejection handler logs it and shows Copy failed. The `ClipboardItem` gets the same rejected promise, so the clipboard stays as it was. A try/catch here could only rethrow, since the rejection is what cancels the write.

There was no test for a request that gets no response, so I've added one: `rejects when the request gets no response and leaves the clipboard as it was`.

async function fetchMarkdown(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`);
const markdown = await response.text();

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.

This can throw with exceptions and should have a try-catch block to handle the edge cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same path as `fetch` above: if `response.text()` rejects, so do `fetchMarkdown` and `copyMarkdown`, and the button logs the error and shows Copy failed with the clipboard unchanged. A try/catch would have to rethrow to keep that behaviour, so it would add nothing.


async function fetchMarkdown(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`);

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.

Why throw? This is one of the scenarios where we need to return a value and show a Failed message/state.

@mehnoorsiddiqui mehnoorsiddiqui Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The throw is what shows the failed state. This promise is handed to ClipboardItem, and whatever it resolves with is written to the clipboard, so returning a value would replace the reader's clipboard with it. Rejecting is the only way to cancel the write, which is why fumadocs-ui started throwing here in 16.15.16. The button turns the rejection into Copy failed (markdown-copy-button.tsx).

copyMarkdown could catch it and resolve with a success flag instead, but the button would behave exactly the same, so I've kept the rejection.

Comment thread portal-template/src/components/markdown-copy-button.tsx Outdated
Comment thread portal-template/src/lib/copy-markdown.ts Outdated
mehnoorsiddiqui and others added 2 commits October 6, 2026 15:56
…r portal serve

A page edit under portal serve hot-updates the route without a full
reload, so the cache kept copying the pre-edit Markdown. The browser's
HTTP cache already serves a repeat click.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Oct 6, 2026

Copy link
Copy Markdown

@mehnoorsiddiqui
mehnoorsiddiqui merged commit 34ea45d into dev Oct 6, 2026
11 of 12 checks passed
@mehnoorsiddiqui mehnoorsiddiqui self-assigned this Oct 6, 2026
saeedjamshaid added a commit that referenced this pull request Oct 6, 2026
Brings in #419, #418, #423 and #425. The one conflict, in
routes/$.tsx, is resolved with #419's line: its copy button gets the
page's path and adds the base itself, while the popover keeps the full
address this branch gives it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saeedjamshaid added a commit that referenced this pull request Oct 6, 2026
#419's copyMarkdown put the base in front of the page's path by hand,
a second copy of the rule withBasePath holds, and its button took the
base as a prop read from import.meta.env. It now goes through
withBasePath, which reads Vite's base itself, so the button takes the
page's path alone. The optional base stays for the tests, which check
the address under /docs/.

The plan records the copy button as the template's own, with only the
popover given a full address, and the order agreed for #419 and #424.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 2.0.0-beta.4 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug][UI] useCopyButton swallows a failed copy, so MarkdownCopyButton gives no feedback when the fetch or clipboard write fails

2 participants