diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/demo-plain/index.ts b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/demo-plain/index.ts new file mode 100644 index 0000000..5a63a49 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/demo-plain/index.ts @@ -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, +}); diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/demo-plain/page.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/demo-plain/page.tsx new file mode 100644 index 0000000..aff83d8 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/demo-plain/page.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { DemoMixedCounter } from '.'; + +export default function Page() { + return ( + // @focus + + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/plain-count/Counter.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/plain-count/Counter.tsx new file mode 100644 index 0000000..78ef75e --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/plain-count/Counter.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; + +export function Counter() { + return ( + // @focus + + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/test.ts b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/test.ts new file mode 100644 index 0000000..8d956b2 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/test.ts @@ -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(''); + + // 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('') && !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([]); +}); diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/typed-count/Counter.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/typed-count/Counter.tsx new file mode 100644 index 0000000..5a19507 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/typed-count/Counter.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +interface CounterProps { + count?: number; +} + +export function Counter({ count = 0 }: CounterProps) { + return ( + // @focus-start +

+ Clicked {count} times +

+ // @focus-end + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/index.ts b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/index.ts new file mode 100644 index 0000000..36548ba --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/index.ts @@ -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' }, +); diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/page.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/page.tsx new file mode 100644 index 0000000..4661a38 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/page.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { DemoCounterInit } from '.'; + +export default function Page() { + return ( + // @focus + + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/index.ts b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/index.ts new file mode 100644 index 0000000..5d5d365 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/index.ts @@ -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, +}); diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/page.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/page.tsx new file mode 100644 index 0000000..cfee15e --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/page.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { DemoCounterLoading } from '.'; + +export default function Page() { + return ( + // @focus + + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/index.ts b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/index.ts new file mode 100644 index 0000000..2844860 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/index.ts @@ -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, +}); diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/page.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/page.tsx new file mode 100644 index 0000000..2ff06a5 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/page.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { DemoCounterPlain } from '.'; + +export default function Page() { + return ( + // @focus + + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/inline-count/Counter.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/inline-count/Counter.tsx new file mode 100644 index 0000000..c145841 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/inline-count/Counter.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; + +interface CounterProps { + count?: number; +} + +export function Counter({ count = 0 }: CounterProps) { + return ( + // @focus + + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/output-count/Counter.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/output-count/Counter.tsx new file mode 100644 index 0000000..5a19507 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/output-count/Counter.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +interface CounterProps { + count?: number; +} + +export function Counter({ count = 0 }: CounterProps) { + return ( + // @focus-start +

+ Clicked {count} times +

+ // @focus-end + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/test.ts b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/test.ts new file mode 100644 index 0000000..131d4fd --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/test.ts @@ -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('', { 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([]); + }); +} diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.deferHighlight.test.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.deferHighlight.test.tsx new file mode 100644 index 0000000..4d1e309 --- /dev/null +++ b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.deferHighlight.test.tsx @@ -0,0 +1,412 @@ +/** + * @vitest-environment jsdom + * + * `deferHighlight` holds variant and transform swaps while the async transform + * deltas (`computeHastDeltas`) are still being computed, so a swap never paints + * a tree that re-flows once they land. `useCode` picks the rendered variant on + * its own, so the wait has to cover every variant of the block, not just the + * variant the highlighter itself considers current. Rendering doesn't wait for + * the deltas: the code shown meanwhile stays highlighted wherever it can be, and + * is never older than the loaded code. + */ +import * as React from 'react'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import { CodeHighlighterClient } from './CodeHighlighterClient'; +import { CodeHighlighterContext } from './CodeHighlighterContext'; +import { CodeContext } from '../CodeProvider/CodeContext'; +import type { ComputeHastDeltasLoader } from '../CodeProvider/CodeContext'; +import { useCode } from '../useCode'; +import { preloadTransformEngine } from '../useCode/transformEngineCache'; +import { createParseSource } from '../pipeline/parseSource'; +import { parseCode } from '../pipeline/loadIsomorphicCodeVariant/parseCode'; +import { computeHastDeltas } from '../pipeline/loadIsomorphicCodeVariant/computeHastDeltas'; +import { transformSource } from '../pipeline/loadIsomorphicCodeVariant/transformSource'; +import type { Code, ContentProps, ParseSource, Transforms, VariantCode } from './types'; + +const typedSource = 'const typed: number = 1;\nexport default typed;\n'; +const typedJavaScript = 'const typed = 1;\nexport default typed;\n'; + +let parseSource: ParseSource; +let typedTransforms: Transforms; + +beforeAll(async () => { + class NoopObserver { + observe() {} + + unobserve() {} + + disconnect() {} + + takeRecords(): IntersectionObserverEntry[] { + return []; + } + } + globalThis.IntersectionObserver = NoopObserver as unknown as typeof IntersectionObserver; + globalThis.ResizeObserver = NoopObserver as unknown as typeof ResizeObserver; + await preloadTransformEngine(); + parseSource = await createParseSource(); + // A TS → JS transform with a real source delta, as the build produces it. + typedTransforms = (await transformSource(typedSource, 'Typed.tsx', [ + { + extensions: ['tsx'], + transformer: async () => ({ js: { source: typedJavaScript, fileName: 'Typed.jsx' } }), + }, + ]))!; +}); + +beforeEach(() => { + window.localStorage.clear(); +}); + +const plainVariant = (name = 'Plain'): VariantCode => ({ + fileName: `${name}.js`, + source: `const ${name.toLowerCase()} = 1;\n`, +}); + +const typedVariant = (name = 'Typed'): VariantCode => ({ + fileName: `${name}.tsx`, + source: typedSource.replaceAll('typed', name.toLowerCase()), + transforms: structuredClone(typedTransforms), +}); + +/** + * A `computeHastDeltasLoader` whose computations finish only when the test + * resolves them. + */ +function createHeldDeltas() { + const pending: Array<() => void> = []; + const loader: ComputeHastDeltasLoader = async () => (parsedCode, parser) => + new Promise((resolve) => { + pending.push(() => resolve(computeHastDeltas(parsedCode, parser))); + }); + const resolveAll = async () => { + await act(async () => { + pending.splice(0).forEach((finish) => finish()); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + }); + }; + return { loader, pending, resolveAll }; +} + +/** Each distinct committed state of the rendered file: its name and markup. */ +let commits: Array<{ fileName: string; markup: string }> = []; + +function Demo(props: ContentProps) { + const code = useCode(props, { variantSwapDelay: 0, transformDelay: 0 }); + const deferHighlight = React.useContext(CodeHighlighterContext)?.deferHighlight; + const preRef = React.useRef(null); + React.useLayoutEffect(() => { + const fileName = code.selectedFileName ?? ''; + const markup = preRef.current?.innerHTML ?? ''; + const last = commits[commits.length - 1]; + if (!last || last.fileName !== fileName || last.markup !== markup) { + commits.push({ fileName, markup }); + } + }); + return ( +
+ {code.selectedFileName} + {String(!!deferHighlight)} +
+        {code.selectedFile}
+      
+ {code.variants.map((variant) => ( + + ))} + +
+ ); +} + +/** + * Renders a block, returning a function that replaces its `code` (as a new + * `code` prop, a `refresh()` or a lazily loaded variant does, starting a re-parse). + */ +function mountBlock(code: Code, loader: ComputeHastDeltasLoader) { + commits = []; + const block = (current: Code) => ( + + + + + + ); + const view = render(block(code)); + return (next: Code) => view.rerender(block(next)); +} + +/** Lets timers, frames and the coordinated swap barriers run their course. */ +async function settle() { + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 150); + }); + }); +} + +const renderedFileName = () => screen.getByTestId('file').textContent; +const isDeferred = () => screen.getByTestId('defer').textContent === 'true'; +const renderedText = () => screen.getByTestId('code').textContent ?? ''; +/** Whether rendered code markup is highlighted (split into highlighted lines). */ +const isHighlighted = (markup: string) => markup.includes('class="line"'); + +/** + * A TypeScript variant holding `const : number = `, with the TS → + * JS transform the build computes for exactly that source. + */ +async function typedVariantOf(name: string, value: number): Promise { + const fileName = `${name}.tsx`; + const source = `const ${name.toLowerCase()}: number = ${value};\nexport default ${name.toLowerCase()};\n`; + const transforms = await transformSource(source, fileName, [ + { + extensions: ['tsx'], + transformer: async (text) => ({ + js: { source: text.replace(': number', ''), fileName: `${name}.jsx` }, + }), + }, + ]); + return { fileName, source, transforms }; +} + +describe('CodeHighlighterClient deferHighlight while transform deltas are computed', () => { + it('holds a switch into a variant with transforms, and its JS toggle, until the deltas land', async () => { + const deltas = createHeldDeltas(); + mountBlock({ Plain: plainVariant(), Typed: typedVariant() }, deltas.loader); + await waitFor(() => expect(deltas.pending).toHaveLength(1)); + await settle(); + expect(renderedFileName()).toBe('Plain.js'); + expect(isDeferred()).toBe(true); + const commitsBefore = commits.length; + + // Switch to the TypeScript variant, then ask for JS, while the deltas are + // still being computed. + act(() => { + screen.getByTestId('variant:Typed').click(); + }); + await settle(); + act(() => { + screen.getByTestId('js').click(); + }); + await settle(); + + // Nothing commits until they land. + expect(renderedFileName()).toBe('Plain.js'); + expect(commits).toHaveLength(commitsBefore); + + await deltas.resolveAll(); + await waitFor(() => expect(renderedFileName()).toBe('Typed.tsx')); + expect(isDeferred()).toBe(false); + + act(() => { + screen.getByTestId('js').click(); + }); + await waitFor(() => expect(renderedFileName()).toBe('Typed.jsx')); + await settle(); + expect(screen.getByTestId('code').textContent).toContain('const typed = 1;'); + + // Each swap painted its final tree: the variant in TypeScript, then in + // JavaScript, with no re-flow after either. + const typedCommits = commits + .slice(commitsBefore) + .filter(({ fileName }) => fileName !== 'Plain.js'); + expect(typedCommits.map(({ fileName }) => fileName)).toEqual(['Typed.tsx', 'Typed.jsx']); + }); + + it('holds a switch out of a variant with transforms into one without, until the deltas land', async () => { + const deltas = createHeldDeltas(); + mountBlock({ Typed: typedVariant(), Plain: plainVariant() }, deltas.loader); + await waitFor(() => expect(deltas.pending).toHaveLength(1)); + expect(isDeferred()).toBe(true); + + act(() => { + screen.getByTestId('variant:Plain').click(); + }); + await settle(); + expect(renderedFileName()).toBe('Typed.tsx'); + + await deltas.resolveAll(); + await waitFor(() => expect(renderedFileName()).toBe('Plain.js')); + expect(isDeferred()).toBe(false); + }); + + it('holds a switch between variants that all have transforms, until the deltas land', async () => { + const deltas = createHeldDeltas(); + mountBlock({ Typed: typedVariant(), Other: typedVariant('Other') }, deltas.loader); + await waitFor(() => expect(deltas.pending).toHaveLength(1)); + expect(isDeferred()).toBe(true); + + act(() => { + screen.getByTestId('variant:Other').click(); + }); + await settle(); + expect(renderedFileName()).toBe('Typed.tsx'); + + await deltas.resolveAll(); + await waitFor(() => expect(renderedFileName()).toBe('Other.tsx')); + expect(isDeferred()).toBe(false); + }); + + it('never waits for the deltas when no variant has transforms', async () => { + const deltas = createHeldDeltas(); + mountBlock({ Plain: plainVariant(), Other: plainVariant('Other') }, deltas.loader); + await waitFor(() => expect(deltas.pending).toHaveLength(1)); + expect(isDeferred()).toBe(false); + + act(() => { + screen.getByTestId('variant:Other').click(); + }); + await waitFor(() => expect(renderedFileName()).toBe('Other.js')); + expect(deltas.pending).toHaveLength(1); + expect(isDeferred()).toBe(false); + + await deltas.resolveAll(); + }); +}); + +describe('CodeHighlighterClient rendering while the deltas of a re-parse are computed', () => { + /** + * Renders `code`, lets its first deltas land, applies `before` (e.g. a + * transform toggle), then replaces the code with `next` and returns the commits + * made from then on, with the deltas for `next` still pending. + */ + async function replaceWhileDeltasPending(code: Code, next: Code, before?: () => Promise) { + const deltas = createHeldDeltas(); + const replace = mountBlock(code, deltas.loader); + await waitFor(() => expect(deltas.pending).toHaveLength(1)); + await deltas.resolveAll(); + await settle(); + await before?.(); + expect(isHighlighted(screen.getByTestId('code').innerHTML)).toBe(true); + + const commitsBefore = commits.length; + act(() => { + replace(next); + }); + await waitFor(() => expect(deltas.pending).toHaveLength(1)); + await settle(); + expect(isDeferred()).toBe(true); + return { deltas, commitsAfterReplace: () => commits.slice(commitsBefore) }; + } + + it('keeps a variant without transforms highlighted, with its new content, in a mixed block', async () => { + const typed = await typedVariantOf('Typed', 1); + const { deltas, commitsAfterReplace } = await replaceWhileDeltasPending( + { Plain: plainVariant(), Typed: typed }, + { Plain: { ...plainVariant(), source: 'const plain = 2;\n' }, Typed: { ...typed } }, + ); + + expect(renderedText()).toContain('const plain = 2;'); + expect(commitsAfterReplace().length).toBeGreaterThan(0); + for (const { markup } of commitsAfterReplace()) { + expect(isHighlighted(markup)).toBe(true); + expect(markup).not.toContain('= 1'); + } + + await deltas.resolveAll(); + expect(renderedText()).toContain('const plain = 2;'); + expect(isHighlighted(screen.getByTestId('code').innerHTML)).toBe(true); + }); + + it('keeps TypeScript highlighted, with its new content, in a block where every variant has transforms', async () => { + const other = await typedVariantOf('Other', 1); + const { deltas, commitsAfterReplace } = await replaceWhileDeltasPending( + { Typed: await typedVariantOf('Typed', 1), Other: other }, + { Typed: await typedVariantOf('Typed', 5), Other: { ...other } }, + ); + + expect(renderedText()).toContain('const typed: number = 5;'); + expect(commitsAfterReplace().length).toBeGreaterThan(0); + for (const { markup } of commitsAfterReplace()) { + expect(isHighlighted(markup)).toBe(true); + } + + await deltas.resolveAll(); + expect(renderedText()).toContain('const typed: number = 5;'); + expect(isHighlighted(screen.getByTestId('code').innerHTML)).toBe(true); + }); + + const selectJavaScript = async () => { + act(() => { + screen.getByTestId('js').click(); + }); + await waitFor(() => expect(renderedFileName()).toBe('Typed.jsx')); + await settle(); + }; + + it('keeps the JavaScript tree highlighted when a re-parse leaves the content unchanged', async () => { + const typed = await typedVariantOf('Typed', 1); + const other = await typedVariantOf('Other', 1); + const { deltas, commitsAfterReplace } = await replaceWhileDeltasPending( + { Typed: typed, Other: other }, + { Typed: structuredClone(typed), Other: structuredClone(other) }, + selectJavaScript, + ); + + expect(renderedFileName()).toBe('Typed.jsx'); + expect(renderedText()).toContain('const typed = 1;'); + for (const { markup } of commitsAfterReplace()) { + expect(isHighlighted(markup)).toBe(true); + } + + await deltas.resolveAll(); + expect(renderedText()).toContain('const typed = 1;'); + expect(isHighlighted(screen.getByTestId('code').innerHTML)).toBe(true); + }); + + it('shows changed JavaScript as plain text until its deltas land, never the old or untransformed code', async () => { + const other = await typedVariantOf('Other', 1); + const { deltas, commitsAfterReplace } = await replaceWhileDeltasPending( + { Typed: await typedVariantOf('Typed', 1), Other: other }, + { Typed: await typedVariantOf('Typed', 5), Other: { ...other } }, + selectJavaScript, + ); + + expect(renderedFileName()).toBe('Typed.jsx'); + expect(commitsAfterReplace().length).toBeGreaterThan(0); + for (const { fileName, markup } of commitsAfterReplace()) { + expect(fileName).toBe('Typed.jsx'); + expect(markup).toContain('const typed = 5;'); + expect(markup).not.toContain('number'); + } + + await deltas.resolveAll(); + await settle(); + expect(renderedFileName()).toBe('Typed.jsx'); + expect(renderedText()).toContain('const typed = 5;'); + expect(renderedText()).not.toContain('number'); + expect(isHighlighted(screen.getByTestId('code').innerHTML)).toBe(true); + }); + + it('renders the first load as it did, plain until the first deltas land', async () => { + const deltas = createHeldDeltas(); + mountBlock({ Plain: plainVariant(), Typed: await typedVariantOf('Typed', 1) }, deltas.loader); + await waitFor(() => expect(deltas.pending).toHaveLength(1)); + await settle(); + + expect(screen.getByTestId('code').innerHTML).toBe( + '
const plain = 1;\n
', + ); + + await deltas.resolveAll(); + expect(isHighlighted(screen.getByTestId('code').innerHTML)).toBe(true); + }); +}); diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx index 03227a8..b7ebd19 100644 --- a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx +++ b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx @@ -19,7 +19,7 @@ import { useControlledCode } from '../CodeControllerContext'; import type { Selection } from '../CodeControllerContext'; import { codeToFallbackProps, - deriveFallbacksFromCode, + resolveVariantFallbacks, stripFallbackHastsFromCode, } from './codeToFallbackProps'; import { resolveFallbackCritical } from './resolveFallbackCritical'; @@ -29,7 +29,11 @@ import { scatterResidualFallbacks, } from './fallbackCompression'; import { mergeCodeMetadata } from '../pipeline/loadIsomorphicCodeVariant/mergeCodeMetadata'; -import { getAvailableTransforms } from '../pipeline/loadIsomorphicCodeVariant/getAvailableTransforms'; +import { + getAvailableTransforms, + hasAnyVariantTransforms, +} from '../pipeline/loadIsomorphicCodeVariant/getAvailableTransforms'; +import { getPendingTransformedCode } from './getPendingTransformedCode'; import { useSpeculativeCodePreload } from './useSpeculativeCodePreload'; import { useSpeculativeEditingPreload } from './useSpeculativeEditingPreload'; import { useSpeculativeUseCodePreload } from './useSpeculativeUseCodePreload'; @@ -681,6 +685,7 @@ function useCodeTransforms({ // callers detect staleness with reference equality. const [transformedState, setTransformedState] = React.useState<{ input?: Code; + loaded?: Code; output?: Code; }>({}); @@ -690,6 +695,13 @@ function useCodeTransforms({ [parsedCode, loadedCode, variantName], ); + // Whether any variant has transforms, so has deltas worth waiting for. Not + // just `variantName`'s: `useCode` picks the rendered variant on its own. + const hasAnyTransforms = React.useMemo( + () => hasAnyVariantTransforms(parsedCode ?? loadedCode), + [parsedCode, loadedCode], + ); + // Effect to compute transformations for all variants. Only runs when the // full async pipeline is wired (`parsedCode` + worker + deltas computer); // the no-async case is derived during render below instead of being stored, @@ -707,7 +719,7 @@ function useCodeTransforms({ const commit = (output: Code) => { if (!settled) { settled = true; - setTransformedState({ input: parsedCode, output }); + setTransformedState({ input: parsedCode, loaded: loadedCode, output }); } }; @@ -743,18 +755,27 @@ function useCodeTransforms({ settled = true; // a newer run (or unmount) supersedes this one; ignore late writes clearTimeout(timer); }; - }, [parsedCode, sourceParser, computeHastDeltasLoader]); + }, [parsedCode, loadedCode, sourceParser, computeHastDeltasLoader]); - // When the full async pipeline is wired, expose the cached output regardless - // of whether `parsedCode` changed since the last computation — falling back - // to `undefined` here would yank the currently-displayed HAST for a frame - // while the async pipeline catches up. Staleness is signalled via + // When the full async pipeline is wired, expose the finished output. While the + // deltas for a new `parsedCode` are still being computed, expose the finished + // output for variants whose content is unchanged and the new parse for the rest + // (see `getPendingTransformedCode`), so the code shown stays highlighted without + // ever being older than the loaded code. Staleness is still signalled via // `waitingForTransformedCode` so downstream gates (e.g. // `useTransformManagement` / `useVariantSelection`) hold off committing a // swap until fresh deltas land. Without the pipeline, `transformedCode` is a // synchronous pass-through of `parsedCode` derived during render. const hasAsyncPipeline = !!parsedCode && !!sourceParser && !!computeHastDeltasLoader; - const transformedCode = hasAsyncPipeline ? transformedState.output : parsedCode; + const transformedCode = React.useMemo(() => { + if (!hasAsyncPipeline || !parsedCode) { + return parsedCode; + } + if (transformedState.input === parsedCode) { + return transformedState.output; + } + return getPendingTransformedCode(parsedCode, loadedCode, transformedState); + }, [hasAsyncPipeline, parsedCode, loadedCode, transformedState]); // Async hast-deltas pipeline status. While true, consumers (notably // `useTransformManagement`'s `deferHighlight` gate) should treat @@ -774,7 +795,7 @@ function useCodeTransforms({ // until its deltas land. const waitingForTransformedCode = hasAsyncPipeline && transformedState.input !== parsedCode; - return { transformedCode, availableTransforms, waitingForTransformedCode }; + return { transformedCode, availableTransforms, hasAnyTransforms, waitingForTransformedCode }; } function useControlledCodeParsing({ @@ -1475,11 +1496,12 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) { url: props.url, }); - const { transformedCode, availableTransforms, waitingForTransformedCode } = useCodeTransforms({ - parsedCode, - loadedCode: codeWithGlobals, - variantName, - }); + const { transformedCode, availableTransforms, hasAnyTransforms, waitingForTransformedCode } = + useCodeTransforms({ + parsedCode, + loadedCode: codeWithGlobals, + variantName, + }); // Combined highlight-readiness gate consumed via context (notably by // `useTransformManagement`). Stay deferred while either the sync @@ -1488,11 +1510,14 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) { // pending causes the incoming pre to first render without the // transform deltas and then re-flow a frame or two later when the // deltas land, producing a visible jump on top of the collapse - // animation. The wait only matters for highlighters with at least one - // applicable transform; plain (variant-only) highlighters skip it so - // their stored-preference resolution doesn't pay the deltas latency. + // animation. The wait only matters for blocks with at least one + // applicable transform in ANY variant: `useCode` selects the rendered + // variant itself, so this highlighter's `variantName` may be a variant + // without transforms while the one being switched to has them. Blocks + // with no transforms anywhere skip it so their stored-preference + // resolution doesn't pay the deltas latency. const deferHighlight = - deferHighlightForParsing || (availableTransforms.length > 0 && waitingForTransformedCode); + deferHighlightForParsing || (hasAnyTransforms && waitingForTransformedCode); // The fallback↔content swap, generalized into `useCoordinatedSwap`: it owns // the force-mount-once behavior, nested-fallback suppression (via the shared @@ -1572,22 +1597,10 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) { const codeForFallback = overlaidCode || (controlled?.code ? undefined : resolvedPropsCode || resolvedStateCode); - // Resolve the active variant's fallbacks from the two places one can cross - // the server→client boundary: the hoisted copy (from a `ContentLoading` - // component, which had it stripped off `Code`) and the variant's own - // `fallback` field on `Code` (present without a `ContentLoading`, or scattered - // back from the residual blob). For most files only one is populated. When - // both are — a `fallbackCollapsed` block hoists the *visible* window but - // scatters the *full* fallback onto `Code` — the `Code` copy must win, since - // the full text is the DEFLATE dictionary `hastCompressed` needs. So merge - // with the derived (`Code`) copy taking precedence. - const activeFallbacks = React.useMemo(() => { - const merged = { - ...hoistedFallbackHasts[variantName], - ...deriveFallbacksFromCode(codeForFallback, variantName), - }; - return Object.keys(merged).length > 0 ? merged : undefined; - }, [hoistedFallbackHasts, variantName, codeForFallback]); + const variantFallbacks = React.useMemo( + () => resolveVariantFallbacks(hoistedFallbackHasts, codeForFallback), + [hoistedFallbackHasts, codeForFallback], + ); const fallbackContext = React.useMemo( () => ({ @@ -1653,9 +1666,11 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) { // Only suppress when an external CodeController owns the code; static // `props.code` still needs the locally-computed list. availableTransforms: controlled?.code ? [] : availableTransforms, + availableTransformsVariant: variantName, url: props.url, deferHighlight, - fallbacks: activeFallbacks, + deferHighlightRender: deferHighlightForParsing, + variantFallbacks, highlightReady, highlightAfter, editActivation, @@ -1673,9 +1688,11 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) { controlled?.errors, controlled?.code, availableTransforms, + variantName, props.url, deferHighlight, - activeFallbacks, + deferHighlightForParsing, + variantFallbacks, highlightReady, highlightAfter, editActivation, diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx index 3b1f1b4..0ab4068 100644 --- a/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx +++ b/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx @@ -27,16 +27,44 @@ export interface CodeHighlighterContextType { * `useDemo().error`. */ errors?: Record; + /** + * Transforms with a real delta for the variant named by + * `availableTransformsVariant`, i.e. the variant the highlighter considers + * current. Empty when a `CodeControllerContext` owns the code. + */ availableTransforms?: string[]; + /** + * The variant `availableTransforms` was computed for. That is not always + * `selection.variant` (a `variant` prop wins over it) nor the variant a + * consumer that selects variants on its own (like `useCode`) renders, so + * such a consumer reuses the list only when this names its rendered variant. + */ + availableTransformsVariant?: string; url?: string; + /** + * Commit gate for swaps: `true` while the highlighter is still parsing, or + * computing the transform deltas of a block where any variant has transforms. + * Variant and transform swaps (and a deferred `expand()`) wait for it, so they + * never paint a tree that re-flows once the pending work lands. + */ deferHighlight?: boolean; /** + * Render-side counterpart of `deferHighlight`: `true` only while the parse is + * in flight. The code published during the transform-deltas window is already + * highlighted wherever it can be (see `getPendingTransformedCode`), so + * rendering doesn't wait for the deltas. Consumers fall back to + * `deferHighlight` when it's absent. + */ + deferHighlightRender?: boolean; /** - * Compact fallback data for the active variant, keyed by fileName. - * Used by `Pre` to both render the fallback and derive text dictionaries - * for decompressing `hastCompressed` payloads. + * Compact fallback data for every variant, keyed by variant and then + * fileName. Used to render the fallback and as the text dictionary for + * decompressing a `hastCompressed` payload. That payload only decodes with + * its own file's dictionary, and variants often share file names, so a + * consumer that selects variants on its own (like `useCode`) reads the entry + * for the variant it renders. */ - fallbacks?: Fallbacks; + variantFallbacks?: Record; /** * Render-side readiness gate. `true` once the highlight trigger * (`init` / `hydration` / `idle` / `visible`) has fired *and* the diff --git a/packages/docs/src/CodeHighlighter/codeToFallbackProps.test.ts b/packages/docs/src/CodeHighlighter/codeToFallbackProps.test.ts index 5e4bb3b..9c99670 100644 --- a/packages/docs/src/CodeHighlighter/codeToFallbackProps.test.ts +++ b/packages/docs/src/CodeHighlighter/codeToFallbackProps.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { codeToFallbackProps, deriveFallbacksFromCode, + resolveVariantFallbacks, stripFallbackHastsFromCode, } from './codeToFallbackProps'; import type { Code, HastRoot } from './types'; @@ -514,3 +515,79 @@ describe('deriveFallbacksFromCode', () => { expect(deriveFallbacksFromCode(code, 'javascript')).toEqual({ 'App.js': fbMain }); }); }); + +describe('resolveVariantFallbacks', () => { + it('keeps the hoisted fallbacks when the code carries none', () => { + const fbButton = fb('button'); + const code: Code = { + Default: { fileName: 'Button.tsx', source: { hastCompressed: 'abc' } }, + }; + + expect(resolveVariantFallbacks({ Default: { 'Button.tsx': fbButton } }, code)).toEqual({ + Default: { 'Button.tsx': fbButton }, + }); + }); + + it('reads the fallbacks off the code when nothing was hoisted', () => { + const fbButton = fb('button'); + const fbCheckbox = fb('checkbox'); + const code: Code = { + Button: { fileName: 'Button.tsx', source: { hastCompressed: 'abc' }, fallback: fbButton }, + Checkbox: { + fileName: 'Checkbox.tsx', + source: { hastCompressed: 'def' }, + fallback: fbCheckbox, + }, + }; + + expect(resolveVariantFallbacks({}, code)).toEqual({ + Button: { 'Button.tsx': fbButton }, + Checkbox: { 'Checkbox.tsx': fbCheckbox }, + }); + }); + + it('prefers the code copy of a file over the hoisted one and keeps hoisted-only files', () => { + // A `fallbackCollapsed` block hoists only the visible window but carries the + // full fallback on `Code`; the full text is the dictionary the source needs. + const fbWindow = fb('window'); + const fbFull = fb('full'); + const fbStyles = fb('styles'); + const code: Code = { + Default: { + fileName: 'Button.tsx', + source: { hastCompressed: 'abc' }, + fallback: fbFull, + extraFiles: { 'styles.css': { source: { hastCompressed: 'def' } } }, + }, + }; + + expect( + resolveVariantFallbacks( + { Default: { 'Button.tsx': fbWindow, 'styles.css': fbStyles } }, + code, + ), + ).toEqual({ Default: { 'Button.tsx': fbFull, 'styles.css': fbStyles } }); + }); + + it('omits variants that have no fallbacks anywhere', () => { + const fbButton = fb('button'); + const code: Code = { + Button: { fileName: 'Button.tsx', source: { hastCompressed: 'abc' }, fallback: fbButton }, + Checkbox: { fileName: 'Checkbox.tsx', source: 'const checkbox = 1;' }, + Radio: 'const radio = 1;', + }; + + expect(resolveVariantFallbacks({ Switch: {} }, code)).toEqual({ + Button: { 'Button.tsx': fbButton }, + }); + }); + + it('resolves from the hoisted fallbacks alone when there is no code', () => { + const fbButton = fb('button'); + + expect(resolveVariantFallbacks({ Default: { 'Button.tsx': fbButton } }, undefined)).toEqual({ + Default: { 'Button.tsx': fbButton }, + }); + expect(resolveVariantFallbacks({}, undefined)).toEqual({}); + }); +}); diff --git a/packages/docs/src/CodeHighlighter/codeToFallbackProps.ts b/packages/docs/src/CodeHighlighter/codeToFallbackProps.ts index 1a0a230..5bb3261 100644 --- a/packages/docs/src/CodeHighlighter/codeToFallbackProps.ts +++ b/packages/docs/src/CodeHighlighter/codeToFallbackProps.ts @@ -289,6 +289,40 @@ export function deriveFallbacksFromCode( return Object.keys(fallbacks).length > 0 ? fallbacks : undefined; } +/** + * Resolve each variant's fallbacks from the two places one can cross the + * server→client boundary: the hoisted copy (from a `ContentLoading` component, + * which had it stripped off `Code`) and the variant's own `fallback` field on + * `Code` (present without a `ContentLoading`, or scattered back from the + * residual blob). For most files only one is populated. When both are — a + * `fallbackCollapsed` block hoists the *visible* window but scatters the *full* + * fallback onto `Code` — the `Code` copy must win, since the full text is the + * DEFLATE dictionary `hastCompressed` needs. So merge with the derived (`Code`) + * copy taking precedence. + * + * Kept per variant: `useCode` selects variants on its own, and variants often + * share file names, so a single file-name map would hand another variant's + * same-named file this variant's dictionary. Variants with no fallback in + * either place are omitted. + */ +export function resolveVariantFallbacks( + hoistedFallbackHasts: Record, + code: Code | undefined, +): Record { + const byVariant: Record = {}; + const names = new Set([...Object.keys(hoistedFallbackHasts), ...Object.keys(code ?? {})]); + for (const name of names) { + const merged = { + ...hoistedFallbackHasts[name], + ...deriveFallbacksFromCode(code, name), + }; + if (Object.keys(merged).length > 0) { + byVariant[name] = merged; + } + } + return byVariant; +} + /** * Strip `fallback` entries from a `Code` object and return the * stripped Code alongside the extracted fallbacks grouped by variant → fileName. diff --git a/packages/docs/src/CodeHighlighter/getPendingTransformedCode.test.ts b/packages/docs/src/CodeHighlighter/getPendingTransformedCode.test.ts new file mode 100644 index 0000000..f047436 --- /dev/null +++ b/packages/docs/src/CodeHighlighter/getPendingTransformedCode.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { getPendingTransformedCode } from './getPendingTransformedCode'; +import type { Code } from './types'; + +const hast = (text: string) => ({ + type: 'root' as const, + children: [{ type: 'text' as const, value: text }], +}); + +describe('getPendingTransformedCode', () => { + const loaded: Code = { + First: { fileName: 'First.tsx', source: 'const first = 1;' }, + Second: { fileName: 'Second.tsx', source: 'const second = 2;' }, + }; + const output: Code = { + First: { fileName: 'First.tsx', source: hast('first, with deltas') }, + Second: { fileName: 'Second.tsx', source: hast('second, with deltas') }, + }; + + it('is undefined before the first computation has finished', () => { + expect(getPendingTransformedCode(output, loaded, {})).toBeUndefined(); + }); + + it('keeps the finished output of variants whose content is unchanged, as the same or an equal object', () => { + const parsed: Code = { + First: { fileName: 'First.tsx', source: hast('first, parsed again') }, + Second: { fileName: 'Second.tsx', source: hast('second, parsed again') }, + }; + const nextLoaded: Code = { First: loaded.First, Second: structuredClone(loaded.Second) }; + + expect(getPendingTransformedCode(parsed, nextLoaded, { loaded, output })).toEqual(output); + }); + + it('shows the new parse of changed and new variants', () => { + const parsed: Code = { + First: { fileName: 'First.tsx', source: hast('first, parsed again') }, + Second: { fileName: 'Second.tsx', source: hast('second, changed') }, + Third: { fileName: 'Third.tsx', source: hast('third, new') }, + }; + const nextLoaded: Code = { + First: loaded.First, + Second: { fileName: 'Second.tsx', source: 'const second = 3;' }, + Third: { fileName: 'Third.tsx', source: 'const third = 3;' }, + }; + + expect(getPendingTransformedCode(parsed, nextLoaded, { loaded, output })).toEqual({ + First: output.First, + Second: parsed.Second, + Third: parsed.Third, + }); + }); +}); diff --git a/packages/docs/src/CodeHighlighter/getPendingTransformedCode.ts b/packages/docs/src/CodeHighlighter/getPendingTransformedCode.ts new file mode 100644 index 0000000..e2ba5ca --- /dev/null +++ b/packages/docs/src/CodeHighlighter/getPendingTransformedCode.ts @@ -0,0 +1,55 @@ +import type { Code } from './types'; + +/** + * The last finished transform-deltas computation: the loaded code it started + * from and the code it produced (highlighted, with the deltas embedded). + */ +export interface FinishedTransformedCode { + loaded?: Code; + output?: Code; +} + +/** + * Whether two loaded variants hold the same content. A new object with equal + * content counts as the same, so a re-parse that changes nothing (a `refresh()` + * returning the same sources) keeps the finished output. + */ +function isSameVariantContent(current: Code[string], previous: Code[string]): boolean { + if (current === previous) { + return true; + } + if (!current || !previous || typeof current !== 'object' || typeof previous !== 'object') { + return false; + } + return JSON.stringify(current) === JSON.stringify(previous); +} + +/** + * The code to show while the transform deltas for a new `parsedCode` are still + * being computed. A variant whose loaded content is unchanged keeps the finished + * output, deltas included, so its transformed views stay highlighted. A changed + * or new variant shows its new parse: highlighted, with its transforms applying + * to its text (as plain text) until the deltas land. So the code shown is never + * older than the loaded code. Returns `undefined` before the first computation + * has finished (the first load), when there's no highlighted output to keep. + */ +export function getPendingTransformedCode( + parsedCode: Code, + loadedCode: Code | undefined, + finished: FinishedTransformedCode, +): Code | undefined { + const { loaded, output } = finished; + if (!loaded || !output) { + return undefined; + } + const pending: Code = {}; + for (const [variantName, variant] of Object.entries(parsedCode)) { + const finishedVariant = output[variantName]; + pending[variantName] = + finishedVariant !== undefined && + isSameVariantContent(loadedCode?.[variantName], loaded[variantName]) + ? finishedVariant + : variant; + } + return pending; +} diff --git a/packages/docs/src/CodeHighlighter/prepareInitialSource.test.tsx b/packages/docs/src/CodeHighlighter/prepareInitialSource.test.tsx index 2d376ce..abf7795 100644 --- a/packages/docs/src/CodeHighlighter/prepareInitialSource.test.tsx +++ b/packages/docs/src/CodeHighlighter/prepareInitialSource.test.tsx @@ -28,58 +28,54 @@ import { variantHasLayoutShift } from '../useCode/sourceLineCounts'; import { createEnhanceCodeEmphasis } from '../pipeline/enhanceCodeEmphasis'; import { prepareInitialSource } from './prepareInitialSource'; +/** A root with one frame per entry of `lineTexts`, each holding a single line. */ function framedRoot( - lineText: string, + lineTexts: string | string[], counts: { totalLines: number; focusedLines: number; collapsible?: boolean } = { totalLines: 1, focusedLines: 1, collapsible: false, }, ): HastRoot { + const lines = typeof lineTexts === 'string' ? [lineTexts] : lineTexts; return { type: 'root', data: counts, - children: [ - { - type: 'element', - tagName: 'span', - properties: { className: 'frame' }, - data: { fallback: [{ type: 'text', value: lineText }] } as HastElement['data'], - children: [ - { - type: 'element', - tagName: 'span', - properties: { className: 'line', dataLn: 1 }, - children: [{ type: 'text', value: lineText }], - }, - ], - }, - ], + children: lines.map((lineText, index) => ({ + type: 'element', + tagName: 'span', + properties: { className: 'frame' }, + data: { fallback: [{ type: 'text', value: lineText }] } as HastElement['data'], + children: [ + { + type: 'element', + tagName: 'span', + properties: { className: 'line', dataLn: index + 1 }, + children: [{ type: 'text', value: lineText }], + }, + ], + })), }; } /** - * Build a `{ hastCompressed }` source the way the loader does: derive the root - * fallback, strip the per-frame `data.fallback`, and compress with the fallback - * text as the DEFLATE dictionary. Decoding REQUIRES the matching fallback — a - * missing/mismatched one throws "invalid distance". Made long enough that the - * residual blob clears `FALLBACK_COMPRESSION_MIN_BYTES` (so the bug's wire-code - * path is exercised, not the small-residual inline path). + * Compress a framed root the way the loader does: derive the root fallback, + * strip the per-frame `data.fallback`, and compress with the fallback text as + * the DEFLATE dictionary. Decoding REQUIRES the matching fallback — a + * missing/mismatched one throws "invalid distance". */ -function buildCompressedVariant( - seed: string, - counts?: { totalLines: number; focusedLines: number; collapsible?: boolean }, -): { +function compressFramedRoot(root: HastRoot): { source: { hastCompressed: string }; fallback: FallbackNode[]; fallbackCritical: { [frameIndex: number]: FallbackNode }; } { - const root = framedRoot(`const ${seed} = "${'x'.repeat(200)}";`, counts); const fallback = buildRootFallback(root); // The sparse highlighted-visible companion the loader bakes alongside `fallback`. const fallbackCritical = buildCriticalFallback(root, getInitialVisibleFrames(root, false)); const stripped = JSON.parse(JSON.stringify(root)) as HastRoot; - delete (stripped.children[0] as HastElement).data!.fallback; + for (const frame of stripped.children) { + delete (frame as HastElement).data!.fallback; + } return { // Fresh object per call so the decode WeakMap never bridges cases. source: { hastCompressed: compressHast(JSON.stringify(stripped), fallbackToText(fallback)) }, @@ -88,6 +84,19 @@ function buildCompressedVariant( }; } +/** + * Build a `{ hastCompressed }` source with its fallback (see + * `compressFramedRoot`). Made long enough that the residual blob clears + * `FALLBACK_COMPRESSION_MIN_BYTES` (so the bug's wire-code path is exercised, + * not the small-residual inline path). + */ +function buildCompressedVariant( + seed: string, + counts?: { totalLines: number; focusedLines: number; collapsible?: boolean }, +) { + return compressFramedRoot(framedRoot(`const ${seed} = "${'x'.repeat(200)}";`, counts)); +} + function ContentLoading(): null { return null; } @@ -246,6 +255,122 @@ describe('prepareInitialSource residual round-trip', () => { }); }); +describe('prepareInitialSource fallbackCollapsed without a residual blob', () => { + const collapsedCounts = { totalLines: 2, focusedLines: 1, collapsible: true }; + const loadingSourceOf = (fallback: React.ReactNode): FallbackNode[] | undefined => + (fallback as React.ReactElement<{ source?: FallbackNode[] }>).props.source; + + it('keeps the rendered file decodable on a client render (compressResidual: false)', () => { + // Two frames with no emphasis: the collapsed window is only the first frame, + // so its text is not the dictionary the whole source was compressed with. + const variant = compressFramedRoot( + framedRoot( + [`const button = "${'x'.repeat(200)}";`, `const checkbox = "${'y'.repeat(200)}";`], + collapsedCounts, + ), + ); + const code = { + Default: { + fileName: 'Button.tsx', + source: variant.source, + fallback: variant.fallback, + // The stored counts the loader ships, so nothing decodes on the server + // (which would warm the decode cache with the full dictionary). + ...collapsedCounts, + }, + } as unknown as Code; + + const { fallback, codeForClient, residualFallbacks } = prepareInitialSource({ + code, + initialVariant: 'Default', + initialFilename: 'Button.tsx', + initialSource: variant.source, + ContentLoading, + Content, + slug: 'slug', + name: 'name', + fallbackCollapsed: true, + compressResidual: false, + }); + + expect(residualFallbacks).toBeUndefined(); + // The loading UI still paints only the collapsed window. + expect(fallbackToText(loadingSourceOf(fallback)!)).toBe( + fallbackToText(variant.fallback.slice(0, 1)), + ); + + // The client decodes with the variant's own fallback on the code, else the hoist. + const dictionary = fallbackOf(codeForClient, 'Default') ?? loadingSourceOf(fallback); + expect(() => decodeHastSource(variant.source, dictionary)).not.toThrow(); + }); + + it('keeps the rendered file decodable when the residual is too small to compress', () => { + const variant = compressFramedRoot( + framedRoot(['const a = 1;', 'const b = 2;'], collapsedCounts), + ); + const code = { + Default: { + fileName: 'Button.tsx', + source: variant.source, + fallback: variant.fallback, + // The stored counts the loader ships, so nothing decodes on the server + // (which would warm the decode cache with the full dictionary). + ...collapsedCounts, + }, + } as unknown as Code; + + const { fallback, codeForClient, residualFallbacks } = prepareInitialSource({ + code, + initialVariant: 'Default', + initialFilename: 'Button.tsx', + initialSource: variant.source, + ContentLoading, + Content, + slug: 'slug', + name: 'name', + fallbackCollapsed: true, + }); + + expect(residualFallbacks).toBeUndefined(); + const dictionary = fallbackOf(codeForClient, 'Default') ?? loadingSourceOf(fallback); + expect(() => decodeHastSource(variant.source, dictionary)).not.toThrow(); + }); + + it('still defers the full fallback into the blob when it is worth compressing', () => { + const variant = compressFramedRoot( + framedRoot( + [`const button = "${'x'.repeat(200)}";`, `const checkbox = "${'y'.repeat(200)}";`], + collapsedCounts, + ), + ); + const code = { + Default: { + fileName: 'Button.tsx', + source: variant.source, + fallback: variant.fallback, + // The stored counts the loader ships, so nothing decodes on the server + // (which would warm the decode cache with the full dictionary). + ...collapsedCounts, + }, + } as unknown as Code; + + const { codeForClient, residualFallbacks } = prepareInitialSource({ + code, + initialVariant: 'Default', + initialFilename: 'Button.tsx', + initialSource: variant.source, + ContentLoading, + Content, + slug: 'slug', + name: 'name', + fallbackCollapsed: true, + }); + + expect(residualFallbacks).toBeDefined(); + expect(fallbackOf(codeForClient, 'Default')).toBeUndefined(); + }); +}); + describe('prepareInitialSource line counts', () => { it('reads STORED variant line counts instead of decompressing the source', () => { // The loader now hoists the window counts off `root.data` onto the variant diff --git a/packages/docs/src/CodeHighlighter/prepareInitialSource.tsx b/packages/docs/src/CodeHighlighter/prepareInitialSource.tsx index d2ffaaa..d13db34 100644 --- a/packages/docs/src/CodeHighlighter/prepareInitialSource.tsx +++ b/packages/docs/src/CodeHighlighter/prepareInitialSource.tsx @@ -17,6 +17,7 @@ import { extractResidualFallbacks, mergeResidualFallbacks, residualDictionaryText, + scatterResidualFallbacks, } from './fallbackCompression'; import { replaceUrlPrefix } from '../pipeline/loaderUtils/applyUrlPrefix'; import { getVariantFileLineCounts } from '../useCode/sourceLineCounts'; @@ -325,6 +326,15 @@ export function prepareInitialSource( ); codeForClient = residualFallbacks ? wireCode : strippedCode; } + // With no blob (a client render, or a residual too small to compress), a + // `fallbackCollapsed` block would leave the rendered files' full fallbacks + // nowhere: the hoist is only each file's collapsed window, which can't decode + // its `hastCompressed` source. Keep them inline on `codeForClient` instead — + // the same shape the client produces after decompressing the blob, and the + // variant's own fallback wins over the hoist on the client. + if (!residualFallbacks && effectiveFallbackCollapsed) { + codeForClient = scatterResidualFallbacks(codeForClient, allFallbackHasts); + } // Get the component for the selected variant const component = props.components?.[initialVariant]; diff --git a/packages/docs/src/CodeHighlighter/user.spec.tsx b/packages/docs/src/CodeHighlighter/user.spec.tsx index 9fceb0b..a12bfe2 100644 --- a/packages/docs/src/CodeHighlighter/user.spec.tsx +++ b/packages/docs/src/CodeHighlighter/user.spec.tsx @@ -33,6 +33,8 @@ import { CodeContext } from '../CodeProvider/CodeContext'; import { parseControlledCode } from './parseControlledCode'; import { createParseSource } from '../pipeline/parseSource'; import { preloadSourceEditingEngine } from '../useCode/useSourceEditing'; +import { preloadTransformEngine } from '../useCode/transformEngineCache'; +import { createCompressedFile } from '../pipeline/hastUtils/hastCompression.testUtils'; import type { Code, ContentProps, ControlledCode, HastRoot, ParseSource } from './types'; let parseSource: ParseSource; @@ -256,6 +258,173 @@ describe('CodeHighlighter rendering', () => { await waitFor(() => expect(screen.getByTestId('file').textContent).toBe('app.js')); }); + it('offers only the transforms the selected variant declares', async () => { + // Only the first variant declares a `js` transform, so the toggle must go + // away when the user switches to the second one. + window.localStorage.clear(); + const code = { + Typed: { + fileName: 'Button.tsx', + source: highlighted('const button: number = 1;'), + transforms: { js: { fileName: 'Button.jsx', hasDelta: true } }, + }, + Plain: { fileName: 'Button.js', source: highlighted('const button = 1;') }, + } as unknown as Code; + + render( + + + , + ); + + expect(screen.getByTestId('transform:js')).toBeTruthy(); + + act(() => { + screen.getByTestId('variant:Plain').click(); + }); + await waitFor(() => + expect(screen.getByTestId('code').textContent).toContain('const button = 1;'), + ); + expect(screen.queryByTestId('transform:js')).toBeNull(); + + act(() => { + screen.getByTestId('variant:Typed').click(); + }); + await waitFor(() => + expect(screen.getByTestId('code').textContent).toContain('const button: number = 1;'), + ); + expect(screen.getByTestId('transform:js')).toBeTruthy(); + }); + + it('offers only the transforms of the variant it renders when the highlighter is given a variant', async () => { + // The highlighter is told `Typed` is the current variant, but the content + // selects and renders its own (`Plain`, the first). Only `Typed` declares a + // `js` transform, so no toggle may show until the user switches to it. + window.localStorage.clear(); + const code = { + Plain: { fileName: 'Button.js', source: highlighted('const button = 1;') }, + Typed: { + fileName: 'Button.tsx', + source: highlighted('const button: number = 1;'), + transforms: { js: { fileName: 'Button.jsx', hasDelta: true } }, + }, + } as unknown as Code; + + render( + + + , + ); + + expect(screen.getByTestId('variant').textContent).toBe('Plain'); + expect(screen.queryByTestId('transform:js')).toBeNull(); + + act(() => { + screen.getByTestId('variant:Typed').click(); + }); + await waitFor(() => + expect(screen.getByTestId('code').textContent).toContain('const button: number = 1;'), + ); + expect(screen.getByTestId('transform:js')).toBeTruthy(); + }); + + it('offers only the transforms of the variant it renders when a controller selects another variant', async () => { + // A controller that only selects (owns no code) points the highlighter at + // `Typed`, while the content renders its own first variant, `Plain`. + window.localStorage.clear(); + const code = { + Plain: { fileName: 'Button.js', source: highlighted('const button = 1;') }, + Typed: { + fileName: 'Button.tsx', + source: highlighted('const button: number = 1;'), + transforms: { js: { fileName: 'Button.jsx', hasDelta: true } }, + }, + } as unknown as Code; + + render( + + + + + , + ); + + expect(screen.getByTestId('variant').textContent).toBe('Plain'); + expect(screen.queryByTestId('transform:js')).toBeNull(); + + act(() => { + screen.getByTestId('variant:Typed').click(); + }); + await waitFor(() => + expect(screen.getByTestId('code').textContent).toContain('const button: number = 1;'), + ); + expect(screen.getByTestId('transform:js')).toBeTruthy(); + }); + + it('keeps a transform applied when the user switches to a variant whose file has the same name', async () => { + // Production payloads compress each file with its own `fallback` text as the + // DEFLATE dictionary. Both variants name their file `app.ts`, so the second + // must decode with ITS dictionary, not the first variant's same-named one. + // Start from no stored transform preference so the toggle is exercised. + window.localStorage.clear(); + await preloadTransformEngine(); + const compressed = (text: string) => ({ + ...createCompressedFile(text, highlighted(text)), + totalLines: 1, + }); + const toJs = (text: string) => ({ + js: { + fileName: 'app.js', + delta: { + children: { + _t: 'a', + 0: { children: { _t: 'a', 0: { children: { _t: 'a', 0: { value: [text] } } } } }, + }, + }, + }, + }); + const code = { + Plain: { + fileName: 'app.ts', + ...compressed('const answer: number = 42;'), + transforms: toJs('const answer = 42;'), + }, + Doubled: { + fileName: 'app.ts', + ...compressed('const answer: number = 21 * 2;'), + transforms: toJs('const answer = 21 * 2;'), + }, + } as unknown as Code; + + render( + + + , + ); + + expect(screen.getByTestId('transform').textContent).toBe('none'); + act(() => { + screen.getByTestId('transform:js').click(); + }); + await waitFor(() => + expect(screen.getByTestId('code').textContent).toContain('const answer = 42;'), + ); + + act(() => { + screen.getByTestId('variant:Doubled').click(); + }); + + // The incoming variant renders with the transform still applied. + await waitFor(() => expect(screen.getByTestId('code').textContent).toContain('21 * 2')); + expect(screen.getByTestId('code').textContent).toContain('const answer = 21 * 2;'); + expect(screen.getByTestId('file').textContent).toBe('app.js'); + }); + it('re-highlights a controlled file after the user edits it', async () => { const code = { Default: { fileName: 'app.js', source: highlighted('const answer = 42;') }, diff --git a/packages/docs/src/pipeline/hastUtils/hastCompression.testUtils.ts b/packages/docs/src/pipeline/hastUtils/hastCompression.testUtils.ts new file mode 100644 index 0000000..242a32d --- /dev/null +++ b/packages/docs/src/pipeline/hastUtils/hastCompression.testUtils.ts @@ -0,0 +1,36 @@ +/** + * Test fixtures for `hastCompression`. Kept in a sibling file so the + * production module's public surface stays free of helpers that only tests + * should reach. + */ +import type { Root as HastRoot } from 'hast'; +import { compressHast } from './hastCompress'; +import { fallbackToText } from './fallbackFormat'; +import type { FallbackNode } from './fallbackFormat'; + +/** + * Builds a `hastCompressed` file the way production payloads are compressed: + * the DEFLATE dictionary is the file's own `fallback` text, so the source + * decodes only with that same `fallback`. `root` is the HAST that gets + * compressed and defaults to a single `` holding `text`. + */ +export function createCompressedFile( + text: string, + root: HastRoot = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'span', + properties: {}, + children: [{ type: 'text', value: text }], + }, + ], + }, +): { source: { hastCompressed: string }; fallback: FallbackNode[] } { + const fallback: FallbackNode[] = [text]; + return { + source: { hastCompressed: compressHast(JSON.stringify(root), fallbackToText(fallback)) }, + fallback, + }; +} diff --git a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/applyCodeTransform.test.ts b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/applyCodeTransform.test.ts index 4576ff8..2caedc4 100644 --- a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/applyCodeTransform.test.ts +++ b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/applyCodeTransform.test.ts @@ -273,6 +273,46 @@ describe('applyCodeTransform', () => { }); }); + describe('line deltas on highlighted sources', () => { + it('applies a line delta to the text of a highlighted source whose node delta is not computed yet', async () => { + // Right after a re-parse, a variant has a highlighted source but still the + // line delta `transformSource` computed; its node delta comes later. + const source = 'const typed: number = 1;\nexport default typed;\n'; + const transforms = (await transformSource(source, 'Typed.tsx', [ + { + extensions: ['tsx'], + transformer: async (text) => ({ + js: { source: text.replace(': number', ''), fileName: 'Typed.jsx' }, + }), + }, + ]))!; + const highlighted: HastRoot = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'span', + properties: { className: ['line'] }, + children: [ + { + type: 'element', + tagName: 'span', + properties: { className: ['pl-k'] }, + children: [{ type: 'text', value: 'const' }], + }, + { type: 'text', value: ' typed: number = 1;' }, + ], + }, + { type: 'text', value: '\nexport default typed;\n' }, + ], + }; + + expect(applyCodeTransform(highlighted, transforms, 'js')).toBe( + 'const typed = 1;\nexport default typed;\n', + ); + }); + }); + describe('per-frame fallback regeneration', () => { // `diffHast` records a frame the transform rewrote as a content-less // *delete* of its `data.fallback` (the source keeps its own, so the delta diff --git a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/applyCodeTransformWithComments.ts b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/applyCodeTransformWithComments.ts index 413e172..b5ed935 100644 --- a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/applyCodeTransformWithComments.ts +++ b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/applyCodeTransformWithComments.ts @@ -198,6 +198,65 @@ function regenerateMissingFrameFallbacksInPlace( } } +/** + * Whether `delta` is a line-array delta, as `transformSource` computes from the + * source's lines, rather than a node-based delta for a HAST root (an object). + */ +function isLineDelta(delta: object): boolean { + // jsondiffpatch marks an array delta with `_t: 'a'`. + const arrayMarker = '_t'; + return (delta as Record)[arrayMarker] === 'a'; +} + +/** + * The text of a HAST tree, as its text nodes hold it. + */ +function hastText(node: Nodes): string { + if (node.type === 'text') { + return node.value; + } + return 'children' in node ? node.children.map(hastText).join('') : ''; +} + +/** + * Applies a transform's line-array delta to `sourceText`, returning the + * transformed text and `comments` shifted onto it. + */ +function applyLineDelta( + sourceText: string, + transform: Transforms[string], + transformKey: string, + comments: SourceComments | undefined, +): { source: string; comments?: SourceComments } { + const sourceLines = sourceText.split('\n'); + const patched = patch(sourceLines, transform.delta!); + + if (!Array.isArray(patched)) { + throw new Error(`Patch for transform "${transformKey}" did not return an array`); + } + + // String transforms only wipe lines (never insert/reorder), so the + // 1-indexed mapping is identity for surviving non-empty lines and + // dropped for wiped ones. Build the map by walking both arrays. + // If the transformer supplied an explicit `comments` map for this + // entry, use it verbatim instead of auto-shifting. + let remappedComments: SourceComments | undefined; + if (transform.comments) { + remappedComments = transform.comments; + } else if (comments) { + const lineMap = new Map(); + const limit = Math.min(sourceLines.length, patched.length); + for (let i = 0; i < limit; i += 1) { + if (patched[i] !== '' || sourceLines[i] === '') { + lineMap.set(i + 1, i + 1); + } + } + remappedComments = remapComments(comments, lineMap); + } + + return { source: patched.join('\n'), comments: remappedComments }; +} + /** * Applies a specific transform to a variant source and returns the transformed source * along with a remapped copy of the supplied `comments` map (when any) shifted to @@ -254,33 +313,7 @@ export function applyCodeTransformWithComments( return { source, comments: transform.comments ?? comments }; } // For string sources, deltas are typically line-array based (from transformSource) - const sourceLines = source.split('\n'); - const patched = patch(sourceLines, transform.delta); - - if (!Array.isArray(patched)) { - throw new Error(`Patch for transform "${transformKey}" did not return an array`); - } - - // String transforms only wipe lines (never insert/reorder), so the - // 1-indexed mapping is identity for surviving non-empty lines and - // dropped for wiped ones. Build the map by walking both arrays. - // If the transformer supplied an explicit `comments` map for this - // entry, use it verbatim instead of auto-shifting. - let remappedComments: SourceComments | undefined; - if (transform.comments) { - remappedComments = transform.comments; - } else if (comments) { - const lineMap = new Map(); - const limit = Math.min(sourceLines.length, patched.length); - for (let i = 0; i < limit; i += 1) { - if (patched[i] !== '' || sourceLines[i] === '') { - lineMap.set(i + 1, i + 1); - } - } - remappedComments = remapComments(comments, lineMap); - } - - return { source: patched.join('\n'), comments: remappedComments }; + return applyLineDelta(source, transform, transformKey, comments); } // For Hast node sources, deltas are typically node-based (from diffHast). @@ -311,6 +344,14 @@ export function applyCodeTransformWithComments( const embeddedTransforms = sourceRoot.data?.transforms; const delta = transform.delta ?? embeddedTransforms?.[transformKey]?.delta; + // A line-array delta (from `transformSource`) whose node-based counterpart + // hasn't been computed yet (the highlighted source was just parsed, and + // `computeHastDeltas` is still running) applies to the source's text, giving + // the transformed code as plain text until the highlighted delta lands. + if (transform.delta && isLineDelta(transform.delta)) { + return applyLineDelta(hastText(sourceRoot), transform, transformKey, comments); + } + if (!delta) { // Rename-only transform (manifest entry with `hasDelta: false`): no // delta exists on the manifest entry or embedded in the source's diff --git a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.test.ts b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.test.ts index b2f4b2f..e0ede36 100644 --- a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.test.ts +++ b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { Code, VariantCode } from '../../CodeHighlighter/types'; -import { getAvailableTransforms } from './getAvailableTransforms'; +import { getAvailableTransforms, hasAnyVariantTransforms } from './getAvailableTransforms'; const createVariantCode = (overrides: Partial = {}): VariantCode => ({ fileName: 'test.js', @@ -151,3 +151,36 @@ describe('getAvailableTransforms', () => { expect(transforms).toEqual(['js']); }); }); + +describe('hasAnyVariantTransforms', () => { + it('is true when a variant other than the first has a transform', () => { + const code: Code = { + Plain: createVariantCode({ source: 'code' }), + Typed: createVariantCode({ + fileName: 'test.ts', + source: 'code', + transforms: { js: { hasDelta: true, fileName: 'test.js' } }, + }), + }; + + expect(hasAnyVariantTransforms(code)).toBe(true); + }); + + it('is false when no variant has a transform with a delta', () => { + const code: Code = { + Plain: createVariantCode({ source: 'code' }), + Renamed: createVariantCode({ + fileName: 'test.ts', + source: 'code', + transforms: { js: { hasDelta: false, fileName: 'test.js' } }, + }), + Loading: 'https://example.com/Loading.js', + }; + + expect(hasAnyVariantTransforms(code)).toBe(false); + }); + + it('is false without code', () => { + expect(hasAnyVariantTransforms(undefined)).toBe(false); + }); +}); diff --git a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.ts b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.ts index f7fa11d..cfc0a9d 100644 --- a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.ts +++ b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.ts @@ -53,3 +53,14 @@ export function getAvailableTransforms( return Array.from(transforms); } + +/** + * Whether any variant of `code` has an available transform (see + * `getAvailableTransforms`). + */ +export function hasAnyVariantTransforms(code: Code | undefined): boolean { + return ( + !!code && + Object.keys(code).some((variantName) => getAvailableTransforms(code, variantName).length > 0) + ); +} diff --git a/packages/docs/src/useCode/SourceEditingEngine.test.ts b/packages/docs/src/useCode/SourceEditingEngine.test.ts new file mode 100644 index 0000000..129af81 --- /dev/null +++ b/packages/docs/src/useCode/SourceEditingEngine.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { toControlledCode } from './SourceEditingEngine'; +import { stringOrHastToString } from '../pipeline/hastUtils'; +import { createCompressedFile } from '../pipeline/hastUtils/hastCompression.testUtils'; +import type { Code } from '../CodeHighlighter/types'; + +describe('toControlledCode', () => { + it('converts plain string sources and string extra files', () => { + const code: Code = { + Default: { + fileName: 'Button.tsx', + source: 'const button = 1;', + extraFiles: { 'styles.css': '.button {}' }, + }, + }; + + const controlled = toControlledCode(code, 'Default', undefined, stringOrHastToString); + + expect(controlled.Default?.source).toBe('const button = 1;'); + expect(controlled.Default?.extraFiles?.['styles.css']?.source).toBe('.button {}'); + }); + + describe('compressed sources', () => { + it('decodes the main file with its own fallback when the fallbacks map holds a same-named file of another variant', () => { + const first = createCompressedFile('const first: number = 1;'); + const second = createCompressedFile('const second: number = 2;'); + const code: Code = { + Second: { fileName: 'Button.tsx', ...second }, + }; + + const controlled = toControlledCode( + code, + 'Second', + { 'Button.tsx': first.fallback }, + stringOrHastToString, + ); + + expect(controlled.Second?.source).toBe('const second: number = 2;'); + }); + + it('decodes an extra file with its own fallback when the fallbacks map holds a same-named file of another variant', () => { + const firstStyles = createCompressedFile('.first { color: red; }'); + const secondStyles = createCompressedFile('.second { color: blue; }'); + const code: Code = { + Second: { + fileName: 'Button.tsx', + source: 'const second = 2;', + extraFiles: { 'styles.css': secondStyles }, + }, + }; + + const controlled = toControlledCode( + code, + 'Second', + { 'styles.css': firstStyles.fallback }, + stringOrHastToString, + ); + + expect(controlled.Second?.extraFiles?.['styles.css']?.source).toBe( + '.second { color: blue; }', + ); + }); + + it('decodes the active variant with the fallbacks map when its own fallback was stripped', () => { + const button = createCompressedFile('const button: number = 1;'); + const styles = createCompressedFile('.button { color: red; }'); + const code: Code = { + Default: { + fileName: 'Button.tsx', + source: button.source, + extraFiles: { 'styles.css': { source: styles.source } }, + }, + }; + + const controlled = toControlledCode( + code, + 'Default', + { 'Button.tsx': button.fallback, 'styles.css': styles.fallback }, + stringOrHastToString, + ); + + expect(controlled.Default?.source).toBe('const button: number = 1;'); + expect(controlled.Default?.extraFiles?.['styles.css']?.source).toBe( + '.button { color: red; }', + ); + }); + }); +}); diff --git a/packages/docs/src/useCode/SourceEditingEngine.ts b/packages/docs/src/useCode/SourceEditingEngine.ts index adc2520..1fa6259 100644 --- a/packages/docs/src/useCode/SourceEditingEngine.ts +++ b/packages/docs/src/useCode/SourceEditingEngine.ts @@ -436,11 +436,14 @@ export function toControlledCode( // The per-file `fallback` is the DEFLATE dictionary for a `hastCompressed` // source. It rides on the `VariantCode` in the no-`ContentLoading` path; on // the `ContentLoading` path the active variant's fallback is stripped off - // `Code` and lives in `context.fallbacks` (`activeFallbacks`) instead — so - // prefer that for the active variant, falling back to the variant's field. + // `Code` and is hoisted into the highlighter context instead, which `useCode` + // resolves for this variant (`activeFallbacks`). The file's own `fallback` + // wins: it is the dictionary its source was compressed with, while the map + // is keyed by file name alone and so can hold a same-named file of another + // variant (and a `fallbackCollapsed` hoist is only the visible window). const variantFallbacks = key === activeVariantKey ? activeFallbacks : undefined; const mainFallback = - (variant.fileName ? variantFallbacks?.[variant.fileName] : undefined) ?? variant.fallback; + variant.fallback ?? (variant.fileName ? variantFallbacks?.[variant.fileName] : undefined); const source = variant.source != null ? toString(variant.source, mainFallback) : variant.source; let extraFiles: ControlledVariantExtraFiles | undefined; @@ -450,7 +453,7 @@ export function toControlledCode( if (typeof entry === 'string') { extraFiles[fileName] = { source: entry, ...analyzeSource(entry) }; } else { - const entryFallback = variantFallbacks?.[fileName] ?? entry.fallback; + const entryFallback = entry.fallback ?? variantFallbacks?.[fileName]; const extraSource = entry.source != null ? toString(entry.source, entryFallback) : null; extraFiles[fileName] = { source: extraSource, diff --git a/packages/docs/src/useCode/TransformEngine.test.ts b/packages/docs/src/useCode/TransformEngine.test.ts index a3d44a5..b1baf5a 100644 --- a/packages/docs/src/useCode/TransformEngine.test.ts +++ b/packages/docs/src/useCode/TransformEngine.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, vi } from 'vitest'; +import type { Delta } from 'jsondiffpatch'; import { createTransformedFiles, applyTransformToSource } from './TransformEngine'; import type { TransformRuntimeDeps } from './TransformEngine'; import { decodeHastSource } from '../pipeline/loadIsomorphicCodeVariant/decodeHastSource'; import { frameFallbackFromSpans } from '../pipeline/hastUtils'; +import { createCompressedFile } from '../pipeline/hastUtils/hastCompression.testUtils'; import type { VariantCode } from '../CodeHighlighter/types'; // Real hast helpers the engine takes injected (no mocks, per convention 3.5). @@ -354,5 +356,86 @@ describe('TransformEngine', () => { consoleSpy.mockRestore(); }); + + describe('compressed sources', () => { + // A `hastCompressed` source decodes only with the dictionary its own + // `fallback` text built, so each file here is compressed with its own + // (`createCompressedFile`). + + function replaceText(text: string): Delta { + return { + children: { + _t: 'a', + 0: { children: { _t: 'a', 0: { value: [text] } } }, + }, + }; + } + + function textOf(source: unknown) { + return (source as { children: { children: { value: string }[] }[] }).children[0].children[0] + .value; + } + + it('decodes a variant with its own dictionary when fallbacks of another variant share the file name', () => { + const first = createCompressedFile('const first: number = 1;'); + const second = createCompressedFile('const second: number = 2;'); + const secondVariant: VariantCode = { + fileName: 'Button.tsx', + ...second, + transforms: { js: { delta: replaceText('const second = 2;'), fileName: 'Button.jsx' } }, + }; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // The per-file map handed in belongs to the first variant. + const result = createTransformedFiles(secondVariant, 'js', deps, { + 'Button.tsx': first.fallback, + }); + + const errors = [...errorSpy.mock.calls]; + errorSpy.mockRestore(); + expect(errors).toEqual([]); + expect(result!.files.map((file) => file.name)).toEqual(['Button.jsx']); + expect(textOf(result!.files[0].source)).toBe('const second = 2;'); + }); + + it('decodes an extra file with its own dictionary when fallbacks of another variant share the file name', () => { + const firstStyles = createCompressedFile('.first { color: red; }'); + const secondStyles = createCompressedFile('.second { color: blue; }'); + const secondVariant: VariantCode = { + fileName: 'Button.tsx', + source: 'const second = 2;', + extraFiles: { + 'styles.css': { + ...secondStyles, + transforms: { js: { delta: replaceText('.second {}') } }, + }, + }, + }; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = createTransformedFiles(secondVariant, 'js', deps, { + 'styles.css': firstStyles.fallback, + }); + + const errors = [...errorSpy.mock.calls]; + errorSpy.mockRestore(); + expect(errors).toEqual([]); + expect(result!.files.map((file) => file.name)).toEqual(['Button.tsx', 'styles.css']); + expect(textOf(result!.files[1].source)).toBe('.second {}'); + }); + + it('uses the fallbacks map when the variant carries no dictionary of its own', () => { + const { source, fallback } = createCompressedFile('const only: number = 1;'); + const variant: VariantCode = { + fileName: 'Button.tsx', + source, + transforms: { js: { delta: replaceText('const only = 1;'), fileName: 'Button.jsx' } }, + }; + + const result = createTransformedFiles(variant, 'js', deps, { 'Button.tsx': fallback }); + + expect(textOf(result!.files[0].source)).toBe('const only = 1;'); + }); + }); }); }); diff --git a/packages/docs/src/useCode/TransformEngine.ts b/packages/docs/src/useCode/TransformEngine.ts index 2a9fbe8..55e0422 100644 --- a/packages/docs/src/useCode/TransformEngine.ts +++ b/packages/docs/src/useCode/TransformEngine.ts @@ -114,7 +114,9 @@ export function createTransformedFiles( // Per-file DEFLATE dictionaries hoisted from a `ContentLoading` component. // A file's fallback may live here (hoisted) instead of on the variant // (stripped) — applying a transform must decode `hastCompressed`, so resolve - // from both, preferring the hoisted copy. + // from both. The file's own `fallback` wins: it is the dictionary its source + // was compressed with, while this map is keyed by file name alone and so can + // hold a same-named file of another variant. fallbacks?: Fallbacks, ): TransformedFiles | undefined { // Only create transformed files when there's actually a transform selected @@ -171,8 +173,7 @@ export function createTransformedFiles( selectedTransform, deps, selectedVariant.comments, - (selectedVariant.fileName ? fallbacks?.[selectedVariant.fileName] : undefined) ?? - selectedVariant.fallback, + selectedVariant.fallback ?? fallbacks?.[selectedVariant.fileName], ); const fileName = selectedVariant.fileName; @@ -226,8 +227,8 @@ export function createTransformedFiles( selectedTransform, deps, fileComments, - fallbacks?.[extraFileName] ?? - (typeof fileData === 'object' ? fileData.fallback : undefined), + (typeof fileData === 'object' ? fileData.fallback : undefined) ?? + fallbacks?.[extraFileName], ); transformedSource = result.source; transformedComments = result.comments; diff --git a/packages/docs/src/useCode/useCode.ts b/packages/docs/src/useCode/useCode.ts index cda7c6c..337108e 100644 --- a/packages/docs/src/useCode/useCode.ts +++ b/packages/docs/src/useCode/useCode.ts @@ -470,7 +470,9 @@ export function useCode( // `highlightAfter === 'init'` bypass that prevents a visible flash // of unhighlighted code on first-paint variant swaps. const shouldHighlight = shouldHighlightForRender({ - deferHighlight: context?.deferHighlight, + // The render-side gate, not the swap commit gate: the code published while + // transform deltas are computed is already highlighted where it can be. + deferHighlight: context?.deferHighlightRender ?? context?.deferHighlight, highlightReady: context?.highlightReady, pendingBootstrap: variantSelection.pendingBootstrap, highlightAfter: context?.highlightAfter, @@ -487,12 +489,19 @@ export function useCode( const renderedVariantKey = variantSelection.committedVariantKey || variantSelection.selectedVariantKey; + // Per-file DEFLATE dictionaries for the rendered variant. The variant is + // selected here rather than by the highlighter, and variants often share file + // names — so read the rendered variant's entry, or a same-named file would + // decode with another variant's dictionary. + const fallbacks = context?.variantFallbacks?.[renderedVariantKey]; + // Sub-hook: Transform Management const transformManagement = useTransformManagement({ context, effectiveCode, selectedVariantKey: renderedVariantKey, selectedVariant: renderedVariant, + fallbacks, initialTransform, transformDelay, transformLayoutShift, @@ -506,6 +515,7 @@ export function useCode( selectedVariantKey: renderedVariantKey, effectiveCode, selectedVariant: renderedVariant, + fallbacks, disabled, }); @@ -629,7 +639,7 @@ export function useCode( saveVariantToLocalStorage: variantSelection.saveVariantToLocalStorage, hashVariant: variantSelection.hashVariant, sourceEnhancers: mergedEnhancers, - fallbacks: context?.fallbacks, + fallbacks, expanded: uiState.expanded, collapseToEmpty, expand, @@ -646,9 +656,9 @@ export function useCode( selectedFile: fileNavigation.selectedFile, selectedVariant: renderedVariant, transformedFiles: transformManagement.transformedFiles, - // Per-file dictionaries for the active variant (decodes `hastCompressed` + // Per-file dictionaries for the rendered variant (decodes `hastCompressed` // sources back to text); `selectedFileFallback` covers the single-file copy. - fallbacks: context?.fallbacks, + fallbacks, selectedFileFallback: fileNavigation.selectedFileFallback, title: userProps.name, copyOpts, diff --git a/packages/docs/src/useCode/useCodeUtils.test.ts b/packages/docs/src/useCode/useCodeUtils.test.ts index 7531191..77237c5 100644 --- a/packages/docs/src/useCode/useCodeUtils.test.ts +++ b/packages/docs/src/useCode/useCodeUtils.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { getAvailableTransforms, getApplicableTransforms, + isPassThroughFile, shouldHighlightForRender, transformHasCollapsePlaceholder, } from './useCodeUtils'; @@ -144,6 +145,86 @@ describe('useCodeUtils', () => { }); }); + describe('isPassThroughFile', () => { + const buttonSource = { hastCompressed: 'button' }; + const stylesSource = { hastCompressed: 'styles' }; + const variant: VariantCode = { + fileName: 'Button.tsx', + source: buttonSource, + extraFiles: { + 'styles.css': { source: stylesSource }, + 'utils.js': 'export const utils = 1;', + }, + }; + + it('is true for a main file passed through as its original source', () => { + expect( + isPassThroughFile(variant, { + name: 'Button.tsx', + originalName: 'Button.tsx', + source: buttonSource, + }), + ).toBe(true); + }); + + it('is true for a renamed main file whose source was left untouched', () => { + expect( + isPassThroughFile(variant, { + name: 'Button.jsx', + originalName: 'Button.tsx', + source: buttonSource, + }), + ).toBe(true); + }); + + it('is false for a main file the transform rewrote', () => { + expect( + isPassThroughFile(variant, { + name: 'Button.jsx', + originalName: 'Button.tsx', + source: { type: 'root', children: [] }, + }), + ).toBe(false); + }); + + it('is true for an extra file passed through as its original source', () => { + expect( + isPassThroughFile(variant, { + name: 'styles.css', + originalName: 'styles.css', + source: stylesSource, + }), + ).toBe(true); + expect( + isPassThroughFile(variant, { + name: 'utils.js', + originalName: 'utils.js', + source: 'export const utils = 1;', + }), + ).toBe(true); + }); + + it('is false for an extra file the transform rewrote', () => { + expect( + isPassThroughFile(variant, { + name: 'styles.css', + originalName: 'styles.css', + source: { hastCompressed: 'styles' }, + }), + ).toBe(false); + }); + + it('is false for a file the variant does not have', () => { + expect( + isPassThroughFile(variant, { + name: 'Checkbox.tsx', + originalName: 'Checkbox.tsx', + source: buttonSource, + }), + ).toBe(false); + }); + }); + describe('userProps generation logic', () => { // Helper type for user props (name, slug, and custom properties) type UserProps = T & { diff --git a/packages/docs/src/useCode/useCodeUtils.ts b/packages/docs/src/useCode/useCodeUtils.ts index be36963..abdd6c8 100644 --- a/packages/docs/src/useCode/useCodeUtils.ts +++ b/packages/docs/src/useCode/useCodeUtils.ts @@ -23,6 +23,23 @@ export interface TransformedFiles { filenameMap: { [originalName: string]: string }; } +/** + * Whether `file` is one a transform left untouched. `createTransformedFiles` + * passes such a file through as the variant's original source object (no + * transform for the file, a rename-only transform, or a failed one), so it may + * still be `hastCompressed` and decodes with the original file's fallback. A + * file the transform rewrote is a new live tree whose text differs from the + * original's. + */ +export function isPassThroughFile(variant: VariantCode, file: TransformedFile): boolean { + if (file.originalName === variant.fileName) { + return file.source === variant.source; + } + const extraFile = variant.extraFiles?.[file.originalName]; + const originalSource = typeof extraFile === 'object' ? extraFile.source : extraFile; + return originalSource !== undefined && file.source === originalSource; +} + /** * Pure function to get available transforms from effective code data. * diff --git a/packages/docs/src/useCode/useCopyFunctionality.test.ts b/packages/docs/src/useCode/useCopyFunctionality.test.ts index 03245dc..cb4f1d0 100644 --- a/packages/docs/src/useCode/useCopyFunctionality.test.ts +++ b/packages/docs/src/useCode/useCopyFunctionality.test.ts @@ -46,7 +46,7 @@ describe('collectVariantFiles', () => { }; // The standalone `useCode`/`useDemo` path renders without a - // `CodeHighlighterClient`, so `context?.fallbacks` is undefined. The + // `CodeHighlighterClient`, so `context?.variantFallbacks` is undefined. The // render path still decodes via the variant's own `fallback`; copy must // too, instead of throwing on the dictionary-compressed payload. const files = collectVariantFiles(selectedVariant, undefined, undefined); diff --git a/packages/docs/src/useCode/useCopyFunctionality.ts b/packages/docs/src/useCode/useCopyFunctionality.ts index 00d44f4..452f767 100644 --- a/packages/docs/src/useCode/useCopyFunctionality.ts +++ b/packages/docs/src/useCode/useCopyFunctionality.ts @@ -51,9 +51,9 @@ export function collectVariantFiles( // `fallbacks` (hoisted from a `ContentLoading` component) and the variant's // own per-file `fallback` fields (kept on `Code` when not stripped — e.g. the // standalone `useCode`/`useDemo` path with no `CodeHighlighter` context, so - // `context?.fallbacks` is undefined). Without this merge, copy-as-markdown - // throws on a `hastCompressed` source whose dictionary lives on the - // `VariantCode`. The variant copy wins so the full text — the dictionary + // `context?.variantFallbacks` is undefined). Without this merge, + // copy-as-markdown throws on a `hastCompressed` source whose dictionary lives + // on the `VariantCode`. The variant copy wins so the full text — the dictionary // `hastCompressed` needs — is used when a `fallbackCollapsed` block hoisted // only the visible window. const resolvedFallbacks: Fallbacks = { ...fallbacks }; diff --git a/packages/docs/src/useCode/useFileNavigation.test.ts b/packages/docs/src/useCode/useFileNavigation.test.ts index ee8e4dd..b9d9d9d 100644 --- a/packages/docs/src/useCode/useFileNavigation.test.ts +++ b/packages/docs/src/useCode/useFileNavigation.test.ts @@ -4060,6 +4060,92 @@ describe('useFileNavigation', () => { }); }); + describe('files decode dictionaries', () => { + it('gives a file the transform passed through its own dictionary, but not a rewritten file', () => { + // `styles.css` has no transform, so it passes through as its original + // (possibly `hastCompressed`) source and needs its dictionary. The + // rewritten main file is a live tree whose text differs from the original, + // so the original fallback must not paint over it. + const buttonFallback = ['const button: number = 1;']; + const stylesFallback = ['.button {}']; + const stylesSource = { hastJson: '{"type":"root","children":[]}' }; + const selectedVariant: VariantCode = { + fileName: 'Button.tsx', + source: { hastJson: '{"type":"root","children":[]}' }, + fallback: buttonFallback, + language: 'tsx', + extraFiles: { + 'styles.css': { source: stylesSource, fallback: stylesFallback, totalLines: 1 }, + }, + }; + const transformedFiles: TransformedFiles = { + files: [ + { + name: 'Button.jsx', + originalName: 'Button.tsx', + source: { type: 'root', children: [] }, + }, + { name: 'styles.css', originalName: 'styles.css', source: stylesSource }, + ], + filenameMap: { 'Button.tsx': 'Button.jsx', 'styles.css': 'styles.css' }, + }; + + const { result } = renderHook(() => + useFileNavigationTest({ + selectedVariant, + transformedFiles, + mainSlug: 'Demo', + selectedVariantKey: 'Default', + variantKeys: ['Default'], + shouldHighlight: true, + }), + ); + + const [buttonFile, stylesFile] = result.current.files; + const buttonProps = (buttonFile.component as React.ReactElement).props; + const stylesProps = (stylesFile.component as React.ReactElement).props; + expect(buttonFile.name).toBe('Button.jsx'); + expect(buttonProps.fallback).toBeUndefined(); + expect(buttonProps.language).toBeUndefined(); + expect(stylesFile.name).toBe('styles.css'); + expect(stylesProps.fallback).toBe(stylesFallback); + expect(stylesProps.language).toBe('css'); + expect(stylesProps.fallbackLineCounts).toEqual(expect.objectContaining({ totalLines: 1 })); + }); + + it('gives a passed-through main file its dictionary from the fallbacks map', () => { + // A rename-only (or failed) transform hands the main file back as its + // original source, whose fallback was hoisted off `Code`. + const buttonFallback = ['const button = 1;']; + const buttonSource = { hastJson: '{"type":"root","children":[]}' }; + const selectedVariant: VariantCode = { + fileName: 'Button.tsx', + source: buttonSource, + language: 'tsx', + }; + const transformedFiles: TransformedFiles = { + files: [{ name: 'Button.jsx', originalName: 'Button.tsx', source: buttonSource }], + filenameMap: { 'Button.tsx': 'Button.jsx' }, + }; + + const { result } = renderHook(() => + useFileNavigationTest({ + selectedVariant, + transformedFiles, + mainSlug: 'Demo', + selectedVariantKey: 'Default', + variantKeys: ['Default'], + shouldHighlight: true, + fallbacks: { 'Button.tsx': buttonFallback }, + }), + ); + + const buttonProps = (result.current.files[0].component as React.ReactElement).props; + expect(buttonProps.fallback).toBe(buttonFallback); + expect(buttonProps.language).toBe('tsx'); + }); + }); + describe('selectedFileLines line counting', () => { it('uses the STORED variant count for a compressed pass-through file even when a transform is active', () => { // A transform is active on the variant, but the selected file is a pass-through: diff --git a/packages/docs/src/useCode/useFileNavigation.tsx b/packages/docs/src/useCode/useFileNavigation.tsx index 70d3239..b8fec46 100644 --- a/packages/docs/src/useCode/useFileNavigation.tsx +++ b/packages/docs/src/useCode/useFileNavigation.tsx @@ -12,6 +12,7 @@ import type { FallbackNode } from '../CodeHighlighter/fallbackFormat'; import { useUrlHashState } from '../useUrlHashState'; import { countLines } from '../pipeline/parseSource/addLineGutters'; import { getLanguageFromExtension } from '../pipeline/loaderUtils/getLanguageFromExtension'; +import { isPassThroughFile } from './useCodeUtils'; import type { TransformedFiles } from './useCodeUtils'; import { getVariantFileLineCounts } from './sourceLineCounts'; import type { SetSource } from './useSourceEditing'; @@ -37,6 +38,20 @@ function getLanguageFromFileName(fileName: string | undefined): string | undefin return getLanguageFromExtension(extension); } +/** + * The language a variant file renders with when untransformed: the variant's + * `language` for its main file, otherwise the extra file's own `language` or + * the one its extension implies. + */ +function getVariantFileLanguage(variant: VariantCode, fileName: string): string | undefined { + if (fileName === variant.fileName) { + return variant.language; + } + const extraFile = variant.extraFiles?.[fileName]; + const declared = typeof extraFile === 'object' ? extraFile.language : undefined; + return declared ?? getLanguageFromFileName(fileName); +} + /** * Checks if the URL hash is relevant to a specific demo * Hash format is: {mainSlug}:{variantName}:{fileName} or {mainSlug}:{fileName} @@ -841,29 +856,49 @@ export function useFileNavigation({ // If we have transformed files, use them if (transformedFiles) { - return transformedFiles.files.map((f) => ({ - name: f.name, - slug: generateFileSlug(mainSlug, f.originalName, selectedVariantKey), - component: ( -
-            {f.source}
-          
- ), - })); + return transformedFiles.files.map((file) => { + // A file the transform left untouched is still its original source — + // possibly `hastCompressed` — so it renders like the untransformed + // branch below, with its dictionary. A rewritten file is live HAST whose + // text differs, so the original `fallback` would paint stale text first. + const passedThrough = isPassThroughFile(selectedVariant, file); + return { + name: file.name, + slug: generateFileSlug(mainSlug, file.originalName, selectedVariantKey), + component: ( +
+              {file.source}
+            
+ ), + }; + }); } // Otherwise, create files from original untransformed data diff --git a/packages/docs/src/useCode/useSourceEditing.ts b/packages/docs/src/useCode/useSourceEditing.ts index 0373eb9..8103877 100644 --- a/packages/docs/src/useCode/useSourceEditing.ts +++ b/packages/docs/src/useCode/useSourceEditing.ts @@ -9,6 +9,7 @@ import type { Code, ControlledCode, ControlledVariantCode, + Fallbacks, VariantCode, } from '../CodeHighlighter/types'; import type { CodeHighlighterContextType } from '../CodeHighlighter/CodeHighlighterContext'; @@ -59,6 +60,11 @@ interface UseSourceEditingProps { selectedVariantKey: string; effectiveCode: Code; selectedVariant: VariantCode | null; + /** + * Per-file DEFLATE dictionaries for `selectedVariant` (keyed by file name), + * used to decode its `hastCompressed` sources into editable text. + */ + fallbacks?: Fallbacks; disabled?: boolean; } @@ -87,6 +93,7 @@ export function useSourceEditing({ selectedVariantKey, effectiveCode, selectedVariant, + fallbacks, disabled, }: UseSourceEditingProps): UseSourceEditingResult { const contextSetCode = context?.setCode; @@ -137,7 +144,7 @@ export function useSourceEditing({ : engine.toControlledCode( effectiveCode, selectedVariantKey, - context?.fallbacks, + fallbacks, stringOrHastToString, ); @@ -282,7 +289,7 @@ export function useSourceEditing({ effectiveCode, selectedVariant, context?.preParsedCache, - context?.fallbacks, + fallbacks, editingEngineLoader, ], ); diff --git a/packages/docs/src/useCode/useTransformManagement.test.ts b/packages/docs/src/useCode/useTransformManagement.test.ts index 0e4ea20..0dd55cb 100644 --- a/packages/docs/src/useCode/useTransformManagement.test.ts +++ b/packages/docs/src/useCode/useTransformManagement.test.ts @@ -180,12 +180,16 @@ describe('useTransformManagement', () => { expect(result.current.selectedTransform).toBe('TypeScript'); }); - it('should use context availableTransforms when provided', async () => { - const context = { availableTransforms: ['CustomTransform'] }; + it('should use context availableTransforms when provided for the selected variant', async () => { + const context = { + availableTransforms: ['CustomTransform'], + availableTransformsVariant: 'Default', + }; (createTransformedFiles as any).mockReturnValue({ transformed: true }); // Clear mocks before running test to ensure clean state vi.clearAllMocks(); + (getAvailableTransforms as any).mockReturnValue([]); const { result } = renderHook(() => useTransformManagement({ @@ -203,6 +207,76 @@ describe('useTransformManagement', () => { // `getAvailableTransforms`, so we don't assert call counts here.) }); + it('should compute availableTransforms for the selected variant when the context lists them for another variant', async () => { + // The highlighter lists transforms for the variant it considers current, + // while `useCode` selects the rendered variant on its own, so the two can + // disagree. + const context = { + availableTransforms: ['CustomTransform'], + availableTransformsVariant: 'Default', + }; + vi.clearAllMocks(); + (getAvailableTransforms as any).mockReturnValue([]); + (createTransformedFiles as any).mockReturnValue({ transformed: true }); + + const { result } = renderHook(() => + useTransformManagement({ + context, + effectiveCode: mockEffectiveCode, + selectedVariantKey: 'Alternative', + selectedVariant: mockSelectedVariant, + }), + ); + + expect(result.current.availableTransforms).toEqual([]); + expect(getAvailableTransforms).toHaveBeenCalledWith(mockEffectiveCode, 'Alternative'); + }); + + it('should compute availableTransforms when only the context selection matches the selected variant', async () => { + // A controlled `variant` prop makes the highlighter list transforms for that + // variant while its `selection` still names another one, so a matching + // selection says nothing about whose list it is. + const context = { + availableTransforms: ['CustomTransform'], + availableTransformsVariant: 'Alternative', + selection: { variant: 'Default' }, + }; + vi.clearAllMocks(); + (getAvailableTransforms as any).mockReturnValue([]); + (createTransformedFiles as any).mockReturnValue({ transformed: true }); + + const { result } = renderHook(() => + useTransformManagement({ + context, + effectiveCode: mockEffectiveCode, + selectedVariantKey: 'Default', + selectedVariant: mockSelectedVariant, + }), + ); + + expect(result.current.availableTransforms).toEqual([]); + expect(getAvailableTransforms).toHaveBeenCalledWith(mockEffectiveCode, 'Default'); + }); + + it('should compute availableTransforms when the context does not say which variant its list belongs to', async () => { + const context = { availableTransforms: ['CustomTransform'] }; + vi.clearAllMocks(); + (getAvailableTransforms as any).mockReturnValue([]); + (createTransformedFiles as any).mockReturnValue({ transformed: true }); + + const { result } = renderHook(() => + useTransformManagement({ + context, + effectiveCode: mockEffectiveCode, + selectedVariantKey: 'Default', + selectedVariant: mockSelectedVariant, + }), + ); + + expect(result.current.availableTransforms).toEqual([]); + expect(getAvailableTransforms).toHaveBeenCalledWith(mockEffectiveCode, 'Default'); + }); + describe('localStorage persistence', () => { it('should load transform from localStorage when no initialTransform provided', async () => { // Mock localStorage diff --git a/packages/docs/src/useCode/useTransformManagement.ts b/packages/docs/src/useCode/useTransformManagement.ts index c1c57b0..6a5e05c 100644 --- a/packages/docs/src/useCode/useTransformManagement.ts +++ b/packages/docs/src/useCode/useTransformManagement.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { Code, VariantCode } from '../CodeHighlighter/types'; +import type { Code, Fallbacks, VariantCode } from '../CodeHighlighter/types'; // `decodeHastSource` and `frameFallbackFromSpans` are already part of the // always-loaded `useCode` shell (via `Pre`, `sourceLineCounts`, // `useFileNavigation`, `useSourceEnhancing`). Passing them into the lazy @@ -64,6 +64,11 @@ interface UseTransformManagementProps { effectiveCode: Code; selectedVariantKey: string; selectedVariant: VariantCode | null; + /** + * Per-file DEFLATE dictionaries for `selectedVariant` (keyed by file name), + * used to decode its `hastCompressed` sources before applying a transform. + */ + fallbacks?: Fallbacks; initialTransform?: string; /** * When set to a positive number, the *swap* of `transformedFiles` to the @@ -165,22 +170,30 @@ export function useTransformManagement({ effectiveCode, selectedVariantKey, selectedVariant, + fallbacks, initialTransform, transformDelay, transformLayoutShift, selectedFileName, expanded, }: UseTransformManagementProps): UseTransformManagementResult { - // Transform state - get available transforms from context or from the effective code data + // Transform state - get available transforms from context or from the effective code data. + // The highlighter lists transforms for the variant IT considers current, but + // `useCode` selects the rendered variant on its own, so the context list only + // applies when it was computed for this variant. + const contextListVariant = context?.availableTransformsVariant; const availableTransforms = React.useMemo(() => { - // First try to get from context - if (context?.availableTransforms && context.availableTransforms.length > 0) { + if ( + contextListVariant === selectedVariantKey && + context?.availableTransforms && + context.availableTransforms.length > 0 + ) { return context.availableTransforms; } // Otherwise, get from the effective code data using the utility function return getAvailableTransforms(effectiveCode, selectedVariantKey); - }, [context?.availableTransforms, effectiveCode, selectedVariantKey]); + }, [context?.availableTransforms, contextListVariant, effectiveCode, selectedVariantKey]); // Lazily-resolved transform engine (the `jsondiffpatch`-pulling applier). // Initialized synchronously from the module cache so a warmed block (a later @@ -322,7 +335,18 @@ export function useTransformManagement({ // `deferHighlight` flips false, the receiver flow opens its barrier // and the collapse animation plays once, against a fully-parsed // target tree. - const effectiveStoredValue = context?.deferHighlight ? null : storedValue; + // + // The gate is a one-way latch, like `useVariantSelection`'s + // `allowStoredBootstrap`: it only holds the value until the highlighter + // first settles. A later deferral (the deltas window of a re-parse) must + // not mask the transform already applied, or the block would swap back to + // the untransformed code once it ends; `awaitHighlight` below already holds + // any swap until the pending work lands. + const [highlightSettled, setHighlightSettled] = React.useState(!context?.deferHighlight); + if (!highlightSettled && !context?.deferHighlight) { + setHighlightSettled(true); + } + const effectiveStoredValue = context?.deferHighlight && !highlightSettled ? null : storedValue; // Resolved view of the raw preference. This is the value // `useCoordinated` sees as its "external source of truth"; when it @@ -386,7 +410,7 @@ export function useTransformManagement({ transformLayoutShift, selectedFileName, expanded, - fallbacks: context?.fallbacks, + fallbacks, }); // eslint-disable-next-line react-hooks/refs layoutShiftPropsRef.current = { @@ -394,7 +418,7 @@ export function useTransformManagement({ transformLayoutShift, selectedFileName, expanded, - fallbacks: context?.fallbacks, + fallbacks, }; // Plumb classifier props through `transformHasCollapsePlaceholder` @@ -694,9 +718,9 @@ export function useTransformManagement({ selectedVariant, delayedAppliedTransform, transformRuntimeDeps, - context?.fallbacks, + fallbacks, ); - }, [precomputed, selectedVariant, delayedAppliedTransform, context?.fallbacks, transformEngine]); + }, [precomputed, selectedVariant, delayedAppliedTransform, fallbacks, transformEngine]); const result = { availableTransforms, diff --git a/packages/docs/src/useCode/user.spec.ts b/packages/docs/src/useCode/user.spec.ts index 09a3284..6338be0 100644 --- a/packages/docs/src/useCode/user.spec.ts +++ b/packages/docs/src/useCode/user.spec.ts @@ -14,12 +14,15 @@ */ import * as React from 'react'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { renderHook, act, waitFor } from '@testing-library/react'; +import { render, renderHook, act, waitFor } from '@testing-library/react'; import { useCode } from './useCode'; -import type { ContentProps, HastRoot } from '../CodeHighlighter/types'; +import type { UseCodeResult } from './useCode'; +import type { ContentProps, HastRoot, Transforms } from '../CodeHighlighter/types'; import { CodeHighlighterContext } from '../CodeHighlighter/CodeHighlighterContext'; import type { CodeHighlighterContextType } from '../CodeHighlighter/CodeHighlighterContext'; import { CodeControllerContext } from '../CodeControllerContext/CodeControllerContext'; +import { createCompressedFile } from '../pipeline/hastUtils/hastCompression.testUtils'; +import { preloadTransformEngine } from './transformEngineCache'; describe('useCode integration tests', () => { let originalLocation: Location; @@ -1337,6 +1340,170 @@ describe('useCode integration tests', () => { }); }); + describe('compressed sources across variants', () => { + // Each file is compressed with its own `fallback` text as the dictionary + // (`createCompressedFile`), so it decodes only with that same text. + + function toJs(text: string): Transforms { + return { + js: { + delta: { + children: { + _t: 'a', + 0: { children: { _t: 'a', 0: { value: [text] } } }, + }, + }, + fileName: 'Button.jsx', + }, + }; + } + + it('keeps the selected transform when switching to a variant whose file shares a name', async () => { + await preloadTransformEngine(); + const first = createCompressedFile('const first: number = 1;'); + const second = createCompressedFile('const second: number = 2;'); + + // A `ContentLoading` hoists each variant's dictionaries off `Code`, and the + // highlighter hands them back per variant alongside its own variant's map. + const context: CodeHighlighterContextType = { + code: { + First: { + fileName: 'Button.tsx', + source: first.source, + totalLines: 1, + transforms: toJs('const first = 1;'), + }, + Second: { + fileName: 'Button.tsx', + source: second.source, + totalLines: 1, + transforms: toJs('const second = 2;'), + }, + }, + variantFallbacks: { + First: { 'Button.tsx': first.fallback }, + Second: { 'Button.tsx': second.fallback }, + }, + }; + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(CodeHighlighterContext.Provider, { value: context }, children); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // `
` observes frame visibility, which jsdom doesn't implement.
+      const { IntersectionObserver: originalIntersectionObserver } = globalThis;
+      globalThis.IntersectionObserver = class {
+        observe() {}
+
+        unobserve() {}
+
+        disconnect() {}
+      } as unknown as typeof IntersectionObserver;
+
+      try {
+        let code: UseCodeResult | undefined;
+        function Viewer() {
+          code = useCode({ slug: 'button' });
+          return React.createElement('div', { 'data-testid': 'viewer' }, code.selectedFile);
+        }
+        const { getByTestId } = render(React.createElement(Viewer), { wrapper });
+
+        act(() => {
+          code!.selectTransform('js');
+        });
+        await waitFor(() => {
+          expect(getByTestId('viewer').textContent).toBe('const first = 1;');
+        });
+
+        act(() => {
+          code!.selectVariant('Second');
+        });
+        await waitFor(() => {
+          expect(getByTestId('viewer').textContent).toContain('second');
+        });
+
+        expect(getByTestId('viewer').textContent).toBe('const second = 2;');
+        expect(code!.selectedFileName).toBe('Button.jsx');
+        const transformErrors = errorSpy.mock.calls.filter(([message]) =>
+          String(message).includes('Transform failed'),
+        );
+        expect(transformErrors).toEqual([]);
+      } finally {
+        errorSpy.mockRestore();
+        globalThis.IntersectionObserver = originalIntersectionObserver;
+      }
+    });
+  });
+
+  describe('compressed files with a transform', () => {
+    it('renders every file of a transformed variant, including a compressed file the transform leaves untouched', async () => {
+      await preloadTransformEngine();
+      // Each file is compressed with its own `fallback` text as the dictionary.
+      // Only the main file declares the `js` transform, so `styles.css` passes
+      // through as its original compressed payload. The block mounts with the
+      // transform already selected (e.g. a stored preference), so no file has
+      // been rendered untransformed first.
+      const button = createCompressedFile('const button: number = 1;');
+      const styles = createCompressedFile('.button { color: red; }');
+      const contentProps: ContentProps<{}> = {
+        code: {
+          Default: {
+            fileName: 'Button.tsx',
+            ...button,
+            totalLines: 1,
+            transforms: {
+              js: {
+                delta: {
+                  children: {
+                    _t: 'a',
+                    0: { children: { _t: 'a', 0: { value: ['const button = 1;'] } } },
+                  },
+                },
+                fileName: 'Button.jsx',
+              },
+            },
+            extraFiles: { 'styles.css': { ...styles, totalLines: 1 } },
+          },
+        },
+      };
+      // `
` observes frame visibility, which jsdom doesn't implement.
+      const { IntersectionObserver: originalIntersectionObserver } = globalThis;
+      globalThis.IntersectionObserver = class {
+        observe() {}
+
+        unobserve() {}
+
+        disconnect() {}
+      } as unknown as typeof IntersectionObserver;
+
+      try {
+        let code: UseCodeResult | undefined;
+        function Viewer() {
+          code = useCode(contentProps, { initialTransform: 'js' });
+          return React.createElement(
+            'div',
+            null,
+            code.files.map((file) =>
+              React.createElement(
+                'div',
+                { key: file.name, 'data-testid': file.name },
+                file.component,
+              ),
+            ),
+          );
+        }
+        const { getByTestId } = render(React.createElement(Viewer));
+
+        await waitFor(() => {
+          expect(code!.files.map((file) => file.name)).toEqual(['Button.jsx', 'styles.css']);
+        });
+        expect(code!.selectedTransform).toBe('js');
+        expect(getByTestId('Button.jsx').textContent).toBe('const button = 1;');
+        expect(getByTestId('styles.css').textContent).toBe('.button { color: red; }');
+      } finally {
+        globalThis.IntersectionObserver = originalIntersectionObserver;
+      }
+    });
+  });
+
   describe('copy feedback', () => {
     it('reports a recent copy of the file and of the Markdown separately', async () => {
       const writes: string[] = [];