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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
325 changes: 325 additions & 0 deletions code/addons/docs/src/blocks/blocks/Source.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { SourceType } from 'storybook/internal/docs-tools';
import type { PreparedStory } from 'storybook/internal/types';

import type { DocsContextProps } from './DocsContext';
import type { SourceProps } from './Source';
import { useSourceProps } from './Source';
import type { SourceContextProps } from './SourceContainer';
import { UNKNOWN_ARGS_HASH } from './SourceContainer';

type ReducedStoryContext = ReturnType<DocsContextProps['getStoryContext']>;

const createMockDocsContext = (
story: PreparedStory,
storyContext: ReducedStoryContext
): DocsContextProps =>
({
storyById: () => story,
getStoryContext: () => storyContext,
}) as unknown as DocsContextProps;

describe('useSourceProps — source transform handling (issue #35889)', () => {
it('does not apply parameter transform twice when snippet arrives on later render for auto args story', () => {
Comment thread
valentinpalkovic marked this conversation as resolved.
const storyContext: ReducedStoryContext = {
id: 'story--primary',
parameters: {
__isArgsStory: true,
docs: {
source: {
type: SourceType.AUTO,
originalSource: '<Button />',
transform: (code: string) => `T:${code}`,
},
},
},
args: {},
initialArgs: {},
unmappedArgs: {},
} as unknown as ReducedStoryContext;
const story: PreparedStory = {
id: 'story--primary',
parameters: storyContext.parameters,
} as unknown as PreparedStory;
const docsContext = createMockDocsContext(story, storyContext);

const { result, rerender } = renderHook(
({ sourceContext, serviceSnippet, props }) =>
useSourceProps(props, docsContext, sourceContext, serviceSnippet),
{
initialProps: {
sourceContext: { sources: {} } as SourceContextProps,
serviceSnippet: '',
props: {} as SourceProps,
},
}
);

// Initial render before snippet arrives falls back to originalSource transformed
expect(result.current.code).toBe('T:<Button />');

// Snippet arrives after initial render (already transformed by emitTransformCode)
rerender({
sourceContext: {
sources: {
'story--primary': {
[UNKNOWN_ARGS_HASH]: { code: 'T:<Button />' },
},
},
},
serviceSnippet: '',
props: {},
});

// Should not apply transform a second time (would be T:T:<Button />)
expect(result.current.code).toBe('T:<Button />');
});

it('does not apply parameter transform twice when snippet arrives on later render for dynamic story', () => {
const storyContext: ReducedStoryContext = {
id: 'story--primary',
parameters: {
__isArgsStory: true,
docs: {
source: {
type: SourceType.DYNAMIC,
originalSource: '<Button />',
transform: (code: string) => `T:${code}`,
},
},
},
args: {},
initialArgs: {},
unmappedArgs: {},
} as unknown as ReducedStoryContext;
const story: PreparedStory = {
id: 'story--primary',
parameters: storyContext.parameters,
} as unknown as PreparedStory;
const docsContext = createMockDocsContext(story, storyContext);

const { result, rerender } = renderHook(
({ sourceContext, serviceSnippet, props }) =>
useSourceProps(props, docsContext, sourceContext, serviceSnippet),
{
initialProps: {
sourceContext: { sources: {} } as SourceContextProps,
serviceSnippet: '',
props: {} as SourceProps,
},
}
);

expect(result.current.code).toBe('');

// Snippet arrives after initial render
rerender({
sourceContext: {
sources: {
'story--primary': {
[UNKNOWN_ARGS_HASH]: { code: 'T:<Button />' },
},
},
},
serviceSnippet: '',
props: {},
});

expect(result.current.code).toBe('T:<Button />');
});

it('applies transform when raw service snippet arrives even if emitted snippet is present', () => {
const storyContext: ReducedStoryContext = {
id: 'story--primary',
parameters: {
__isArgsStory: true,
docs: {
source: {
type: SourceType.AUTO,
originalSource: '<Button />',
transform: (code: string) => `T:${code}`,
},
},
},
args: {},
initialArgs: {},
unmappedArgs: {},
} as unknown as ReducedStoryContext;
const story: PreparedStory = {
id: 'story--primary',
parameters: storyContext.parameters,
} as unknown as PreparedStory;
const docsContext = createMockDocsContext(story, storyContext);

const { result, rerender } = renderHook(
({ sourceContext, serviceSnippet, props }) =>
useSourceProps(props, docsContext, sourceContext, serviceSnippet),
{
initialProps: {
sourceContext: {
sources: {
'story--primary': {
[UNKNOWN_ARGS_HASH]: { code: 'T:<Button />' },
},
},
} as SourceContextProps,
serviceSnippet: '',
props: {} as SourceProps,
},
}
);

expect(result.current.code).toBe('T:<Button />');

// Raw service snippet arrives: not pre-transformed, so transform must be applied
rerender({
sourceContext: {
sources: {
'story--primary': {
[UNKNOWN_ARGS_HASH]: { code: 'T:<Button />' },
},
},
},
serviceSnippet: '<Svc />',
props: {},
});

expect(result.current.code).toBe('T:<Svc />');
});

it('transforms originalSource once for type code even when snippet is present', () => {
const storyContext: ReducedStoryContext = {
id: 'story--primary',
parameters: {
__isArgsStory: true,
docs: {
source: {
type: SourceType.CODE,
originalSource: '<Button />',
transform: (code: string) => `T:${code}`,
},
},
},
args: {},
initialArgs: {},
unmappedArgs: {},
} as unknown as ReducedStoryContext;
const story: PreparedStory = {
id: 'story--primary',
parameters: storyContext.parameters,
} as unknown as PreparedStory;
const docsContext = createMockDocsContext(story, storyContext);

const { result } = renderHook(
({ sourceContext, serviceSnippet, props }) =>
useSourceProps(props, docsContext, sourceContext, serviceSnippet),
{
initialProps: {
sourceContext: {
sources: {
'story--primary': {
[UNKNOWN_ARGS_HASH]: { code: 'T:<Snippet />' },
},
},
} as SourceContextProps,
serviceSnippet: '',
props: {} as SourceProps,
},
}
);

expect(result.current.code).toBe('T:<Button />');
});

it('transforms originalSource once for auto non-args story even when snippet is present', () => {
const storyContext: ReducedStoryContext = {
id: 'story--primary',
parameters: {
__isArgsStory: false,
docs: {
source: {
type: SourceType.AUTO,
originalSource: '<Button />',
transform: (code: string) => `T:${code}`,
},
},
},
args: {},
initialArgs: {},
unmappedArgs: {},
} as unknown as ReducedStoryContext;
const story: PreparedStory = {
id: 'story--primary',
parameters: storyContext.parameters,
} as unknown as PreparedStory;
const docsContext = createMockDocsContext(story, storyContext);

const { result } = renderHook(
({ sourceContext, serviceSnippet, props }) =>
useSourceProps(props, docsContext, sourceContext, serviceSnippet),
{
initialProps: {
sourceContext: {
sources: {
'story--primary': {
[UNKNOWN_ARGS_HASH]: { code: 'T:<Snippet />' },
},
},
} as SourceContextProps,
serviceSnippet: '',
props: {} as SourceProps,
},
}
);

expect(result.current.code).toBe('T:<Button />');
});

it('applies transformFromProps to emitted snippet when explicitly provided on props', () => {
const storyContext: ReducedStoryContext = {
id: 'story--primary',
parameters: {
__isArgsStory: true,
docs: {
source: {
type: SourceType.DYNAMIC,
transform: (code: string) => `T:${code}`,
},
},
},
args: {},
initialArgs: {},
unmappedArgs: {},
} as unknown as ReducedStoryContext;
const story: PreparedStory = {
id: 'story--primary',
parameters: storyContext.parameters,
} as unknown as PreparedStory;
const docsContext = createMockDocsContext(story, storyContext);

const { result } = renderHook(
({ sourceContext, serviceSnippet, props }) =>
useSourceProps(props, docsContext, sourceContext, serviceSnippet),
{
initialProps: {
sourceContext: {
sources: {
'story--primary': {
[UNKNOWN_ARGS_HASH]: { code: 'T:<Button />' },
},
},
} as SourceContextProps,
serviceSnippet: '',
props: {
transform: (code: string) => `/*prop*/ ${code}`,
} as SourceProps,
},
}
);

expect(result.current.code).toBe('/*prop*/ T:<Button />');
});
});
27 changes: 23 additions & 4 deletions code/addons/docs/src/blocks/blocks/Source.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,29 @@ const useCode = ({
// if this is an args story and there's a snippet
(type === SourceType.AUTO && staticSnippet && isArgsStory);

const code = useSnippet ? staticSnippet : sourceParameters.originalSource || '';
const transformer = transformFromProps ?? sourceParameters.transform;

const transformedCode = transformer ? useTransformCode(code, transformer, storyContext) : code;
const sourceKind: 'service' | 'emitted' | 'original' = useSnippet
? serviceSnippet
? 'service'
: 'emitted'
: 'original';
Comment thread
valentinpalkovic marked this conversation as resolved.

const code =
sourceKind === 'service'
? serviceSnippet
: sourceKind === 'emitted'
? snippet
: sourceParameters.originalSource || '';

// The emitted snippet is already transformed by emitTransformCode,
// so only transformFromProps applies to it.
const hasTransformer = Boolean(transformFromProps ?? sourceParameters.transform);
const activeTransformer =
transformFromProps ??
(sourceKind === 'emitted' ? (rawCode: string) => rawCode : sourceParameters.transform);

const transformedCode = hasTransformer
? useTransformCode(code, activeTransformer!, storyContext)
: code;

if (sourceParameters.code !== undefined) {
return sourceParameters.code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export async function emitTransformCode(
const transformed = transform && source ? transform?.(source, context) : source;
const result = transformed ? await transformed : undefined;

// Note: The snippet payload emitted via SNIPPET_RENDERED is already transformed.
addons.getChannel().emit(SNIPPET_RENDERED, {
id,
source: result,
Expand Down
Loading