Skip to content
Merged
51 changes: 10 additions & 41 deletions code/core/src/csf-tools/CsfFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { dedent } from 'ts-dedent';
import { Tag } from '../shared/constants/tags.ts';
import type { PrintResultType } from './PrintResultType.ts';
import { findVarInitialization } from './findVarInitialization.ts';
import { isCanonicalCsf2BindCall, isCsfFactoryCall } from './story-shape/utils.ts';

// We add this BabelFile as a temporary workaround to deal with a BabelFileClass "ImportEquals should have a literal source" issue in no link mode with tsup
interface BabelFile {
Expand Down Expand Up @@ -118,25 +119,12 @@ export const isModuleMock = (importPath: string) => MODULE_MOCK_REGEX.test(impor
const isArgsStory = (init: t.Node, parent: t.Node, csf: CsfFile) => {
let storyFn: t.Node = init;
// export const Foo = Bar.bind({})
if (t.isCallExpression(init)) {
const { callee, arguments: bindArguments } = init;
if (
t.isProgram(parent) &&
t.isMemberExpression(callee) &&
t.isIdentifier(callee.object) &&
t.isIdentifier(callee.property) &&
callee.property.name === 'bind' &&
(bindArguments.length === 0 ||
(bindArguments.length === 1 &&
t.isObjectExpression(bindArguments[0]) &&
bindArguments[0].properties.length === 0))
) {
const boundIdentifier = callee.object.name;
const template = findVarInitialization(boundIdentifier, parent);
if (template) {
csf._templates[boundIdentifier] = template;
storyFn = template;
}
if (t.isProgram(parent) && isCanonicalCsf2BindCall(init)) {
const boundIdentifier = init.callee.object.name;
const template = findVarInitialization(boundIdentifier, parent);
if (template) {
csf._templates[boundIdentifier] = template;
storyFn = template;
}
}
if (t.isArrowFunctionExpression(storyFn)) {
Expand Down Expand Up @@ -413,21 +401,8 @@ export class CsfFile {
getStoryExport(key: string) {
let node = this._storyExports[key] as t.Node;
node = t.isVariableDeclarator(node) ? (node.init as t.Node) : node;
if (t.isCallExpression(node)) {
const { callee, arguments: bindArguments } = node;
if (
t.isMemberExpression(callee) &&
t.isIdentifier(callee.object) &&
t.isIdentifier(callee.property) &&
callee.property.name === 'bind' &&
(bindArguments.length === 0 ||
(bindArguments.length === 1 &&
t.isObjectExpression(bindArguments[0]) &&
bindArguments[0].properties.length === 0))
) {
const { name } = callee.object;
node = this._templates[name];
}
if (isCanonicalCsf2BindCall(node)) {
node = this._templates[node.callee.object.name];
}
return node;
}
Expand Down Expand Up @@ -583,13 +558,7 @@ export class CsfFile {

// Check if this is a factory story (meta.story() or meta.extend())
let storyIsFactory = false;
if (
t.isCallExpression(storyNode) &&
t.isMemberExpression(storyNode.callee) &&
t.isIdentifier(storyNode.callee.property) &&
(storyNode.callee.property.name === 'story' ||
storyNode.callee.property.name === 'extend')
) {
if (storyNode && isCsfFactoryCall(storyNode)) {
storyIsFactory = true;
storyNode = storyNode.arguments[0];
}
Expand Down
2 changes: 2 additions & 0 deletions code/core/src/csf-tools/story-shape/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export {
} from './resolve-story-args.ts';
export { resolveRenderFunction, type RenderFunctionPath, type RenderResolution } from './render.ts';
export {
isCanonicalCsf2BindCall,
isCsfFactoryCall,
keyOf,
metaObjectPath,
pathForNode,
Expand Down
37 changes: 37 additions & 0 deletions code/core/src/csf-tools/story-shape/normalize-story.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ describe('normalizeStoryDeclaration', () => {
`);
});

it('rejects zero-argument calls that are not CSF factories', () => {
expect(() =>
normalize(dedent`
export default { title: 'Button' };
export const A = makeStory();
`)
).toThrow('Expected story to be csf factory, function or an object expression');
});

it('resolves CSF2 Template.bind({}) to a local const arrow function', () => {
expect(
printedShape(dedent`
Expand Down Expand Up @@ -123,6 +132,34 @@ describe('normalizeStoryDeclaration', () => {
});
});

it('resolves CSF2 Template.bind() to its local template', () => {
expect(
printedShape(dedent`
export default { title: 'Button' };
const Template = (args) => args;
export const A = Template.bind();
`)
).toMatchInlineSnapshot(`
{
"code": "(args) => args",
"type": "fn",
}
`);
});

it.each(["Template.bind({ role: 'button' })", "Template['bind']({})"])(
'rejects non-canonical CSF2 bind initializer %s',
(initializer) => {
expect(() =>
normalize(dedent`
export default { title: 'Button' };
const Template = (args) => args;
export const A = ${initializer};
`)
).toThrow('Expected story to be csf factory, function or an object expression');
}
);

it('normalizes plain arrow function story exports to fn', () => {
const normalized = normalize(dedent`
export default { title: 'Button' };
Expand Down
20 changes: 9 additions & 11 deletions code/core/src/csf-tools/story-shape/normalize-story.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type NodePath, types as t } from 'storybook/internal/babel';
import type { NodePath, types as t } from 'storybook/internal/babel';

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

export type NormalizedStoryDeclaration =
| { type: 'config'; path: NodePath<t.ObjectExpression> }
Expand Down Expand Up @@ -64,18 +64,16 @@ function bindInitializer(
return null;
}

if (!isCanonicalCsf2BindCall(storyPath.node)) {
return null;
}

const callee = storyPath.get('callee');
if (!callee.isMemberExpression()) {
return null;
}

const obj = callee.get('object');
const prop = callee.get('property');
const isBind =
(prop.isIdentifier() && prop.node.name === 'bind') ||
(t.isStringLiteral(prop.node) && prop.node.value === 'bind');

if (!obj.isIdentifier() || !isBind) {
if (!obj.isIdentifier()) {
return null;
}

Expand All @@ -86,7 +84,7 @@ function bindInitializer(
function factoryArgumentExpression(
storyPath: StoryDeclarationExpression
): StoryDeclarationExpression {
if (!storyPath.isCallExpression()) {
if (!storyPath.isCallExpression() || !isCsfFactoryCall(storyPath.node)) {
return storyPath;
}

Expand Down Expand Up @@ -131,7 +129,7 @@ function classifyStoryPath(storyPath: StoryDeclarationExpression): NormalizedSto

if (
storyPath.isCallExpression() &&
Array.isArray(storyPath.node.arguments) &&
isCsfFactoryCall(storyPath.node) &&
storyPath.node.arguments.length === 0
) {
return { type: 'emptyConfig', path: storyPath };
Expand Down
175 changes: 175 additions & 0 deletions code/core/src/csf-tools/story-shape/resolve-members.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,181 @@ describe('write order', () => {
});
});

describe('story initializers', () => {
it('reports opaque calls while retaining members assigned after the call', () => {
const code = dedent`
export const Built = makeStory();
Built.args = { label: 'assigned' };
`;

expect(argsOf(code, 'Built')).toMatchInlineSnapshot(`
{
"args": {
"label": "'assigned'",
},
"unresolved": [
"makeStory()",
],
}
`);
});

it('reports a factory call whose named config cannot be inspected', () => {
const code = dedent`
const CONFIG = getConfig();
export const Named = meta.story(CONFIG);
`;

expect(argsOf(code, 'Named')).toMatchInlineSnapshot(`
{
"args": {},
"unresolved": [
"meta.story(CONFIG)",
],
}
`);
});

it('reports every opaque initializer shape while retaining later assignments', () => {
const code = dedent`
export const Optional = makeStory?.();
Optional.args = { label: 'optional' };
export const Constructed = new StoryBuilder();
Constructed.args = { label: 'constructed' };
export const Conditional = usePrimary ? makePrimary() : makeSecondary();
Conditional.args = { label: 'conditional' };
export const Awaited = await makeStory();
Awaited.args = { label: 'awaited' };
`;

expect({
optional: argsOf(code, 'Optional'),
constructed: argsOf(code, 'Constructed'),
conditional: argsOf(code, 'Conditional'),
awaited: argsOf(code, 'Awaited'),
}).toMatchInlineSnapshot(`
{
"awaited": {
"args": {
"label": "'awaited'",
},
"unresolved": [
"await makeStory()",
],
},
"conditional": {
"args": {
"label": "'conditional'",
},
"unresolved": [
"usePrimary ? makePrimary() : makeSecondary()",
],
},
"constructed": {
"args": {
"label": "'constructed'",
},
"unresolved": [
"new StoryBuilder()",
],
},
"optional": {
"args": {
"label": "'optional'",
},
"unresolved": [
"makeStory?.()",
],
},
}
`);
});

it('keeps canonical bind stories readable without exempting bind calls with config', () => {
const code = dedent`
const Template = (args) => args;
export const EmptyObject = Template.bind({});
EmptyObject.args = { label: 'empty object' };
export const NoArgument = Template.bind();
NoArgument.args = { label: 'no argument' };
export const Configured = Template.bind({ role: 'button' });
`;

expect({
emptyObject: argsOf(code, 'EmptyObject'),
noArgument: argsOf(code, 'NoArgument'),
configured: argsOf(code, 'Configured'),
}).toMatchInlineSnapshot(`
{
"configured": {
"args": {},
"unresolved": [
"Template.bind({ role: 'button' })",
],
},
"emptyObject": {
"args": {
"label": "'empty object'",
},
"unresolved": [],
},
"noArgument": {
"args": {
"label": "'no argument'",
},
"unresolved": [],
},
}
`);
});

it('keeps function stories and inline factory configs readable', () => {
const code = dedent`
export const Arrow = (args) => args;
Arrow.args = { label: 'arrow' };
export function Declared(args) { return args; }
Declared.args = { label: 'declared' };
export const Inline = meta.story({ args: { label: 'inline' } });
export const Extended = Inline.extend({ args: { size: 'large' } });
`;

expect({
arrow: argsOf(code, 'Arrow'),
declared: argsOf(code, 'Declared'),
inline: argsOf(code, 'Inline'),
extended: argsOf(code, 'Extended'),
}).toMatchInlineSnapshot(`
{
"arrow": {
"args": {
"label": "'arrow'",
},
"unresolved": [],
},
"declared": {
"args": {
"label": "'declared'",
},
"unresolved": [],
},
"extended": {
"args": {
"label": "'inline'",
"size": "'large'",
},
"unresolved": [],
},
"inline": {
"args": {
"label": "'inline'",
},
"unresolved": [],
},
}
`);
});
});

describe('CSF factories', () => {
it("resolves a spread of a factory story's args", () => {
const code = dedent`
Expand Down
Loading