).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[] = [];