Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,6 @@ Nesting the markdown exposes a few pipeline limits:
- A `types.ts` outside an indexed docs section must pass `{ excludeFromIndex: true }`, or the
types loader creates a parent index page (`docs/app/page.mdx` shadowed the landing route).
It must also import the component by relative path; the loader resolves `@/` as a package.
- In fenced code, a trailing `// @highlight` on the **last** line drops that line, and
`createDemo(import.meta.url, X, {` followed by emphasis comments disables them for the
block. Wrap the statement in `@highlight-start` / `@highlight-end`, or put each argument
on its own line.
- The landing demos use their own `createDemo` (`docs/app/(shared)/demos/createDemo.ts`) with
`fallbackUsesExtraFiles`, so the page backs its claim that every file of a demo is in the
initial HTML as plain text. A `ContentLoading` must take `extraSource` from
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Counter as PlainCount } from '../plain-count/Counter';
import { Counter as TypedCount } from '../typed-count/Counter';
import { createDemoWithVariants } from '../../createDemo';

export const DemoMixedCounter = createDemoWithVariants(import.meta.url, {
PlainCount,
TypedCount,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as React from 'react';
import { DemoMixedCounter } from '.';

export default function Page() {
return (
// @focus
<DemoMixedCounter />
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as React from 'react';

export function Counter() {
return (
// @focus
<button type="button">Clicked 0 times</button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import path from 'node:path';
import { test, expect } from '@playwright/test';

// The standalone demo route, derived from this file's location under `app`.
const route = path
.dirname(import.meta.filename)
.split('/app')
.pop()!;

// The first variant has no transform (its JS version only renames the file), the
// second is TypeScript with a JS transform. The highlighter considers the first
// variant current, while the JS toggle belongs to the second: switching to the
// second, then to JS, must paint each final tree at once, with no re-flow.
test('switches into the TypeScript variant and to JS without re-flowing after either swap', async ({
page,
}) => {
const pageErrors: Error[] = [];
page.on('pageerror', (error) => pageErrors.push(error));

await page.goto(`${route}/demo-plain`);
const code = page.locator('pre').first();
await expect(code).toContainText('Clicked 0 times', { timeout: 15000 });
await expect(page.getByRole('button', { name: 'JS' })).toHaveCount(0);

// Record what the code shows after every change to the page, from here on: its
// text, and the markup of the frames in its collapsed window. (Frames outside
// the window are highlighted lazily, once visible, so they may change later.)
// The observer watches the page, since a swap may replace the `pre` itself.
await page.evaluate(() => {
const snapshots: string[] = [];
(window as unknown as { preSnapshots: string[] }).preSnapshots = snapshots;
new MutationObserver(() => {
const pre = document.querySelector('pre');
const windowFrames = Array.from(pre?.querySelectorAll('.frame[data-frame-type]') ?? []);
const snapshot = `${pre?.textContent}\n${windowFrames.map((frame) => frame.outerHTML).join('')}`;
if (snapshot !== snapshots[snapshots.length - 1]) {
snapshots.push(snapshot);
}
}).observe(document.body, { childList: true, subtree: true, characterData: true });
});
const snapshots = () =>
page.evaluate(() => (window as unknown as { preSnapshots: string[] }).preSnapshots);

await page.getByText('PlainCount', { exact: true }).click();
await page.getByRole('option', { name: 'TypedCount' }).click();
await expect(code).toContainText('interface CounterProps', { timeout: 15000 });

await page.getByRole('button', { name: 'JS' }).click();
await expect(page.getByText('Counter.jsx')).toBeVisible({ timeout: 15000 });
await expect(code).not.toContainText('interface CounterProps');
await expect(code).toContainText('<output>');

// Give a late re-render time to land, then check each swap painted its final
// tree: what the code shows never changed after the variant's first TypeScript
// render, or after its first JS render.
await page.waitForTimeout(1000);
const shown = await snapshots();
const firstJs = shown.findIndex(
(snapshot) => snapshot.includes('<output>') && !snapshot.includes('CounterProps'),
);
expect(firstJs).toBeGreaterThanOrEqual(0);
expect(new Set(shown.slice(firstJs)).size).toBe(1);
const typed = shown.slice(0, firstJs).filter((snapshot) => snapshot.includes('CounterProps'));
expect(new Set(typed).size).toBe(1);

expect(pageErrors, 'the demo should swap without uncaught errors').toEqual([]);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as React from 'react';

interface CounterProps {
count?: number;
}

export function Counter({ count = 0 }: CounterProps) {
return (
// @focus-start
<p>
Clicked <output>{count}</output> times
</p>
// @focus-end
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Counter as InlineCount } from '../inline-count/Counter';
import { Counter as OutputCount } from '../output-count/Counter';
import { createDemoWithVariants } from '../../createDemo';

export const DemoCounterInit = createDemoWithVariants(
import.meta.url,
{ InlineCount, OutputCount },
{ highlightAfter: 'init' },
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as React from 'react';
import { DemoCounterInit } from '.';

export default function Page() {
return (
// @focus
<DemoCounterInit />
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Counter as InlineCount } from '../inline-count/Counter';
import { Counter as OutputCount } from '../output-count/Counter';
import { createDemoWithVariants } from '../../demo-fallback/createDemo';

export const DemoCounterLoading = createDemoWithVariants(import.meta.url, {
InlineCount,
OutputCount,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as React from 'react';
import { DemoCounterLoading } from '.';

export default function Page() {
return (
// @focus
<DemoCounterLoading />
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Counter as InlineCount } from '../inline-count/Counter';
import { Counter as OutputCount } from '../output-count/Counter';
import { createDemoWithVariants } from '../../createDemo';

export const DemoCounterPlain = createDemoWithVariants(import.meta.url, {
InlineCount,
OutputCount,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as React from 'react';
import { DemoCounterPlain } from '.';

export default function Page() {
return (
// @focus
<DemoCounterPlain />
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as React from 'react';

interface CounterProps {
count?: number;
}

export function Counter({ count = 0 }: CounterProps) {
return (
// @focus
<button type="button">Clicked {count} times</button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as React from 'react';

interface CounterProps {
count?: number;
}

export function Counter({ count = 0 }: CounterProps) {
return (
// @focus-start
<p>
Clicked <output>{count}</output> times
</p>
// @focus-end
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import path from 'node:path';
import { test, expect } from '@playwright/test';

// The standalone demo route, derived from this file's location under `app`.
const route = path
.dirname(import.meta.filename)
.split('/app')
.pop()!;

// Both variants name their file `Counter.tsx`, and each production payload is
// compressed with its own file's text as the DEFLATE dictionary. Switching
// variants with the JS transform applied must decode the incoming variant with
// ITS dictionary, not the same-named file's from the initial variant.
const setups = [
{ name: 'demo-plain', description: 'without a ContentLoading' },
{ name: 'demo-init', description: "with highlightAfter: 'init' and no ContentLoading" },
{ name: 'demo-loading', description: 'with a ContentLoading' },
];

for (const { name, description } of setups) {
test(`keeps the JS transform when switching between same-named variant files ${description}`, async ({
page,
}) => {
const pageErrors: Error[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error));
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});

await page.goto(`${route}/${name}`);
const code = page.locator('pre').first();

await expect(code).toContainText('interface CounterProps', { timeout: 15000 });

await page.getByRole('button', { name: 'JS' }).click();
await expect(page.getByText('Counter.jsx')).toBeVisible({ timeout: 15000 });
await expect(code).not.toContainText('interface CounterProps');

await page.getByText('InlineCount', { exact: true }).click();
await page.getByRole('option', { name: 'OutputCount' }).click();

// The incoming variant renders, still in JavaScript.
await expect(code).toContainText('<output>', { timeout: 15000 });
await expect(code).not.toContainText('interface CounterProps');
await expect(page.getByText('Counter.jsx')).toBeVisible();

expect(
consoleErrors.filter((text) => /dictionary mismatch|Transform failed/i.test(text)),
).toEqual([]);
expect(pageErrors, 'the demo should switch variants without uncaught errors').toEqual([]);
});
}
8 changes: 6 additions & 2 deletions docs/app/lib/hooks/use-code/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,12 @@ function CopyControls(props) {

Both are wired through [`useCopier`](../use-copier/page.mdx); pass the `copy`
option (`UseCopierOpts`) to configure success-state timing and callbacks for
both. Compressed sources are decoded back to text on demand, so neither copy
forces an early decompression of the block.
both. Each also exposes its copier's `recentlySuccessful` flag, as
`copyRecentlySuccessful` and `copyMarkdownRecentlySuccessful`: `true` for the
`copy.timeout` (2000 ms by default) after a copy succeeds. They're meant for
driving a toast or other notification that confirms the copy. Compressed sources
are decoded back to text on demand, so neither copy forces an early
decompression of the block.

> [!NOTE]
> For lists and standalone chunks, the lower-level
Expand Down
8 changes: 8 additions & 0 deletions docs/app/lib/hooks/use-code/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,19 @@ type UseCodeResult<T extends {} = {}> = {
expand: () => void;
setExpanded: (expanded: boolean) => void;
copy: (event: React.MouseEvent) => Promise<void>;
/**
* `true` for a moment after `copy` succeeds — for the `copy.timeout` option,
* 2000 ms by default — to drive a toast or other notification confirming the
* copy. The `recentlySuccessful` of the `useCopier` behind `copy`.
*/
copyRecentlySuccessful: boolean;
/**
* Copies all files in the current variant to the clipboard as a Markdown
* snippet (heading + per-file fenced code blocks).
*/
copyMarkdown: (event: React.MouseEvent) => Promise<void>;
/** Like `copyRecentlySuccessful`, for `copyMarkdown`. */
copyMarkdownRecentlySuccessful: boolean;
availableTransforms: string[];
selectedTransform: string | null | undefined;
selectTransform: (transformName: string | null) => void;
Expand Down
12 changes: 7 additions & 5 deletions docs/app/lib/hooks/use-copier/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -105,28 +105,30 @@ import { TypesUseCopier } from './types';
2. **Clipboard API**: Uses the `clipboard-copy` library for cross-browser compatibility
3. **Success State**: Sets `recentlySuccessful` to `true` after successful copy
4. **Timeout Management**: Automatically resets success state after configured timeout
5. **Cleanup**: Clears timeouts to prevent memory leaks
5. **Cleanup**: Clears the timeout on unmount, and a write that finishes after unmounting starts none

## State Management

The hook manages internal state for copy feedback:

- **Initial state**: `recentlySuccessful` is `false`
- **On copy attempt**: State resets to `false` and clears any existing timeout
- **On success**: State becomes `true` and starts timeout countdown
- **On success**: State becomes `true` and the timeout countdown (re)starts
- **After timeout**: State resets to `false`
- **On error**: State remains `false` but error callback is fired
- **Copying again**: State stays `true` while the new write is in flight, so feedback keyed on it doesn't flicker; each successful write restarts the countdown, so the state stays `true` until `timeout` after the last write to finish. A write still in flight `timeout` after it started ends the feedback, so a stalled write can't keep it on. Use `onCopied` to react to every copy.
- **On error**: State becomes `false` and the error callback is fired
- **On empty content**: Nothing is written and state becomes `false`; neither `onCopied` nor `onError` is fired, but `onClick` still is

## Error Handling

Copy operations can fail for various reasons:

- **Security restrictions**: Some browsers require user interaction
- **Permission denied**: Clipboard access may be blocked
- **Empty content**: No content to copy (function returns undefined)

The hook provides the `onError` callback to handle these cases gracefully.

Empty content (an empty string, or a function that returns `undefined`) is not an error: there is nothing to copy, so the clipboard is left untouched and `recentlySuccessful` stays `false`.

---

## When to Use
Expand Down
Loading
Loading