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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions code/core/src/csf-tools/story-shape/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
argsRecordFromObjectPath,
mergeArgsRecords,
metaArgsRecord,
storyAssignedArgsPath,
} from './args.ts';
import { normalizeStoryDeclaration } from './normalize-story.ts';
import { keyOf, metaObjectPath } from './utils.ts';
Expand Down Expand Up @@ -179,3 +180,52 @@ describe('mergeArgsRecords', () => {
`);
});
});

describe('storyAssignedArgsPath', () => {
const assignedArgs = (code: string) => {
const source = `export default { title: 'T' };\n${dedent(code)}`;
const csf = loadCsf(source, { makeTitle: (title) => title ?? 'title' }).parse();
return argsRecordFromObjectPath(storyAssignedArgsPath(csf._file.path, 'A'));
};

it('reads the CSF2 assignment form', () => {
expect(
Object.keys(
assignedArgs(`
export const A = () => 1;
A.args = { label: 'Save' };
`)
)
).toEqual(['label']);
});

it('reads the computed spelling too', () => {
expect(
Object.keys(
assignedArgs(`
export const A = () => 1;
A['args'] = { label: 'Save' };
`)
)
).toEqual(['label']);
});

it('ignores an assignment to a different story', () => {
expect(
assignedArgs(`
export const A = () => 1;
export const B = () => 1;
B.args = { label: 'Save' };
`)
).toEqual({});
});

it('ignores a non-args property', () => {
expect(
assignedArgs(`
export const A = () => 1;
A.parameters = { docs: {} };
`)
).toEqual({});
});
});
36 changes: 36 additions & 0 deletions code/core/src/csf-tools/story-shape/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,42 @@ export const metaArgsRecord = (meta?: t.ObjectExpression | null): Record<string,
: {};
};

/**
* `args` assigned to a story after its declaration, the CSF2 form `MyStory.args = { … }`.
*
* Assignment happens outside the story's own initializer, so it is invisible to anything that only
* reads the declaration. Both `Story.args` and `Story['args']` are matched.
*/
export const storyAssignedArgsPath = (
program: NodePath<t.Program>,
storyName: string
): NodePath<t.ObjectExpression> | null => {
let found: NodePath<t.ObjectExpression> | null = null;

program.traverse({
AssignmentExpression(assignment) {
const left = assignment.get('left');
const right = assignment.get('right');
if (!left.isMemberExpression() || !right.isObjectExpression()) {
return;
}

const object = left.get('object');
const property = left.get('property');
const isStory = object.isIdentifier() && object.node.name === storyName;
const isArgs =
(property.isIdentifier() && property.node.name === 'args' && !left.node.computed) ||
(t.isStringLiteral(property.node) && left.node.computed && property.node.value === 'args');

if (isStory && isArgs) {
found = right;
}
},
});

return found;
};

/** CSF arg precedence: story args override meta args per key. */
export const mergeArgsRecords = (
metaArgs: Record<string, t.Node>,
Expand Down
16 changes: 14 additions & 2 deletions code/core/src/csf-tools/story-shape/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export { argsRecordFromObjectPath, mergeArgsRecords, metaArgsRecord } from './args.ts';
export {
argsRecordFromObjectPath,
mergeArgsRecords,
metaArgsRecord,
storyAssignedArgsPath,
} from './args.ts';
export {
type ImportBinding,
collectImportBindings,
Expand All @@ -7,4 +12,11 @@ export {
} from './imports.ts';
export { extractStoryJSDocInfo } from './jsdoc.ts';
export { type NormalizedStoryDeclaration, normalizeStoryDeclaration } from './normalize-story.ts';
export { keyOf, metaObjectPath, resolveIdentifierInit } from './utils.ts';
export { type RenderFunctionPath, type RenderResolution, resolveRenderFunction } from './render.ts';
export {
keyOf,
metaObjectPath,
propertyValue,
resolveIdentifierInit,
returnedObjectExpression,
} from './utils.ts';
84 changes: 84 additions & 0 deletions code/core/src/csf-tools/story-shape/render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';

import type { types as t } from 'storybook/internal/babel';
import { type NodePath, recast } from 'storybook/internal/babel';

import { dedent } from 'ts-dedent';

import { loadCsf } from '../CsfFile.ts';
import { resolveRenderFunction } from './render.ts';
import { normalizeStoryDeclaration } from './normalize-story.ts';

/** Resolves `render` on story `A`, the way a snippet generator would. */
const resolveStoryRender = (code: string) => {
const source = `export default { title: 'T' };\n${dedent(code)}`;
const csf = loadCsf(source, { makeTitle: (title) => title ?? 'title' }).parse();
const declaration = csf._storyDeclarationPath['A'];
const normalized = normalizeStoryDeclaration(declaration);
const properties: NodePath<t.ObjectProperty>[] =
normalized.type === 'config'
? normalized.path.get('properties').filter((p) => p.isObjectProperty())
: [];

return resolveRenderFunction(properties, declaration);
};

const printedBody = (resolution: ReturnType<typeof resolveStoryRender>) =>
resolution.kind === 'resolved' ? recast.print(resolution.path.node).code : undefined;

describe('resolveRenderFunction', () => {
it('reports a story with no render property as missing', () => {
expect(resolveStoryRender(`export const A = { args: {} };`)).toEqual({ kind: 'missing' });
});

it('resolves an inline arrow function', () => {
expect(printedBody(resolveStoryRender(`export const A = { render: () => 1 };`))).toBe(
'() => 1'
);
});

it('follows an identifier to a local arrow function', () => {
expect(
printedBody(
resolveStoryRender(`
const Template = () => 1;
export const A = { render: Template };
`)
)
).toBe('() => 1');
});

it('follows an identifier to a local function declaration', () => {
expect(
printedBody(
resolveStoryRender(`
function Template() { return 1; }
export const A = { render: Template };
`)
)
).toBe('function Template() { return 1; }');
});

// The distinction that matters: an unreadable render is not the same as no render, because a
// caller may only fall back to the meta's render in the second case.
it('reports an identifier it cannot follow as unresolved rather than missing', () => {
expect(resolveStoryRender(`export const A = { render: ImportedTemplate };`)).toEqual({
kind: 'unresolved',
});
});

it('reports an identifier bound to a non-function as unresolved', () => {
expect(
resolveStoryRender(`
const Template = 'not a function';
export const A = { render: Template };
`)
).toEqual({ kind: 'unresolved' });
});

it('throws when render is present but is not a function at all', () => {
expect(() => resolveStoryRender(`export const A = { render: { nested: true } };`)).toThrow(
/Expected render to be an arrow function or function expression/
);
});
});
60 changes: 60 additions & 0 deletions code/core/src/csf-tools/story-shape/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { types as t } from 'storybook/internal/babel';
import { type NodePath } from 'storybook/internal/babel';

import { keyOf, resolveIdentifierInit } from './utils.ts';

/** A function a story or meta supplies through `render`. */
export type RenderFunctionPath = NodePath<
t.ArrowFunctionExpression | t.FunctionExpression | t.FunctionDeclaration
>;

/**
* Outcome of looking for a `render` function.
*
* `missing` and `unresolved` have to stay distinct. A story whose `render` exists but cannot be
* read must not fall back to the meta's `render`: the story's intent was to override it, and
* quietly rendering the meta's version instead produces a snippet for code the story never runs.
*/
export type RenderResolution =
| { kind: 'missing' }
| { kind: 'resolved'; path: RenderFunctionPath }
| { kind: 'unresolved' };

const isRenderFunction = (path: NodePath<t.Node>): path is RenderFunctionPath =>
path.isArrowFunctionExpression() || path.isFunctionExpression() || path.isFunctionDeclaration();

/**
* Resolves the `render` property of a story or meta config, following a local identifier
* (`render: Template`) to the function it names.
*
* `storyDeclaration` anchors the identifier lookup to the module the story lives in, so a helper
* declared beside the story resolves while an imported one reports `unresolved`.
*
* Throws when `render` is present but is neither a function nor an identifier, because that is a
* story-file mistake rather than something a static pass merely could not follow.
*/
export function resolveRenderFunction(
properties: NodePath<t.ObjectProperty>[],
storyDeclaration: NodePath<t.Node>
): RenderResolution {
const renderPath = properties.find((property) => keyOf(property.node) === 'render')?.get('value');

if (!renderPath) {
return { kind: 'missing' };
}

if (renderPath.isIdentifier()) {
const resolved = resolveIdentifierInit(storyDeclaration, renderPath);
return resolved && isRenderFunction(resolved)
? { kind: 'resolved', path: resolved }
: { kind: 'unresolved' };
}

if (!isRenderFunction(renderPath)) {
throw renderPath.buildCodeFrameError(
'Expected render to be an arrow function or function expression'
);
}

return { kind: 'resolved', path: renderPath };
}
37 changes: 37 additions & 0 deletions code/core/src/csf-tools/story-shape/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,43 @@ export const keyOf = (p: t.ObjectProperty): string | null =>
? p.key.value
: null;

/** Value of an object expression's own property, when it has one. */
export const propertyValue = (
object: t.ObjectExpression | undefined | null,
name: string
): t.Node | undefined =>
object?.properties.find(
(candidate): candidate is t.ObjectProperty =>
t.isObjectProperty(candidate) && keyOf(candidate) === name
)?.value;

/**
* Object literal a function returns, when it returns one directly.
*
* Covers the concise body (`() => ({ … })`) and a block body whose `return` carries an object
* literal, which is the shape template-based renderers use for `render`.
*/
export const returnedObjectExpression = (
fn: t.Node | undefined
): t.ObjectExpression | undefined => {
if (
!t.isArrowFunctionExpression(fn) &&
!t.isFunctionExpression(fn) &&
!t.isFunctionDeclaration(fn)
) {
return undefined;
}
if (t.isObjectExpression(fn.body)) {
return fn.body;
}
const returned = t.isBlockStatement(fn.body)
? fn.body.body.find((statement): statement is t.ReturnStatement =>
t.isReturnStatement(statement)
)?.argument
: undefined;
return t.isObjectExpression(returned) ? returned : undefined;
};

/** Resolve a local story helper used by `Template.bind({})` or `render: Template`. */
export function resolveIdentifierInit(
storyPath: NodePath<t.Node>,
Expand Down
27 changes: 27 additions & 0 deletions code/frameworks/angular-vite/src/client/docs/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// The flag is read once at module evaluation, so each case needs a fresh module registry.
const loadDecorators = async () => {
vi.resetModules();
return (await import('./config.ts')).decorators;
};

describe('angular docs decorators', () => {
beforeEach(() => {
vi.resetModules();
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('registers the source decorator by default', async () => {
await expect(loadDecorators()).resolves.toHaveLength(1);
});
Comment on lines +18 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the default feature-flag state explicit.

At Line 18, the test imports config.ts without setting FEATURES. A Vitest setup with experimentalDocgenServer: true would make this test load an empty decorator list instead of the default list. Stub FEATURES with experimentalDocgenServer: false before loading the module.

As per coding guidelines, use vi.stubGlobal and restore ambient globals in tests.

Suggested fix
   it('registers the source decorator by default', async () => {
+    vi.stubGlobal('FEATURES', { experimentalDocgenServer: false });
     await expect(loadDecorators()).resolves.toHaveLength(1);
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('registers the source decorator by default', async () => {
await expect(loadDecorators()).resolves.toHaveLength(1);
});
it('registers the source decorator by default', async () => {
vi.stubGlobal('FEATURES', { experimentalDocgenServer: false });
await expect(loadDecorators()).resolves.toHaveLength(1);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/frameworks/angular-vite/src/client/docs/config.test.ts` around lines 18
- 20, Update the `registers the source decorator by default` test to stub the
global `FEATURES` with `experimentalDocgenServer: false` via `vi.stubGlobal`
before loading `config.ts` or calling `loadDecorators()`, and restore the
ambient global state after the test using the existing Vitest cleanup mechanism.

Source: Coding guidelines


it('drops the source decorator when the server-side docs path is on', async () => {
vi.stubGlobal('FEATURES', { experimentalDocgenServer: true });

await expect(loadDecorators()).resolves.toEqual([]);
});
});
6 changes: 5 additions & 1 deletion code/frameworks/angular-vite/src/client/docs/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ export const parameters: Parameters = {
},
};

export const decorators: DecoratorFunction[] = [sourceDecorator];
// Two generators would otherwise call `emitTransformCode` for the same story with nothing
// coordinating them: `story-docs` covers snippets when the server-side docs path is on.
const useStaticServiceSnippets = globalThis.FEATURES?.experimentalDocgenServer;

export const decorators: DecoratorFunction[] = useStaticServiceSnippets ? [] : [sourceDecorator];
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"components": [
{
"name": "ButtonComponent",
"type": "component",
"file": "button.component.ts",
"selector": "sb-button",
"propertiesClass": [],
"methodsClass": [],
"inputsClass": [
{ "name": "label", "type": "string", "optional": false, "defaultValue": "'Badge'" },
{ "name": "count", "type": "number", "optional": true }
],
"outputsClass": [{ "name": "clicked", "type": "EventEmitter<string>", "optional": false }]
}
],
"directives": [],
"classes": [],
"miscellaneous": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Fixture: a story file with no `meta.component`, which the story-docs provider passes through.
export default { title: 'NoComponent' };

export const Only = { args: { label: 'x' } };
Loading