Skip to content

Commit f63bd12

Browse files
oskarbrueningclaude
andcommitted
fix: make ody-copy-button work in cross-origin iframes
Use a synchronous execCommand('copy') as the source of truth so copying succeeds inside cross-origin iframes (where the async Clipboard API is blocked by default) and legacy contexts. The async navigator.clipboard.writeText becomes a best-effort enhancement attempted only when the sync path can't run; its late result never touches the UI, since it settles after the user-gesture window. The copy CustomEvent shape ({ value, ok }, bubbles) and all state/feedback behavior are unchanged. Behavior-only fix, no public API change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6fcd373 commit f63bd12

2 files changed

Lines changed: 113 additions & 22 deletions

File tree

src/ui/components/copy-button.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@ import { OdyElement, classes, define } from '../base.js';
22
import { iconSvg } from '../icons.js';
33

44
/**
5-
* `<ody-copy-button>` — a button that copies its `value` to the clipboard via
6-
* `navigator.clipboard.writeText`, showing a transient success (or error) state.
7-
* Dispatches a `copy` CustomEvent with `{ value, ok }`.
5+
* `<ody-copy-button>` — a button that copies its `value` to the clipboard,
6+
* showing a transient success (or error) state. Dispatches a `copy`
7+
* CustomEvent with `{ value, ok }`.
8+
*
9+
* The copy uses a synchronous `document.execCommand('copy')` as the source of
10+
* truth so it works inside cross-origin iframes (where the async Clipboard API
11+
* is blocked by default) and legacy contexts. The async
12+
* `navigator.clipboard.writeText` is used only as a best-effort enhancement
13+
* when the synchronous path can't run.
814
*
915
* Attributes:
1016
* - `value` — the text copied to the clipboard.
@@ -38,17 +44,59 @@ export class OdyCopyButton extends OdyElement {
3844

3945
readonly #onClick = (): void => {
4046
const value = this.attr('value');
41-
const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
42-
if (!clipboard || typeof clipboard.writeText !== 'function') {
43-
this.#feedback('error', value);
44-
return;
47+
48+
// The async Clipboard API is preferred where allowed, but it CANNOT be the
49+
// source of truth: its promise settles after the user-gesture window, so a
50+
// fallback attempted then would fail. Do the synchronous copy now (works in
51+
// cross-origin iframes and legacy contexts), and only reach for the async
52+
// API when the sync path couldn't run — its late result never changes the UI.
53+
const ok = this.#execCopy(value);
54+
if (!ok) {
55+
const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
56+
if (clipboard && typeof clipboard.writeText === 'function') {
57+
// Best-effort only; may still copy where execCommand is disabled but
58+
// the async API is allowed. Its result never overrides the UI below.
59+
void clipboard.writeText(value).then(
60+
() => undefined,
61+
() => undefined,
62+
);
63+
}
4564
}
46-
void clipboard.writeText(value).then(
47-
() => this.#feedback('success', value),
48-
() => this.#feedback('error', value),
49-
);
65+
this.#feedback(ok ? 'success' : 'error', value);
5066
};
5167

68+
/**
69+
* Synchronous clipboard write. Must be called within the user-gesture window
70+
* (i.e. directly from the click handler, not from an async continuation).
71+
* Works in cross-origin iframes and legacy contexts where the async Clipboard
72+
* API is unavailable or blocked.
73+
*/
74+
#execCopy(value: string): boolean {
75+
if (!value || typeof document === 'undefined') return false;
76+
const el = document.createElement('textarea');
77+
el.value = value;
78+
el.setAttribute('readonly', '');
79+
el.style.position = 'fixed';
80+
el.style.top = '0';
81+
el.style.left = '0';
82+
el.style.opacity = '0';
83+
document.body.appendChild(el);
84+
el.select();
85+
try {
86+
el.setSelectionRange(0, value.length);
87+
} catch {
88+
/* older browsers */
89+
}
90+
let ok = false;
91+
try {
92+
ok = document.execCommand('copy');
93+
} catch {
94+
/* blocked */
95+
}
96+
document.body.removeChild(el);
97+
return ok;
98+
}
99+
52100
#feedback(state: 'success' | 'error', value: string): void {
53101
this.#state = state;
54102
this.render();

test/ui/batch3b.test.ts

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ afterEach(() => {
2020
document.body.innerHTML = '';
2121
vi.unstubAllGlobals();
2222
vi.useRealTimers();
23+
delete (document as unknown as { execCommand?: unknown }).execCommand;
2324
});
2425

2526
describe('ody-accordion', () => {
@@ -109,46 +110,87 @@ describe('ody-tabs', () => {
109110
});
110111

111112
describe('ody-copy-button', () => {
112-
it('copies the value, shows success, dispatches copy, then reverts', async () => {
113+
/** Stub the synchronous clipboard path (happy-dom has no execCommand). */
114+
function stubExecCommand(result: boolean): ReturnType<typeof vi.fn> {
115+
const execCommand = vi.fn().mockReturnValue(result);
116+
(document as unknown as { execCommand: unknown }).execCommand = execCommand;
117+
return execCommand;
118+
}
119+
120+
it('copies the value synchronously, shows success, dispatches copy, then reverts', async () => {
113121
vi.useFakeTimers();
122+
const execCommand = stubExecCommand(true);
114123
const writeText = vi.fn().mockResolvedValue(undefined);
115124
vi.stubGlobal('navigator', { clipboard: { writeText } });
116125
const el = await mount<HTMLElement>('<ody-copy-button value="hello" label="Copy" success-duration="500"></ody-copy-button>');
117126
const detail: Array<{ value: string; ok: boolean }> = [];
118127
el.addEventListener('copy', (e) => detail.push((e as CustomEvent).detail));
119128
el.querySelector('button')!.click();
120-
await Promise.resolve();
121-
await Promise.resolve();
122-
expect(writeText).toHaveBeenCalledWith('hello');
129+
expect(execCommand).toHaveBeenCalledWith('copy');
123130
expect(el.querySelector('.ody-button.success')).not.toBeNull();
124131
expect(detail).toEqual([{ value: 'hello', ok: true }]);
132+
// The sync path succeeded, so the async API must not be touched.
133+
expect(writeText).not.toHaveBeenCalled();
125134
vi.advanceTimersByTime(500);
126135
expect(el.querySelector('.ody-button.success')).toBeNull();
127136
});
128137

129-
it('shows an error state when the clipboard write rejects', async () => {
130-
const writeText = vi.fn().mockRejectedValue(new Error('nope'));
138+
it.each([
139+
['resolves', () => vi.fn().mockResolvedValue(undefined)],
140+
['rejects', () => vi.fn().mockRejectedValue(new Error('blocked'))],
141+
])('falls back to a best-effort async write when the sync copy fails and the async %s, reflecting the sync result immediately', async (_label, makeWriteText) => {
142+
stubExecCommand(false);
143+
const writeText = makeWriteText();
131144
vi.stubGlobal('navigator', { clipboard: { writeText } });
132145
const el = await mount<HTMLElement>('<ody-copy-button value="x"></ody-copy-button>');
133146
const detail: Array<{ ok: boolean }> = [];
134147
el.addEventListener('copy', (e) => detail.push((e as CustomEvent).detail));
135148
el.querySelector('button')!.click();
136-
await Promise.resolve();
137-
await Promise.resolve();
149+
// UI reflects the (failed) sync result immediately, without waiting on the async API.
138150
expect(el.querySelector('.ody-button.danger')).not.toBeNull();
139151
expect(el.querySelector('.ody-button--icon-only')).not.toBeNull();
140152
expect(detail[0].ok).toBe(false);
153+
// Best-effort async path was still invoked.
154+
expect(writeText).toHaveBeenCalledWith('x');
155+
await Promise.resolve();
156+
await Promise.resolve();
157+
// The late async result (success or failure) never flips the UI back.
158+
expect(el.querySelector('.ody-button.danger')).not.toBeNull();
159+
expect(detail).toHaveLength(1);
141160
});
142161

143-
it('falls back to an error state when the clipboard API is missing', async () => {
162+
it('shows an error state when both the sync copy and the async API are unavailable', async () => {
163+
stubExecCommand(false);
144164
vi.stubGlobal('navigator', {});
145165
const el = await mount<HTMLElement>('<ody-copy-button value="x" label="C"></ody-copy-button>');
146166
el.querySelector('button')!.click();
147167
expect(el.querySelector('.ody-button.danger')).not.toBeNull();
148168
});
149169

170+
it('shows an error state for an empty value without running the sync copy', async () => {
171+
const execCommand = stubExecCommand(true);
172+
const writeText = vi.fn().mockResolvedValue(undefined);
173+
vi.stubGlobal('navigator', { clipboard: { writeText } });
174+
const el = await mount<HTMLElement>('<ody-copy-button label="C"></ody-copy-button>');
175+
el.querySelector('button')!.click();
176+
expect(el.querySelector('.ody-button.danger')).not.toBeNull();
177+
// Empty value short-circuits the sync copy before execCommand runs.
178+
expect(execCommand).not.toHaveBeenCalled();
179+
});
180+
181+
it('tolerates a throwing execCommand and reports an error', async () => {
182+
(document as unknown as { execCommand: unknown }).execCommand = vi.fn(() => {
183+
throw new Error('blocked');
184+
});
185+
vi.stubGlobal('navigator', {});
186+
const el = await mount<HTMLElement>('<ody-copy-button value="x"></ody-copy-button>');
187+
el.querySelector('button')!.click();
188+
expect(el.querySelector('.ody-button.danger')).not.toBeNull();
189+
});
190+
150191
it('clears a pending timer on a second copy and tolerates a non-numeric duration', async () => {
151-
vi.stubGlobal('navigator', { clipboard: {} }); // no writeText -> error path, runs #feedback
192+
stubExecCommand(false);
193+
vi.stubGlobal('navigator', {}); // error path, runs #feedback
152194
const el = await mount<HTMLElement>('<ody-copy-button value="x" success-duration="abc"></ody-copy-button>');
153195
el.querySelector('button')!.click();
154196
// second click while the first revert timer is still pending exercises clearTimeout
@@ -158,7 +200,8 @@ describe('ody-copy-button', () => {
158200

159201
it('skips the revert render after the element is disconnected', async () => {
160202
vi.useFakeTimers();
161-
vi.stubGlobal('navigator', { clipboard: {} });
203+
stubExecCommand(false);
204+
vi.stubGlobal('navigator', {});
162205
const el = await mount<HTMLElement>('<ody-copy-button value="x" success-duration="100"></ody-copy-button>');
163206
el.querySelector('button')!.click();
164207
el.remove(); // now not connected; the pending timer must not throw on render

0 commit comments

Comments
 (0)