Merge pull request #18536 from storybookjs/tom/sb-447-use-strict-types-in-core-preview

Turn on strict types in store + preview-web
This commit is contained in:
Tom Coleman 2022-06-23 14:16:52 +10:00 committed by GitHub
commit 533fd71be6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 410 additions and 267 deletions

View File

@ -12,6 +12,7 @@ import type {
StoryKind, StoryKind,
StoryName, StoryName,
Args, Args,
ComponentTitle,
} from '@storybook/csf'; } from '@storybook/csf';
import { Addon } from './index'; import { Addon } from './index';
@ -50,18 +51,29 @@ export interface StorySortObjectParameter {
includeNames?: boolean; includeNames?: boolean;
} }
interface StoryIndexEntry { type Path = string;
interface BaseIndexEntry {
id: StoryId; id: StoryId;
name: StoryName; name: StoryName;
title: string; title: ComponentTitle;
importPath: string; importPath: Path;
} }
export type StoryIndexEntry = BaseIndexEntry & {
type: 'story';
};
export type DocsIndexEntry = BaseIndexEntry & {
storiesImports: Path[];
type: 'docs';
legacy?: boolean;
};
export type IndexEntry = StoryIndexEntry | DocsIndexEntry;
// The `any` here is the story store's `StoreItem` record. Ideally we should probably only // The `any` here is the story store's `StoreItem` record. Ideally we should probably only
// pass a defined subset of that full data, but we pass it all so far :shrug: // pass a defined subset of that full data, but we pass it all so far :shrug:
export type StorySortComparator = Comparator<[StoryId, any, Parameters, Parameters]>; export type StorySortComparator = Comparator<[StoryId, any, Parameters, Parameters]>;
export type StorySortParameter = StorySortComparator | StorySortObjectParameter; export type StorySortParameter = StorySortComparator | StorySortObjectParameter;
export type StorySortComparatorV7 = Comparator<StoryIndexEntry>; export type StorySortComparatorV7 = Comparator<IndexEntry>;
export type StorySortParameterV7 = StorySortComparatorV7 | StorySortObjectParameter; export type StorySortParameterV7 = StorySortComparatorV7 | StorySortObjectParameter;
export interface OptionsParameter extends Object { export interface OptionsParameter extends Object {

View File

@ -16,6 +16,7 @@ import { start } from './start';
jest.mock('@storybook/preview-web/dist/cjs/WebView'); jest.mock('@storybook/preview-web/dist/cjs/WebView');
jest.spyOn(WebView.prototype, 'prepareForDocs').mockReturnValue('docs-root'); jest.spyOn(WebView.prototype, 'prepareForDocs').mockReturnValue('docs-root');
jest.spyOn(WebView.prototype, 'prepareForStory').mockReturnValue('story-root');
jest.mock('global', () => ({ jest.mock('global', () => ({
// @ts-ignore // @ts-ignore
@ -156,7 +157,7 @@ describe('start', () => {
expect.objectContaining({ expect.objectContaining({
id: 'component-a--story-one', id: 'component-a--story-one',
}), }),
undefined 'story-root'
); );
}); });
@ -328,7 +329,7 @@ describe('start', () => {
}), }),
}), }),
}), }),
undefined 'story-root'
); );
}); });
@ -365,7 +366,7 @@ describe('start', () => {
}, },
}), }),
}), }),
undefined 'story-root'
); );
expect((window as any).IS_STORYBOOK).toBe(true); expect((window as any).IS_STORYBOOK).toBe(true);
@ -707,7 +708,7 @@ describe('start', () => {
expect.objectContaining({ expect.objectContaining({
id: 'component-c--story-one', id: 'component-c--story-one',
}), }),
undefined 'story-root'
); );
}); });
@ -1184,7 +1185,7 @@ describe('start', () => {
expect.objectContaining({ expect.objectContaining({
id: 'component-a--story-one', id: 'component-a--story-one',
}), }),
undefined 'story-root'
); );
}); });
}); });

View File

@ -34,7 +34,9 @@ export class DocsRender<TFramework extends AnyFramework> implements Render<TFram
public disableKeyListeners = false; public disableKeyListeners = false;
public teardown: (options: { viewModeChanged?: boolean }) => Promise<void>; public teardown?: (options: { viewModeChanged?: boolean }) => Promise<void>;
public torndown = false;
constructor( constructor(
private channel: Channel, private channel: Channel,
@ -42,7 +44,7 @@ export class DocsRender<TFramework extends AnyFramework> implements Render<TFram
public entry: IndexEntry public entry: IndexEntry
) { ) {
this.id = entry.id; this.id = entry.id;
this.legacy = entry.type !== 'docs' || entry.legacy; this.legacy = entry.type !== 'docs' || !!entry.legacy;
} }
// The two story "renders" are equal and have both loaded the same story // The two story "renders" are equal and have both loaded the same story
@ -105,6 +107,8 @@ export class DocsRender<TFramework extends AnyFramework> implements Render<TFram
}; };
} }
if (!this.csfFiles) throw new Error('getDocsContext called before prepare');
let metaCsfFile: ModuleExports; let metaCsfFile: ModuleExports;
const exportToStoryId = new Map<ModuleExport, StoryId>(); const exportToStoryId = new Map<ModuleExport, StoryId>();
const storyIdToCSFFile = new Map<StoryId, CSFFile<TFramework>>(); const storyIdToCSFFile = new Map<StoryId, CSFFile<TFramework>>();
@ -126,16 +130,18 @@ export class DocsRender<TFramework extends AnyFramework> implements Render<TFram
return { return {
...base, ...base,
storyIdByModuleExport: (moduleExport) => { storyIdByModuleExport: (moduleExport) => {
if (exportToStoryId.has(moduleExport)) return exportToStoryId.get(moduleExport); const storyId = exportToStoryId.get(moduleExport);
if (storyId) return storyId;
throw new Error(`No story found with that export: ${moduleExport}`); throw new Error(`No story found with that export: ${moduleExport}`);
}, },
storyById, storyById,
componentStories: () => { componentStories: () => {
return Object.entries(metaCsfFile) return (
.map(([_, moduleExport]) => exportToStoryId.get(moduleExport)) Object.entries(metaCsfFile)
.filter(Boolean) .map(([_, moduleExport]) => exportToStoryId.get(moduleExport))
.map(storyById); .filter(Boolean) as StoryId[]
).map(storyById);
}, },
setMeta(m: ModuleExports) { setMeta(m: ModuleExports) {
metaCsfFile = m; metaCsfFile = m;
@ -154,10 +160,15 @@ export class DocsRender<TFramework extends AnyFramework> implements Render<TFram
} }
async render() { async render() {
if (!(this.story || this.exports) || !this.docsContext || !this.canvasElement) if (
!(this.story || this.exports) ||
!this.docsContext ||
!this.canvasElement ||
!this.store.projectAnnotations
)
throw new Error('DocsRender not ready to render'); throw new Error('DocsRender not ready to render');
const { docs } = this.story?.parameters || this.store.projectAnnotations.parameters; const { docs } = this.story?.parameters || this.store.projectAnnotations.parameters || {};
if (!docs) { if (!docs) {
throw new Error( throw new Error(
@ -170,7 +181,8 @@ export class DocsRender<TFramework extends AnyFramework> implements Render<TFram
this.docsContext, this.docsContext,
{ {
...docs, ...docs,
...(!this.legacy && { page: this.exports.default }), // exports must be defined in non-legacy mode (see check at top)
...(!this.legacy && { page: this.exports!.default }),
}, },
this.canvasElement, this.canvasElement,
() => this.channel.emit(DOCS_RENDERED, this.id) () => this.channel.emit(DOCS_RENDERED, this.id)
@ -178,6 +190,7 @@ export class DocsRender<TFramework extends AnyFramework> implements Render<TFram
this.teardown = async ({ viewModeChanged }: { viewModeChanged?: boolean } = {}) => { this.teardown = async ({ viewModeChanged }: { viewModeChanged?: boolean } = {}) => {
if (!viewModeChanged || !this.canvasElement) return; if (!viewModeChanged || !this.canvasElement) return;
renderer.unmount(this.canvasElement); renderer.unmount(this.canvasElement);
this.torndown = true;
}; };
} }

View File

@ -46,7 +46,7 @@ export class Preview<TFramework extends AnyFramework> {
importFn?: ModuleImportFn; importFn?: ModuleImportFn;
renderToDOM: RenderToDOM<TFramework>; renderToDOM?: RenderToDOM<TFramework>;
storyRenders: StoryRender<TFramework>[] = []; storyRenders: StoryRender<TFramework>[] = [];
@ -156,6 +156,8 @@ export class Preview<TFramework extends AnyFramework> {
} }
emitGlobals() { emitGlobals() {
if (!this.storyStore.globals || !this.storyStore.projectAnnotations)
throw new Error(`Cannot emit before initialization`);
this.channel.emit(SET_GLOBALS, { this.channel.emit(SET_GLOBALS, {
globals: this.storyStore.globals.get() || {}, globals: this.storyStore.globals.get() || {},
globalTypes: this.storyStore.projectAnnotations.globalTypes || {}, globalTypes: this.storyStore.projectAnnotations.globalTypes || {},
@ -171,6 +173,9 @@ export class Preview<TFramework extends AnyFramework> {
// If initialization gets as far as the story index, this function runs. // If initialization gets as far as the story index, this function runs.
initializeWithStoryIndex(storyIndex: StoryIndex): PromiseLike<void> { initializeWithStoryIndex(storyIndex: StoryIndex): PromiseLike<void> {
if (!this.importFn)
throw new Error(`Cannot call initializeWithStoryIndex before initialization`);
return this.storyStore.initialize({ return this.storyStore.initialize({
storyIndex, storyIndex,
importFn: this.importFn, importFn: this.importFn,
@ -218,7 +223,7 @@ export class Preview<TFramework extends AnyFramework> {
// Update the store with the new stories. // Update the store with the new stories.
await this.onStoriesChanged({ storyIndex }); await this.onStoriesChanged({ storyIndex });
} catch (err) { } catch (err) {
this.renderPreviewEntryError('Error loading story index:', err); this.renderPreviewEntryError('Error loading story index:', err as Error);
throw err; throw err;
} }
} }
@ -235,6 +240,8 @@ export class Preview<TFramework extends AnyFramework> {
} }
async onUpdateGlobals({ globals }: { globals: Globals }) { async onUpdateGlobals({ globals }: { globals: Globals }) {
if (!this.storyStore.globals)
throw new Error(`Cannot call onUpdateGlobals before initialization`);
this.storyStore.globals.update(globals); this.storyStore.globals.update(globals);
await Promise.all(this.storyRenders.map((r) => r.rerender())); await Promise.all(this.storyRenders.map((r) => r.rerender()));
@ -295,6 +302,9 @@ export class Preview<TFramework extends AnyFramework> {
// we will change it to go ahead and load the story, which will end up being // we will change it to go ahead and load the story, which will end up being
// "instant", although async. // "instant", although async.
renderStoryToElement(story: Story<TFramework>, element: HTMLElement) { renderStoryToElement(story: Story<TFramework>, element: HTMLElement) {
if (!this.renderToDOM)
throw new Error(`Cannot call renderStoryToElement before initialization`);
const render = new StoryRender<TFramework>( const render = new StoryRender<TFramework>(
this.channel, this.channel,
this.storyStore, this.storyStore,
@ -318,7 +328,7 @@ export class Preview<TFramework extends AnyFramework> {
{ viewModeChanged }: { viewModeChanged?: boolean } = {} { viewModeChanged }: { viewModeChanged?: boolean } = {}
) { ) {
this.storyRenders = this.storyRenders.filter((r) => r !== render); this.storyRenders = this.storyRenders.filter((r) => r !== render);
await render?.teardown({ viewModeChanged }); await render?.teardown?.({ viewModeChanged });
} }
// API // API

View File

@ -3,8 +3,11 @@ import global from 'global';
import { RenderContext } from '@storybook/store'; import { RenderContext } from '@storybook/store';
import addons, { mockChannel as createMockChannel } from '@storybook/addons'; import addons, { mockChannel as createMockChannel } from '@storybook/addons';
import { DocsRenderer } from '@storybook/addon-docs'; import { DocsRenderer } from '@storybook/addon-docs';
import { mocked } from 'ts-jest/utils';
import { expect } from '@jest/globals';
import { PreviewWeb } from './PreviewWeb'; import { PreviewWeb } from './PreviewWeb';
import { WebView } from './WebView';
import { import {
componentOneExports, componentOneExports,
importFn, importFn,
@ -56,6 +59,9 @@ beforeEach(() => {
addons.setChannel(mockChannel as any); addons.setChannel(mockChannel as any);
addons.setServerChannel(createMockChannel()); addons.setServerChannel(createMockChannel());
mocked(WebView.prototype).prepareForDocs.mockReturnValue('docs-element' as any);
mocked(WebView.prototype).prepareForStory.mockReturnValue('story-element' as any);
}); });
describe('PreviewWeb', () => { describe('PreviewWeb', () => {

View File

@ -28,6 +28,8 @@ import { logger } from '@storybook/client-logger';
import { addons, mockChannel as createMockChannel } from '@storybook/addons'; import { addons, mockChannel as createMockChannel } from '@storybook/addons';
import type { AnyFramework } from '@storybook/csf'; import type { AnyFramework } from '@storybook/csf';
import type { ModuleImportFn, WebProjectAnnotations } from '@storybook/store'; import type { ModuleImportFn, WebProjectAnnotations } from '@storybook/store';
import { expect } from '@jest/globals';
import { mocked } from 'ts-jest/utils';
import { PreviewWeb } from './PreviewWeb'; import { PreviewWeb } from './PreviewWeb';
import { import {
@ -48,8 +50,8 @@ import {
modernDocsExports, modernDocsExports,
teardownRenderToDOM, teardownRenderToDOM,
} from './PreviewWeb.mockdata'; } from './PreviewWeb.mockdata';
import { WebView } from './WebView';
jest.mock('./WebView');
const { history, document } = global; const { history, document } = global;
const mockStoryIndex = jest.fn(() => storyIndex); const mockStoryIndex = jest.fn(() => storyIndex);
@ -79,6 +81,7 @@ jest.mock('global', () => ({
jest.mock('@storybook/client-logger'); jest.mock('@storybook/client-logger');
jest.mock('react-dom'); jest.mock('react-dom');
jest.mock('./WebView');
const createGate = (): [Promise<any | undefined>, (_?: any) => void] => { const createGate = (): [Promise<any | undefined>, (_?: any) => void] => {
let openGate = (_?: any) => {}; let openGate = (_?: any) => {};
@ -104,9 +107,6 @@ async function createAndRenderPreview({
getProjectAnnotations?: () => WebProjectAnnotations<AnyFramework>; getProjectAnnotations?: () => WebProjectAnnotations<AnyFramework>;
} = {}) { } = {}) {
const preview = new PreviewWeb(); const preview = new PreviewWeb();
(
preview.view.prepareForDocs as jest.MockedFunction<typeof preview.view.prepareForDocs>
).mockReturnValue('docs-element' as any);
await preview.initialize({ await preview.initialize({
importFn: inputImportFn, importFn: inputImportFn,
getProjectAnnotations: inputGetProjectAnnotations, getProjectAnnotations: inputGetProjectAnnotations,
@ -134,6 +134,9 @@ beforeEach(() => {
addons.setChannel(mockChannel as any); addons.setChannel(mockChannel as any);
addons.setServerChannel(createMockChannel()); addons.setServerChannel(createMockChannel());
mockFetchResult = { status: 200, json: mockStoryIndex, text: () => 'error text' }; mockFetchResult = { status: 200, json: mockStoryIndex, text: () => 'error text' };
mocked(WebView.prototype).prepareForDocs.mockReturnValue('docs-element' as any);
mocked(WebView.prototype).prepareForStory.mockReturnValue('story-element' as any);
}); });
describe('PreviewWeb', () => { describe('PreviewWeb', () => {
@ -172,7 +175,7 @@ describe('PreviewWeb', () => {
const preview = await createAndRenderPreview(); const preview = await createAndRenderPreview();
expect(preview.storyStore.globals.get()).toEqual({ a: 'c' }); expect(preview.storyStore.globals!.get()).toEqual({ a: 'c' });
}); });
it('emits the SET_GLOBALS event', async () => { it('emits the SET_GLOBALS event', async () => {
@ -233,7 +236,7 @@ describe('PreviewWeb', () => {
}, },
}); });
expect(preview.storyStore.globals.get()).toEqual({ a: 'b' }); expect(preview.storyStore.globals!.get()).toEqual({ a: 'b' });
}); });
}); });
@ -456,7 +459,7 @@ describe('PreviewWeb', () => {
loaded: { l: 7 }, loaded: { l: 7 },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -487,26 +490,27 @@ describe('PreviewWeb', () => {
}); });
it('renders helpful message if renderToDOM is undefined', async () => { it('renders helpful message if renderToDOM is undefined', async () => {
const originalRenderToDOM = projectAnnotations.renderToDOM; document.location.search = '?id=component-one--a';
try { const preview = new PreviewWeb();
projectAnnotations.renderToDOM = undefined; await expect(
preview.initialize({
importFn,
getProjectAnnotations: () => ({
...getProjectAnnotations,
renderToDOM: undefined,
}),
})
).rejects.toThrow();
document.location.search = '?id=component-one--a'; expect(preview.view.showErrorDisplay).toHaveBeenCalled();
const preview = new PreviewWeb(); expect((preview.view.showErrorDisplay as jest.Mock).mock.calls[0][0])
await expect(preview.initialize({ importFn, getProjectAnnotations })).rejects.toThrow(); .toMatchInlineSnapshot(`
expect(preview.view.showErrorDisplay).toHaveBeenCalled();
expect((preview.view.showErrorDisplay as jest.Mock).mock.calls[0][0])
.toMatchInlineSnapshot(`
[Error: Expected your framework's preset to export a \`renderToDOM\` field. [Error: Expected your framework's preset to export a \`renderToDOM\` field.
Perhaps it needs to be upgraded for Storybook 6.4? Perhaps it needs to be upgraded for Storybook 6.4?
More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field ] More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field ]
`); `);
} finally {
projectAnnotations.renderToDOM = originalRenderToDOM;
}
}); });
it('renders exception if the play function throws', async () => { it('renders exception if the play function throws', async () => {
@ -705,7 +709,7 @@ describe('PreviewWeb', () => {
emitter.emit(UPDATE_GLOBALS, { globals: { foo: 'bar' } }); emitter.emit(UPDATE_GLOBALS, { globals: { foo: 'bar' } });
expect(preview.storyStore.globals.get()).toEqual({ a: 'b', foo: 'bar' }); expect(preview.storyStore.globals!.get()).toEqual({ a: 'b', foo: 'bar' });
}); });
it('passes new globals in context to renderToDOM', async () => { it('passes new globals in context to renderToDOM', async () => {
@ -724,7 +728,7 @@ describe('PreviewWeb', () => {
globals: { a: 'b', foo: 'bar' }, globals: { a: 'b', foo: 'bar' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -807,7 +811,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a', new: 'arg' }, args: { foo: 'a', new: 'arg' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -864,7 +868,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a', new: 'arg' }, args: { foo: 'a', new: 'arg' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
// Now let the first loader call resolve // Now let the first loader call resolve
@ -883,7 +887,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a', new: 'arg' }, args: { foo: 'a', new: 'arg' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -914,7 +918,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a' }, args: { foo: 'a' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith( expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -924,7 +928,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a', new: 'arg' }, args: { foo: 'a', new: 'arg' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -947,7 +951,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a' }, args: { foo: 'a' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith( expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -957,7 +961,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a', new: 'arg' }, args: { foo: 'a', new: 'arg' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -985,7 +989,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a' }, args: { foo: 'a' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
emitter.emit(UPDATE_STORY_ARGS, { emitter.emit(UPDATE_STORY_ARGS, {
@ -1005,7 +1009,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a', new: 'arg' }, args: { foo: 'a', new: 'arg' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
// Now let the playFunction call resolve // Now let the playFunction call resolve
@ -1172,7 +1176,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a', new: 'value' }, args: { foo: 'a', new: 'value' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
await waitForEvents([STORY_ARGS_UPDATED]); await waitForEvents([STORY_ARGS_UPDATED]);
@ -1213,7 +1217,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a' }, args: { foo: 'a' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
await waitForEvents([STORY_ARGS_UPDATED]); await waitForEvents([STORY_ARGS_UPDATED]);
@ -1254,7 +1258,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a' }, args: { foo: 'a' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
await waitForEvents([STORY_ARGS_UPDATED]); await waitForEvents([STORY_ARGS_UPDATED]);
@ -1295,7 +1299,7 @@ describe('PreviewWeb', () => {
args: { foo: 'a' }, args: { foo: 'a' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
await waitForEvents([STORY_ARGS_UPDATED]); await waitForEvents([STORY_ARGS_UPDATED]);
@ -1323,7 +1327,7 @@ describe('PreviewWeb', () => {
expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith( expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
expect.objectContaining({ forceRemount: false }), expect.objectContaining({ forceRemount: false }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
}); });
@ -1347,7 +1351,7 @@ describe('PreviewWeb', () => {
expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith( expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
expect.objectContaining({ forceRemount: true }), expect.objectContaining({ forceRemount: true }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -1367,7 +1371,7 @@ describe('PreviewWeb', () => {
loaded: { l: 7 }, loaded: { l: 7 },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
mockChannel.emit.mockClear(); mockChannel.emit.mockClear();
@ -1723,7 +1727,7 @@ describe('PreviewWeb', () => {
loaded: { l: 7 }, loaded: { l: 7 },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -1888,7 +1892,7 @@ describe('PreviewWeb', () => {
loaded: { l: 7 }, loaded: { l: 7 },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -1942,7 +1946,7 @@ describe('PreviewWeb', () => {
loaded: { l: 7 }, loaded: { l: 7 },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
mockChannel.emit.mockClear(); mockChannel.emit.mockClear();
@ -1967,7 +1971,7 @@ describe('PreviewWeb', () => {
loaded: { l: 7 }, loaded: { l: 7 },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
await waitForRenderPhase('playing'); await waitForRenderPhase('playing');
@ -1995,7 +1999,7 @@ describe('PreviewWeb', () => {
loaded: { l: 7 }, loaded: { l: 7 },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
mockChannel.emit.mockClear(); mockChannel.emit.mockClear();
@ -2284,7 +2288,7 @@ describe('PreviewWeb', () => {
loaded: { l: 7 }, loaded: { l: 7 },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -2557,7 +2561,7 @@ describe('PreviewWeb', () => {
loaded: { l: 7 }, loaded: { l: 7 },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -2585,7 +2589,7 @@ describe('PreviewWeb', () => {
args: { foo: 'updated' }, args: { foo: 'updated' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
@ -2846,7 +2850,7 @@ describe('PreviewWeb', () => {
args: { foo: 'updated', bar: 'edited' }, args: { foo: 'updated', bar: 'edited' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
}); });
@ -3071,7 +3075,7 @@ describe('PreviewWeb', () => {
globals: { a: 'edited' }, globals: { a: 'edited' },
}), }),
}), }),
undefined // this is coming from view.prepareForStory, not super important 'story-element'
); );
}); });
}); });

View File

@ -20,7 +20,7 @@ import {
UPDATE_QUERY_PARAMS, UPDATE_QUERY_PARAMS,
} from '@storybook/core-events'; } from '@storybook/core-events';
import { logger } from '@storybook/client-logger'; import { logger } from '@storybook/client-logger';
import { AnyFramework, StoryId, ProjectAnnotations, Args, Globals } from '@storybook/csf'; import { AnyFramework, StoryId, ProjectAnnotations, Args, Globals, ViewMode } from '@storybook/csf';
import type { import type {
ModuleImportFn, ModuleImportFn,
Selection, Selection,
@ -54,9 +54,9 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
previewEntryError?: Error; previewEntryError?: Error;
currentSelection: Selection; currentSelection?: Selection;
currentRender: StoryRender<TFramework> | DocsRender<TFramework>; currentRender?: StoryRender<TFramework> | DocsRender<TFramework>;
constructor() { constructor() {
super(); super();
@ -93,6 +93,9 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
} }
async setInitialGlobals() { async setInitialGlobals() {
if (!this.storyStore.globals)
throw new Error(`Cannot call setInitialGlobals before initialization`);
const { globals } = this.urlStore.selectionSpecifier || {}; const { globals } = this.urlStore.selectionSpecifier || {};
if (globals) { if (globals) {
this.storyStore.globals.updateFromPersisted(globals); this.storyStore.globals.updateFromPersisted(globals);
@ -113,6 +116,9 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
// Use the selection specifier to choose a story, then render it // Use the selection specifier to choose a story, then render it
async selectSpecifiedStory() { async selectSpecifiedStory() {
if (!this.storyStore.storyIndex)
throw new Error(`Cannot call selectSpecifiedStory before initialization`);
if (!this.urlStore.selectionSpecifier) { if (!this.urlStore.selectionSpecifier) {
this.renderMissingStory(); this.renderMissingStory();
return; return;
@ -199,7 +205,7 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
} }
} }
onSetCurrentStory(selection: Selection) { onSetCurrentStory(selection: { storyId: StoryId; viewMode?: ViewMode }) {
this.urlStore.setSelection({ viewMode: 'story', ...selection }); this.urlStore.setSelection({ viewMode: 'story', ...selection });
this.channel.emit(CURRENT_STORY_WAS_SET, this.urlStore.selection); this.channel.emit(CURRENT_STORY_WAS_SET, this.urlStore.selection);
this.renderSelection(); this.renderSelection();
@ -240,18 +246,19 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
// - a story selected in "docs" viewMode, // - a story selected in "docs" viewMode,
// in which case we render the docsPage for that story // in which case we render the docsPage for that story
async renderSelection({ persistedArgs }: { persistedArgs?: Args } = {}) { async renderSelection({ persistedArgs }: { persistedArgs?: Args } = {}) {
const { renderToDOM } = this;
if (!renderToDOM) throw new Error('Cannot call renderSelection before initialization');
const { selection } = this.urlStore; const { selection } = this.urlStore;
if (!selection) { if (!selection) throw new Error('Cannot call renderSelection as no selection was made');
throw new Error('Cannot render story as no selection was made');
}
const { storyId } = selection; const { storyId } = selection;
let entry; let entry;
try { try {
entry = await this.storyStore.storyIdToEntry(storyId); entry = await this.storyStore.storyIdToEntry(storyId);
} catch (err) { } catch (err) {
await this.teardownRender(this.currentRender); if (this.currentRender) await this.teardownRender(this.currentRender);
this.renderStoryLoadingException(storyId, err); this.renderStoryLoadingException(storyId, err as Error);
return;
} }
// Docs entries cannot be rendered in 'story' viewMode. // Docs entries cannot be rendered in 'story' viewMode.
@ -268,18 +275,14 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
this.view.showPreparingDocs(); this.view.showPreparingDocs();
} }
const lastSelection = this.currentSelection;
let lastRender = this.currentRender;
// If the last render is still preparing, let's drop it right now. Either // If the last render is still preparing, let's drop it right now. Either
// (a) it is a different story, which means we would drop it later, OR // (a) it is a different story, which means we would drop it later, OR
// (b) it is the *same* story, in which case we will resolve our own .prepare() at the // (b) it is the *same* story, in which case we will resolve our own .prepare() at the
// same moment anyway, and we should just "take over" the rendering. // same moment anyway, and we should just "take over" the rendering.
// (We can't tell which it is yet, because it is possible that an HMR is going on and // (We can't tell which it is yet, because it is possible that an HMR is going on and
// even though the storyId is the same, the story itself is not). // even though the storyId is the same, the story itself is not).
if (lastRender?.isPreparing()) { if (this.currentRender?.isPreparing()) {
await this.teardownRender(lastRender); await this.teardownRender(this.currentRender);
lastRender = null;
} }
let render; let render;
@ -290,7 +293,7 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
(...args) => { (...args) => {
// At the start of renderToDOM we make the story visible (see note in WebView) // At the start of renderToDOM we make the story visible (see note in WebView)
this.view.showStoryDuringRender(); this.view.showStoryDuringRender();
return this.renderToDOM(...args); return renderToDOM(...args);
}, },
this.mainStoryCallbacks(storyId), this.mainStoryCallbacks(storyId),
storyId, storyId,
@ -302,7 +305,10 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
// We need to store this right away, so if the story changes during // We need to store this right away, so if the story changes during
// the async `.prepare()` below, we can (potentially) cancel it // the async `.prepare()` below, we can (potentially) cancel it
const lastSelection = this.currentSelection;
this.currentSelection = selection; this.currentSelection = selection;
const lastRender = this.currentRender;
this.currentRender = render; this.currentRender = render;
try { try {
@ -311,18 +317,26 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
if (err !== PREPARE_ABORTED) { if (err !== PREPARE_ABORTED) {
// We are about to render an error so make sure the previous story is // We are about to render an error so make sure the previous story is
// no longer rendered. // no longer rendered.
await this.teardownRender(lastRender); if (lastRender) await this.teardownRender(lastRender);
this.renderStoryLoadingException(storyId, err); this.renderStoryLoadingException(storyId, err as Error);
} }
return; return;
} }
const implementationChanged = !storyIdChanged && !render.isEqual(lastRender); const implementationChanged = !storyIdChanged && lastRender && !render.isEqual(lastRender);
if (persistedArgs && entry.type !== 'docs') if (persistedArgs && entry.type !== 'docs') {
if (!render.story) throw new Error('Render has not been prepared!');
this.storyStore.args.updateFromPersisted(render.story, persistedArgs); this.storyStore.args.updateFromPersisted(render.story, persistedArgs);
}
// Don't re-render the story if nothing has changed to justify it // Don't re-render the story if nothing has changed to justify it
if (lastRender && !storyIdChanged && !implementationChanged && !viewModeChanged) { if (
lastRender &&
!lastRender.torndown &&
!storyIdChanged &&
!implementationChanged &&
!viewModeChanged
) {
this.currentRender = lastRender; this.currentRender = lastRender;
this.channel.emit(STORY_UNCHANGED, storyId); this.channel.emit(STORY_UNCHANGED, storyId);
this.view.showMain(); this.view.showMain();
@ -331,7 +345,7 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
// Wait for the previous render to leave the page. NOTE: this will wait to ensure anything async // Wait for the previous render to leave the page. NOTE: this will wait to ensure anything async
// is properly aborted, which (in some cases) can lead to the whole screen being refreshed. // is properly aborted, which (in some cases) can lead to the whole screen being refreshed.
await this.teardownRender(lastRender, { viewModeChanged }); if (lastRender) await this.teardownRender(lastRender, { viewModeChanged });
// If we are rendering something new (as opposed to re-rendering the same or first story), emit // If we are rendering something new (as opposed to re-rendering the same or first story), emit
if (lastSelection && (storyIdChanged || viewModeChanged)) { if (lastSelection && (storyIdChanged || viewModeChanged)) {
@ -339,6 +353,7 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
} }
if (entry.type !== 'docs') { if (entry.type !== 'docs') {
if (!render.story) throw new Error('Render has not been prepared!');
const { parameters, initialArgs, argTypes, args } = this.storyStore.getStoryContext( const { parameters, initialArgs, argTypes, args } = this.storyStore.getStoryContext(
render.story render.story
); );
@ -367,6 +382,7 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
this.renderStoryToElement.bind(this) this.renderStoryToElement.bind(this)
); );
} else { } else {
if (!render.story) throw new Error('Render has not been prepared!');
this.storyRenders.push(render as StoryRender<TFramework>); this.storyRenders.push(render as StoryRender<TFramework>);
(this.currentRender as StoryRender<TFramework>).renderToElement( (this.currentRender as StoryRender<TFramework>).renderToElement(
this.view.prepareForStory(render.story) this.view.prepareForStory(render.story)
@ -380,6 +396,9 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
// we will change it to go ahead and load the story, which will end up being // we will change it to go ahead and load the story, which will end up being
// "instant", although async. // "instant", although async.
renderStoryToElement(story: Story<TFramework>, element: HTMLElement) { renderStoryToElement(story: Story<TFramework>, element: HTMLElement) {
if (!this.renderToDOM)
throw new Error(`Cannot call renderStoryToElement before initialization`);
const render = new StoryRender<TFramework>( const render = new StoryRender<TFramework>(
this.channel, this.channel,
this.storyStore, this.storyStore,
@ -400,10 +419,10 @@ export class PreviewWeb<TFramework extends AnyFramework> extends Preview<TFramew
async teardownRender( async teardownRender(
render: Render<TFramework>, render: Render<TFramework>,
{ viewModeChanged }: { viewModeChanged?: boolean } = {} { viewModeChanged = false }: { viewModeChanged?: boolean } = {}
) { ) {
this.storyRenders = this.storyRenders.filter((r) => r !== render); this.storyRenders = this.storyRenders.filter((r) => r !== render);
await render?.teardown({ viewModeChanged }); await render?.teardown?.({ viewModeChanged });
} }
// API // API

View File

@ -54,7 +54,8 @@ export interface Render<TFramework extends AnyFramework> {
story?: Story<TFramework>; story?: Story<TFramework>;
isPreparing: () => boolean; isPreparing: () => boolean;
disableKeyListeners: boolean; disableKeyListeners: boolean;
teardown: (options: { viewModeChanged: boolean }) => Promise<void>; teardown?: (options: { viewModeChanged: boolean }) => Promise<void>;
torndown: boolean;
renderToElement: (canvasElement: HTMLElement, renderStoryToElement?: any) => Promise<void>; renderToElement: (canvasElement: HTMLElement, renderStoryToElement?: any) => Promise<void>;
} }
@ -75,6 +76,8 @@ export class StoryRender<TFramework extends AnyFramework> implements Render<TFra
private teardownRender: TeardownRenderToDOM = () => {}; private teardownRender: TeardownRenderToDOM = () => {};
public torndown = false;
constructor( constructor(
public channel: Channel, public channel: Channel,
public store: StoryStore<TFramework>, public store: StoryStore<TFramework>,
@ -143,6 +146,7 @@ export class StoryRender<TFramework extends AnyFramework> implements Render<TFra
} }
private storyContext() { private storyContext() {
if (!this.story) throw new Error(`Cannot call storyContext before preparing`);
return this.store.getStoryContext(this.story); return this.store.getStoryContext(this.story);
} }
@ -153,7 +157,10 @@ export class StoryRender<TFramework extends AnyFramework> implements Render<TFra
initial?: boolean; initial?: boolean;
forceRemount?: boolean; forceRemount?: boolean;
} = {}) { } = {}) {
const { canvasElement } = this;
if (!this.story) throw new Error('cannot render when not prepared'); if (!this.story) throw new Error('cannot render when not prepared');
if (!canvasElement) throw new Error('cannot render when canvasElement is unset');
const { id, componentId, title, name, applyLoaders, unboundStoryFn, playFunction } = this.story; const { id, componentId, title, name, applyLoaders, unboundStoryFn, playFunction } = this.story;
if (forceRemount && !initial) { if (forceRemount && !initial) {
@ -169,7 +176,7 @@ export class StoryRender<TFramework extends AnyFramework> implements Render<TFra
const abortSignal = (this.abortController as AbortController).signal; const abortSignal = (this.abortController as AbortController).signal;
try { try {
let loadedContext: StoryContext<TFramework>; let loadedContext: Awaited<ReturnType<typeof applyLoaders>>;
await this.runPhase(abortSignal, 'loading', async () => { await this.runPhase(abortSignal, 'loading', async () => {
loadedContext = await applyLoaders({ loadedContext = await applyLoaders({
...this.storyContext(), ...this.storyContext(),
@ -181,13 +188,12 @@ export class StoryRender<TFramework extends AnyFramework> implements Render<TFra
} }
const renderStoryContext: StoryContext<TFramework> = { const renderStoryContext: StoryContext<TFramework> = {
// @ts-ignore ...loadedContext!,
...loadedContext,
// By this stage, it is possible that new args/globals have been received for this story // By this stage, it is possible that new args/globals have been received for this story
// and we need to ensure we render it with the new values // and we need to ensure we render it with the new values
...this.storyContext(), ...this.storyContext(),
abortSignal, abortSignal,
canvasElement: this.canvasElement as HTMLElement, canvasElement,
}; };
const renderContext: RenderContext<TFramework> = { const renderContext: RenderContext<TFramework> = {
componentId, componentId,
@ -205,7 +211,7 @@ export class StoryRender<TFramework extends AnyFramework> implements Render<TFra
await this.runPhase(abortSignal, 'rendering', async () => { await this.runPhase(abortSignal, 'rendering', async () => {
this.teardownRender = this.teardownRender =
(await this.renderToScreen(renderContext, this.canvasElement)) || (() => {}); (await this.renderToScreen(renderContext, canvasElement)) || (() => {});
}); });
this.notYetRendered = false; this.notYetRendered = false;
if (abortSignal.aborted) return; if (abortSignal.aborted) return;
@ -242,12 +248,11 @@ export class StoryRender<TFramework extends AnyFramework> implements Render<TFra
// as a method to abort them, ASAP, but this is not foolproof as we cannot control what // as a method to abort them, ASAP, but this is not foolproof as we cannot control what
// happens inside the user's code. // happens inside the user's code.
cancelRender() { cancelRender() {
if (this.abortController) { this.abortController?.abort();
this.abortController.abort();
}
} }
async teardown(options: {} = {}) { async teardown(options: {} = {}) {
this.torndown = true;
this.cancelRender(); this.cancelRender();
// If the story has loaded, we need to cleanup // If the story has loaded, we need to cleanup

View File

@ -61,8 +61,7 @@ const getFirstString = (v: ValueOf<qs.ParsedQs>): string | void => {
return getFirstString(v[0]); return getFirstString(v[0]);
} }
if (isObject(v)) { if (isObject(v)) {
// @ts-ignore return getFirstString(Object.values(v).filter(Boolean) as string[]);
return getFirstString(Object.values(v));
} }
return undefined; return undefined;
}; };
@ -74,7 +73,7 @@ Use \`id=$storyId\` instead.
See https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#new-url-structure` See https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#new-url-structure`
); );
export const getSelectionSpecifierFromPath: () => SelectionSpecifier = () => { export const getSelectionSpecifierFromPath: () => SelectionSpecifier | null = () => {
const query = qs.parse(document.location.search, { ignoreQueryPrefix: true }); const query = qs.parse(document.location.search, { ignoreQueryPrefix: true });
const args = typeof query.args === 'string' ? parseArgsParam(query.args) : undefined; const args = typeof query.args === 'string' ? parseArgsParam(query.args) : undefined;
const globals = typeof query.globals === 'string' ? parseArgsParam(query.globals) : undefined; const globals = typeof query.globals === 'string' ? parseArgsParam(query.globals) : undefined;
@ -103,9 +102,9 @@ export const getSelectionSpecifierFromPath: () => SelectionSpecifier = () => {
}; };
export class UrlStore { export class UrlStore {
selectionSpecifier: SelectionSpecifier; selectionSpecifier: SelectionSpecifier | null;
selection: Selection; selection?: Selection;
constructor() { constructor() {
this.selectionSpecifier = getSelectionSpecifierFromPath(); this.selectionSpecifier = getSelectionSpecifierFromPath();

View File

@ -41,7 +41,7 @@ export class WebView {
testing = false; testing = false;
preparingTimeout: ReturnType<typeof setTimeout> = null; preparingTimeout?: ReturnType<typeof setTimeout>;
constructor() { constructor() {
// Special code for testing situations // Special code for testing situations

View File

@ -25,7 +25,8 @@ const validateArgs = (key = '', value: unknown): boolean => {
); );
} }
if (Array.isArray(value)) return value.every((v) => validateArgs(key, v)); if (Array.isArray(value)) return value.every((v) => validateArgs(key, v));
if (isPlainObject(value)) return Object.entries(value).every(([k, v]) => validateArgs(k, v)); if (isPlainObject(value))
return Object.entries(value as object).every(([k, v]) => validateArgs(k, v));
return false; return false;
}; };

View File

@ -1,6 +1,7 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"strict": true
}, },
"include": [ "include": [
"src/**/*" "src/**/*"

View File

@ -1,3 +1,5 @@
import { expect } from '@jest/globals';
import { ArgsStore } from './ArgsStore'; import { ArgsStore } from './ArgsStore';
jest.mock('@storybook/client-logger'); jest.mock('@storybook/client-logger');

View File

@ -1,9 +1,9 @@
import { expect } from '@jest/globals';
import { GlobalsStore } from './GlobalsStore'; import { GlobalsStore } from './GlobalsStore';
describe('GlobalsStore', () => { describe('GlobalsStore', () => {
it('is initialized to the value in globals', () => { it('is initialized to the value in globals', () => {
const store = new GlobalsStore(); const store = new GlobalsStore({
store.set({
globals: { globals: {
arg1: 'arg1', arg1: 'arg1',
arg2: 2, arg2: 2,
@ -20,8 +20,7 @@ describe('GlobalsStore', () => {
}); });
it('is initialized to the default values from globalTypes if global is unset', () => { it('is initialized to the default values from globalTypes if global is unset', () => {
const store = new GlobalsStore(); const store = new GlobalsStore({
store.set({
globals: { globals: {
arg1: 'arg1', arg1: 'arg1',
arg2: 2, arg2: 2,
@ -42,8 +41,7 @@ describe('GlobalsStore', () => {
describe('update', () => { describe('update', () => {
it('changes the global args', () => { it('changes the global args', () => {
const store = new GlobalsStore(); const store = new GlobalsStore({ globals: { foo: 'old' }, globalTypes: { baz: {} } });
store.set({ globals: { foo: 'old' }, globalTypes: { baz: {} } });
store.update({ foo: 'bar' }); store.update({ foo: 'bar' });
expect(store.get()).toEqual({ foo: 'bar' }); expect(store.get()).toEqual({ foo: 'bar' });
@ -57,8 +55,7 @@ describe('GlobalsStore', () => {
}); });
it('does not merge objects', () => { it('does not merge objects', () => {
const store = new GlobalsStore(); const store = new GlobalsStore({ globals: {}, globalTypes: {} });
store.set({ globals: {}, globalTypes: {} });
store.update({ obj: { foo: 'bar' } }); store.update({ obj: { foo: 'bar' } });
expect(store.get()).toEqual({ obj: { foo: 'bar' } }); expect(store.get()).toEqual({ obj: { foo: 'bar' } });
@ -70,8 +67,7 @@ describe('GlobalsStore', () => {
describe('updateFromPersisted', () => { describe('updateFromPersisted', () => {
it('only sets values for which globals or globalArgs exist', () => { it('only sets values for which globals or globalArgs exist', () => {
const store = new GlobalsStore(); const store = new GlobalsStore({
store.set({
globals: { globals: {
arg1: 'arg1', arg1: 'arg1',
}, },
@ -92,8 +88,7 @@ describe('GlobalsStore', () => {
describe('second call to set', () => { describe('second call to set', () => {
it('is initialized to the (new) default values from globalTypes if the (new) global is unset', () => { it('is initialized to the (new) default values from globalTypes if the (new) global is unset', () => {
const store = new GlobalsStore(); const store = new GlobalsStore({ globals: {}, globalTypes: {} });
store.set({ globals: {}, globalTypes: {} });
expect(store.get()).toEqual({}); expect(store.get()).toEqual({});
@ -118,8 +113,7 @@ describe('GlobalsStore', () => {
describe('when underlying globals have not changed', () => { describe('when underlying globals have not changed', () => {
it('retains updated values, but not if they are undeclared', () => { it('retains updated values, but not if they are undeclared', () => {
const store = new GlobalsStore(); const store = new GlobalsStore({
store.set({
globals: { globals: {
arg1: 'arg1', arg1: 'arg1',
}, },
@ -152,8 +146,7 @@ describe('GlobalsStore', () => {
describe('when underlying globals have changed', () => { describe('when underlying globals have changed', () => {
it('retains a the same delta', () => { it('retains a the same delta', () => {
const store = new GlobalsStore(); const store = new GlobalsStore({
store.set({
globals: { globals: {
arg1: 'arg1', arg1: 'arg1',
arg4: 'arg4', arg4: 'arg4',

View File

@ -14,11 +14,22 @@ const setUndeclaredWarning = deprecate(
); );
export class GlobalsStore { export class GlobalsStore {
allowedGlobalNames: Set<string>; // We use ! here because TS doesn't analyse the .set() function to see if it actually get set
allowedGlobalNames!: Set<string>;
initialGlobals: Globals; initialGlobals!: Globals;
globals: Globals = {}; globals!: Globals;
constructor({
globals = {},
globalTypes = {},
}: {
globals?: Globals;
globalTypes?: GlobalTypes;
}) {
this.set({ globals, globalTypes });
}
set({ globals = {}, globalTypes = {} }: { globals?: Globals; globalTypes?: GlobalTypes }) { set({ globals = {}, globalTypes = {} }: { globals?: Globals; globalTypes?: GlobalTypes }) {
const delta = this.initialGlobals && deepDiff(this.initialGlobals, this.globals); const delta = this.initialGlobals && deepDiff(this.initialGlobals, this.globals);

View File

@ -1,3 +1,5 @@
import { expect } from '@jest/globals';
import { StoryIndexStore } from './StoryIndexStore'; import { StoryIndexStore } from './StoryIndexStore';
import { StoryIndex } from './types'; import { StoryIndex } from './types';
@ -7,18 +9,21 @@ const storyIndex: StoryIndex = {
v: 4, v: 4,
entries: { entries: {
'component-one--a': { 'component-one--a': {
type: 'story',
id: 'component-one--a', id: 'component-one--a',
title: 'Component One', title: 'Component One',
name: 'A', name: 'A',
importPath: './src/ComponentOne.stories.js', importPath: './src/ComponentOne.stories.js',
}, },
'component-one--b': { 'component-one--b': {
type: 'story',
id: 'component-one--b', id: 'component-one--b',
title: 'Component One', title: 'Component One',
name: 'B', name: 'B',
importPath: './src/ComponentOne.stories.js', importPath: './src/ComponentOne.stories.js',
}, },
'component-two--c': { 'component-two--c': {
type: 'story',
id: 'component-one--c', id: 'component-one--c',
title: 'Component Two', title: 'Component Two',
name: 'C', name: 'C',

View File

@ -1,5 +1,4 @@
import dedent from 'ts-dedent'; import dedent from 'ts-dedent';
import { Channel } from '@storybook/addons';
import type { StoryId } from '@storybook/csf'; import type { StoryId } from '@storybook/csf';
import memoize from 'memoizerific'; import memoize from 'memoizerific';
@ -13,8 +12,6 @@ const getImportPathMap = memoize(1)((entries: StoryIndex['entries']) =>
); );
export class StoryIndexStore { export class StoryIndexStore {
channel: Channel;
entries: StoryIndex['entries']; entries: StoryIndex['entries'];
constructor({ entries }: StoryIndex = { v: 4, entries: {} }) { constructor({ entries }: StoryIndex = { v: 4, entries: {} }) {

View File

@ -1,5 +1,6 @@
import type { AnyFramework, ProjectAnnotations } from '@storybook/csf'; import type { AnyFramework, ProjectAnnotations } from '@storybook/csf';
import global from 'global'; import global from 'global';
import { expect } from '@jest/globals';
import { prepareStory } from './csf/prepareStory'; import { prepareStory } from './csf/prepareStory';
import { processCSFFile } from './csf/processCSFFile'; import { processCSFFile } from './csf/processCSFFile';
@ -76,10 +77,10 @@ describe('StoryStore', () => {
store.setProjectAnnotations(projectAnnotations); store.setProjectAnnotations(projectAnnotations);
store.initialize({ storyIndex, importFn, cache: false }); store.initialize({ storyIndex, importFn, cache: false });
expect(store.projectAnnotations.globalTypes).toEqual({ expect(store.projectAnnotations!.globalTypes).toEqual({
a: { name: 'a', type: { name: 'string' } }, a: { name: 'a', type: { name: 'string' } },
}); });
expect(store.projectAnnotations.argTypes).toEqual({ expect(store.projectAnnotations!.argTypes).toEqual({
a: { name: 'a', type: { name: 'string' } }, a: { name: 'a', type: { name: 'string' } },
}); });
}); });
@ -90,10 +91,10 @@ describe('StoryStore', () => {
store.initialize({ storyIndex, importFn, cache: false }); store.initialize({ storyIndex, importFn, cache: false });
store.setProjectAnnotations(projectAnnotations); store.setProjectAnnotations(projectAnnotations);
expect(store.projectAnnotations.globalTypes).toEqual({ expect(store.projectAnnotations!.globalTypes).toEqual({
a: { name: 'a', type: { name: 'string' } }, a: { name: 'a', type: { name: 'string' } },
}); });
expect(store.projectAnnotations.argTypes).toEqual({ expect(store.projectAnnotations!.argTypes).toEqual({
a: { name: 'a', type: { name: 'string' } }, a: { name: 'a', type: { name: 'string' } },
}); });
}); });
@ -408,7 +409,7 @@ describe('StoryStore', () => {
const story = await store.loadStory({ storyId: 'component-one--a' }); const story = await store.loadStory({ storyId: 'component-one--a' });
store.args.update(story.id, { foo: 'bar' }); store.args.update(story.id, { foo: 'bar' });
store.globals.update({ a: 'c' }); store.globals!.update({ a: 'c' });
expect(store.getStoryContext(story)).toMatchObject({ expect(store.getStoryContext(story)).toMatchObject({
args: { foo: 'bar' }, args: { foo: 'bar' },
@ -455,8 +456,9 @@ describe('StoryStore', () => {
importFn.mockClear(); importFn.mockClear();
const csfFiles = await store.loadAllCSFFiles(); const csfFiles = await store.loadAllCSFFiles();
expect(csfFiles).not.toBeUndefined();
expect(Object.keys(csfFiles)).toEqual([ expect(Object.keys(csfFiles!)).toEqual([
'./src/ComponentOne.stories.js', './src/ComponentOne.stories.js',
'./src/ComponentTwo.stories.js', './src/ComponentTwo.stories.js',
]); ]);

View File

@ -38,13 +38,13 @@ const CSF_CACHE_SIZE = 1000;
const STORY_CACHE_SIZE = 10000; const STORY_CACHE_SIZE = 10000;
export class StoryStore<TFramework extends AnyFramework> { export class StoryStore<TFramework extends AnyFramework> {
storyIndex: StoryIndexStore; storyIndex?: StoryIndexStore;
importFn: ModuleImportFn; importFn?: ModuleImportFn;
projectAnnotations: NormalizedProjectAnnotations<TFramework>; projectAnnotations?: NormalizedProjectAnnotations<TFramework>;
globals: GlobalsStore; globals?: GlobalsStore;
args: ArgsStore; args: ArgsStore;
@ -58,10 +58,10 @@ export class StoryStore<TFramework extends AnyFramework> {
initializationPromise: SynchronousPromise<void>; initializationPromise: SynchronousPromise<void>;
resolveInitializationPromise: () => void; // This *does* get set in the constructor but the semantics of `new SynchronousPromise` trip up TS
resolveInitializationPromise!: () => void;
constructor() { constructor() {
this.globals = new GlobalsStore();
this.args = new ArgsStore(); this.args = new ArgsStore();
this.hooks = {}; this.hooks = {};
@ -82,7 +82,11 @@ export class StoryStore<TFramework extends AnyFramework> {
this.projectAnnotations = normalizeProjectAnnotations(projectAnnotations); this.projectAnnotations = normalizeProjectAnnotations(projectAnnotations);
const { globals, globalTypes } = projectAnnotations; const { globals, globalTypes } = projectAnnotations;
this.globals.set({ globals, globalTypes }); if (this.globals) {
this.globals.set({ globals, globalTypes });
} else {
this.globals = new GlobalsStore({ globals, globalTypes });
}
} }
initialize({ initialize({
@ -114,6 +118,8 @@ export class StoryStore<TFramework extends AnyFramework> {
importFn?: ModuleImportFn; importFn?: ModuleImportFn;
storyIndex?: StoryIndex; storyIndex?: StoryIndex;
}) { }) {
if (!this.storyIndex) throw new Error(`onStoriesChanged called before initialization`);
if (importFn) this.importFn = importFn; if (importFn) this.importFn = importFn;
if (storyIndex) this.storyIndex.entries = storyIndex.entries; if (storyIndex) this.storyIndex.entries = storyIndex.entries;
if (this.cachedCSFFiles) await this.cacheAllCSFFiles(); if (this.cachedCSFFiles) await this.cacheAllCSFFiles();
@ -122,11 +128,15 @@ export class StoryStore<TFramework extends AnyFramework> {
// Get an entry from the index, waiting on initialization if necessary // Get an entry from the index, waiting on initialization if necessary
async storyIdToEntry(storyId: StoryId) { async storyIdToEntry(storyId: StoryId) {
await this.initializationPromise; await this.initializationPromise;
return this.storyIndex.storyIdToEntry(storyId); // The index will always be set before the initialization promise returns
return this.storyIndex!.storyIdToEntry(storyId);
} }
// To load a single CSF file to service a story we need to look up the importPath in the index // To load a single CSF file to service a story we need to look up the importPath in the index
loadCSFFileByStoryId(storyId: StoryId): PromiseLike<CSFFile<TFramework>> { loadCSFFileByStoryId(storyId: StoryId): PromiseLike<CSFFile<TFramework>> {
if (!this.storyIndex || !this.importFn)
throw new Error(`loadCSFFileByStoryId called before initialization`);
const { importPath, title } = this.storyIndex.storyIdToEntry(storyId); const { importPath, title } = this.storyIndex.storyIdToEntry(storyId);
return this.importFn(importPath).then((moduleExports) => return this.importFn(importPath).then((moduleExports) =>
// We pass the title in here as it may have been generated by autoTitle on the server. // We pass the title in here as it may have been generated by autoTitle on the server.
@ -135,6 +145,8 @@ export class StoryStore<TFramework extends AnyFramework> {
} }
loadAllCSFFiles(): PromiseLike<StoryStore<TFramework>['cachedCSFFiles']> { loadAllCSFFiles(): PromiseLike<StoryStore<TFramework>['cachedCSFFiles']> {
if (!this.storyIndex) throw new Error(`loadAllCSFFiles called before initialization`);
const importPaths: Record<Path, StoryId> = {}; const importPaths: Record<Path, StoryId> = {};
Object.entries(this.storyIndex.entries).forEach(([storyId, { importPath }]) => { Object.entries(this.storyIndex.entries).forEach(([storyId, { importPath }]) => {
importPaths[importPath] = storyId; importPaths[importPath] = storyId;
@ -179,6 +191,8 @@ export class StoryStore<TFramework extends AnyFramework> {
storyId: StoryId; storyId: StoryId;
csfFile: CSFFile<TFramework>; csfFile: CSFFile<TFramework>;
}): Story<TFramework> { }): Story<TFramework> {
if (!this.projectAnnotations) throw new Error(`storyFromCSFFile called before initialization`);
const storyAnnotations = csfFile.stories[storyId]; const storyAnnotations = csfFile.stories[storyId];
if (!storyAnnotations) { if (!storyAnnotations) {
throw new Error(`Didn't find '${storyId}' in CSF file, this is unexpected`); throw new Error(`Didn't find '${storyId}' in CSF file, this is unexpected`);
@ -197,6 +211,9 @@ export class StoryStore<TFramework extends AnyFramework> {
// If we have a CSF file we can get all the stories from it synchronously // If we have a CSF file we can get all the stories from it synchronously
componentStoriesFromCSFFile({ csfFile }: { csfFile: CSFFile<TFramework> }): Story<TFramework>[] { componentStoriesFromCSFFile({ csfFile }: { csfFile: CSFFile<TFramework> }): Story<TFramework>[] {
if (!this.storyIndex)
throw new Error(`componentStoriesFromCSFFile called before initialization`);
return Object.keys(this.storyIndex.entries) return Object.keys(this.storyIndex.entries)
.filter((storyId: StoryId) => !!csfFile.stories[storyId]) .filter((storyId: StoryId) => !!csfFile.stories[storyId])
.map((storyId: StoryId) => this.storyFromCSFFile({ storyId, csfFile })); .map((storyId: StoryId) => this.storyFromCSFFile({ storyId, csfFile }));
@ -205,6 +222,10 @@ export class StoryStore<TFramework extends AnyFramework> {
async loadDocsFileById( async loadDocsFileById(
docsId: StoryId docsId: StoryId
): Promise<{ docsExports: ModuleExports; csfFiles: CSFFile<TFramework>[] }> { ): Promise<{ docsExports: ModuleExports; csfFiles: CSFFile<TFramework>[] }> {
const { storyIndex } = this;
if (!storyIndex || !this.importFn)
throw new Error(`componentStoriesFromCSFFile called before initialization`);
const entry = await this.storyIdToEntry(docsId); const entry = await this.storyIdToEntry(docsId);
if (entry.type !== 'docs') throw new Error(`Cannot load docs file for id ${docsId}`); if (entry.type !== 'docs') throw new Error(`Cannot load docs file for id ${docsId}`);
@ -213,7 +234,7 @@ export class StoryStore<TFramework extends AnyFramework> {
const [docsExports, ...csfFiles] = (await Promise.all([ const [docsExports, ...csfFiles] = (await Promise.all([
this.importFn(importPath), this.importFn(importPath),
...storiesImports.map((storyImportPath) => { ...storiesImports.map((storyImportPath) => {
const firstStoryEntry = this.storyIndex.importPathToEntry(storyImportPath); const firstStoryEntry = storyIndex.importPathToEntry(storyImportPath);
return this.loadCSFFileByStoryId(firstStoryEntry.id); return this.loadCSFFileByStoryId(firstStoryEntry.id);
}), }),
])) as [ModuleExports, ...CSFFile<TFramework>[]]; ])) as [ModuleExports, ...CSFFile<TFramework>[]];
@ -232,6 +253,8 @@ export class StoryStore<TFramework extends AnyFramework> {
// A prepared story does not include args, globals or hooks. These are stored in the story store // A prepared story does not include args, globals or hooks. These are stored in the story store
// and updated separtely to the (immutable) story. // and updated separtely to the (immutable) story.
getStoryContext(story: Story<TFramework>): Omit<StoryContextForLoaders<TFramework>, 'viewMode'> { getStoryContext(story: Story<TFramework>): Omit<StoryContextForLoaders<TFramework>, 'viewMode'> {
if (!this.globals) throw new Error(`getStoryContext called before initialization`);
return { return {
...story, ...story,
args: this.args.get(story.id), args: this.args.get(story.id),
@ -247,15 +270,17 @@ export class StoryStore<TFramework extends AnyFramework> {
extract( extract(
options: ExtractOptions = { includeDocsOnly: false } options: ExtractOptions = { includeDocsOnly: false }
): Record<StoryId, StoryContextForEnhancers<TFramework>> { ): Record<StoryId, StoryContextForEnhancers<TFramework>> {
if (!this.cachedCSFFiles) { if (!this.storyIndex) throw new Error(`extract called before initialization`);
const { cachedCSFFiles } = this;
if (!cachedCSFFiles)
throw new Error('Cannot call extract() unless you call cacheAllCSFFiles() first.'); throw new Error('Cannot call extract() unless you call cacheAllCSFFiles() first.');
}
return Object.entries(this.storyIndex.entries).reduce( return Object.entries(this.storyIndex.entries).reduce(
(acc, [storyId, { type, importPath }]) => { (acc, [storyId, { type, importPath }]) => {
if (type === 'docs') return acc; if (type === 'docs') return acc;
const csfFile = this.cachedCSFFiles[importPath]; const csfFile = cachedCSFFiles[importPath];
const story = this.storyFromCSFFile({ storyId, csfFile }); const story = this.storyFromCSFFile({ storyId, csfFile });
if (!options.includeDocsOnly && story.parameters.docsOnly) { if (!options.includeDocsOnly && story.parameters.docsOnly) {
@ -282,6 +307,8 @@ export class StoryStore<TFramework extends AnyFramework> {
} }
getSetStoriesPayload() { getSetStoriesPayload() {
if (!this.globals) throw new Error(`getSetStoriesPayload called before initialization`);
const stories = this.extract({ includeDocsOnly: true }); const stories = this.extract({ includeDocsOnly: true });
const kindParameters: Parameters = Object.values(stories).reduce( const kindParameters: Parameters = Object.values(stories).reduce(
@ -305,11 +332,14 @@ export class StoryStore<TFramework extends AnyFramework> {
// It is used to allow v7 Storybooks to be composed in v6 Storybooks, which expect a // It is used to allow v7 Storybooks to be composed in v6 Storybooks, which expect a
// `stories.json` file with legacy fields (`kind` etc). // `stories.json` file with legacy fields (`kind` etc).
getStoriesJsonData = (): StoryIndexV3 => { getStoriesJsonData = (): StoryIndexV3 => {
const { storyIndex } = this;
if (!storyIndex) throw new Error(`getStoriesJsonData called before initialization`);
const value = this.getSetStoriesPayload(); const value = this.getSetStoriesPayload();
const allowedParameters = ['fileName', 'docsOnly', 'framework', '__id', '__isArgsStory']; const allowedParameters = ['fileName', 'docsOnly', 'framework', '__id', '__isArgsStory'];
const stories: Record<StoryId, V2CompatIndexEntry> = mapValues(value.stories, (story) => { const stories: Record<StoryId, V2CompatIndexEntry> = mapValues(value.stories, (story) => {
const { importPath } = this.storyIndex.entries[story.id]; const { importPath } = storyIndex.entries[story.id];
return { return {
...pick(story, ['id', 'name', 'title']), ...pick(story, ['id', 'name', 'title']),
importPath, importPath,
@ -332,13 +362,16 @@ export class StoryStore<TFramework extends AnyFramework> {
}; };
raw(): BoundStory<TFramework>[] { raw(): BoundStory<TFramework>[] {
return Object.values(this.extract()).map(({ id }: { id: StoryId }) => this.fromId(id)); return Object.values(this.extract())
.map(({ id }: { id: StoryId }) => this.fromId(id))
.filter(Boolean) as BoundStory<TFramework>[];
} }
fromId(storyId: StoryId): BoundStory<TFramework> { fromId(storyId: StoryId): BoundStory<TFramework> | null {
if (!this.cachedCSFFiles) { if (!this.storyIndex) throw new Error(`fromId called before initialization`);
if (!this.cachedCSFFiles)
throw new Error('Cannot call fromId/raw() unless you call cacheAllCSFFiles() first.'); throw new Error('Cannot call fromId/raw() unless you call cacheAllCSFFiles() first.');
}
let importPath; let importPath;
try { try {

View File

@ -1,4 +1,7 @@
import { once } from '@storybook/client-logger'; import { once } from '@storybook/client-logger';
import { expect } from '@jest/globals';
import { SBType } from '@storybook/csf';
import { import {
combineArgs, combineArgs,
groupArgsByTarget, groupArgsByTarget,
@ -7,13 +10,13 @@ import {
validateOptions, validateOptions,
} from './args'; } from './args';
const stringType = { name: 'string' }; const stringType: SBType = { name: 'string' };
const numberType = { name: 'number' }; const numberType: SBType = { name: 'number' };
const booleanType = { name: 'boolean' }; const booleanType: SBType = { name: 'boolean' };
const enumType = { name: 'enum' }; const enumType: SBType = { name: 'enum', value: [1, 2, 3] };
const functionType = { name: 'function' }; const functionType: SBType = { name: 'function' };
const numArrayType = { name: 'array', value: numberType }; const numArrayType: SBType = { name: 'array', value: numberType };
const boolObjectType = { name: 'object', value: { bool: booleanType } }; const boolObjectType: SBType = { name: 'object', value: { bool: booleanType } };
jest.mock('@storybook/client-logger'); jest.mock('@storybook/client-logger');

View File

@ -151,7 +151,7 @@ export function groupArgsByTarget<TArgs = Args>({
}: StoryContext<AnyFramework, TArgs>) { }: StoryContext<AnyFramework, TArgs>) {
const groupedArgs: Record<string, Partial<TArgs>> = {}; const groupedArgs: Record<string, Partial<TArgs>> = {};
(Object.entries(args) as [keyof TArgs, any][]).forEach(([name, value]) => { (Object.entries(args) as [keyof TArgs, any][]).forEach(([name, value]) => {
const { target = NO_TARGET_NAME } = (argTypes[name] || {}) as { target: string }; const { target = NO_TARGET_NAME } = (argTypes[name] || {}) as { target?: string };
groupedArgs[target] = groupedArgs[target] || {}; groupedArgs[target] = groupedArgs[target] || {};
groupedArgs[target][name] = value; groupedArgs[target][name] = value;

View File

@ -1,4 +1,5 @@
import { normalizeStoriesEntry } from '@storybook/core-common'; import { normalizeStoriesEntry } from '@storybook/core-common';
import { expect } from '@jest/globals';
import { userOrAutoTitleFromSpecifier as userOrAuto } from './autoTitle'; import { userOrAutoTitleFromSpecifier as userOrAuto } from './autoTitle';

View File

@ -1,3 +1,5 @@
import { expect } from '@jest/globals';
import { composeConfigs } from './composeConfigs'; import { composeConfigs } from './composeConfigs';
describe('composeConfigs', () => { describe('composeConfigs', () => {

View File

@ -1,3 +1,5 @@
import { expect } from '@jest/globals';
import { normalizeInputType, normalizeInputTypes } from './normalizeInputTypes'; import { normalizeInputType, normalizeInputTypes } from './normalizeInputTypes';
describe('normalizeInputType', () => { describe('normalizeInputType', () => {

View File

@ -1,4 +1,4 @@
import type { AnyFramework, ProjectAnnotations } from '@storybook/csf'; import type { AnyFramework, ArgTypes, ProjectAnnotations } from '@storybook/csf';
import { inferArgTypes } from '../inferArgTypes'; import { inferArgTypes } from '../inferArgTypes';
import { inferControls } from '../inferControls'; import { inferControls } from '../inferControls';
@ -12,7 +12,7 @@ export function normalizeProjectAnnotations<TFramework extends AnyFramework>({
...annotations ...annotations
}: ProjectAnnotations<TFramework>): NormalizedProjectAnnotations<TFramework> { }: ProjectAnnotations<TFramework>): NormalizedProjectAnnotations<TFramework> {
return { return {
...(argTypes && { argTypes: normalizeInputTypes(argTypes) }), ...(argTypes && { argTypes: normalizeInputTypes(argTypes as ArgTypes) }),
...(globalTypes && { globalTypes: normalizeInputTypes(globalTypes) }), ...(globalTypes && { globalTypes: normalizeInputTypes(globalTypes) }),
argTypesEnhancers: [ argTypesEnhancers: [
...(argTypesEnhancers || []), ...(argTypesEnhancers || []),

View File

@ -1,12 +1,10 @@
import { expect } from '@jest/globals';
import { AnyFramework, StoryAnnotationsOrFn } from '@storybook/csf'; import { AnyFramework, StoryAnnotationsOrFn } from '@storybook/csf';
import { normalizeStory } from './normalizeStory'; import { normalizeStory } from './normalizeStory';
describe('normalizeStory', () => { describe('normalizeStory', () => {
describe('id generation', () => { describe('id generation', () => {
it('combines title and export name', () => {
expect(normalizeStory('name', {}, { title: 'title' }).id).toEqual('title--name');
});
it('respects component id', () => { it('respects component id', () => {
expect(normalizeStory('name', {}, { title: 'title', id: 'component-id' }).id).toEqual( expect(normalizeStory('name', {}, { title: 'title', id: 'component-id' }).id).toEqual(
'component-id--name' 'component-id--name'
@ -27,22 +25,28 @@ describe('normalizeStory', () => {
describe('name', () => { describe('name', () => {
it('preferences story.name over story.storyName', () => { it('preferences story.name over story.storyName', () => {
expect( expect(
normalizeStory('export', { name: 'name', storyName: 'storyName' }, { title: 'title' }).name normalizeStory(
'export',
{ name: 'name', storyName: 'storyName' },
{ id: 'title', title: 'title' }
).name
).toEqual('name'); ).toEqual('name');
expect(normalizeStory('export', { storyName: 'storyName' }, { title: 'title' }).name).toEqual( expect(
'storyName' normalizeStory('export', { storyName: 'storyName' }, { id: 'title', title: 'title' }).name
); ).toEqual('storyName');
}); });
it('falls back to capitalized export name', () => { it('falls back to capitalized export name', () => {
expect(normalizeStory('exportOne', {}, { title: 'title' }).name).toEqual('Export One'); expect(normalizeStory('exportOne', {}, { id: 'title', title: 'title' }).name).toEqual(
'Export One'
);
}); });
}); });
describe('user-provided story function', () => { describe('user-provided story function', () => {
it('should normalize into an object', () => { it('should normalize into an object', () => {
const storyFn = () => {}; const storyFn = () => {};
const meta = { title: 'title' }; const meta = { id: 'title', title: 'title' };
expect(normalizeStory('storyExport', storyFn, meta)).toMatchInlineSnapshot(` expect(normalizeStory('storyExport', storyFn, meta)).toMatchInlineSnapshot(`
Object { Object {
"argTypes": Object {}, "argTypes": Object {},
@ -63,21 +67,21 @@ describe('normalizeStory', () => {
describe('render function', () => { describe('render function', () => {
it('implicit render function', () => { it('implicit render function', () => {
const storyObj = {}; const storyObj = {};
const meta = { title: 'title' }; const meta = { id: 'title', title: 'title' };
const normalized = normalizeStory('storyExport', storyObj, meta); const normalized = normalizeStory('storyExport', storyObj, meta);
expect(normalized.render).toBeUndefined(); expect(normalized.render).toBeUndefined();
}); });
it('user-provided story render function', () => { it('user-provided story render function', () => {
const storyObj = { render: jest.fn() }; const storyObj = { render: jest.fn() };
const meta = { title: 'title', render: jest.fn() }; const meta = { id: 'title', title: 'title', render: jest.fn() };
const normalized = normalizeStory('storyExport', storyObj, meta); const normalized = normalizeStory('storyExport', storyObj, meta);
expect(normalized.render).toBe(storyObj.render); expect(normalized.render).toBe(storyObj.render);
}); });
it('user-provided meta render function', () => { it('user-provided meta render function', () => {
const storyObj = {}; const storyObj = {};
const meta = { title: 'title', render: jest.fn() }; const meta = { id: 'title', title: 'title', render: jest.fn() };
const normalized = normalizeStory('storyExport', storyObj, meta); const normalized = normalizeStory('storyExport', storyObj, meta);
expect(normalized.render).toBeUndefined(); expect(normalized.render).toBeUndefined();
}); });
@ -86,21 +90,21 @@ describe('normalizeStory', () => {
describe('play function', () => { describe('play function', () => {
it('no render function', () => { it('no render function', () => {
const storyObj = {}; const storyObj = {};
const meta = { title: 'title' }; const meta = { id: 'title', title: 'title' };
const normalized = normalizeStory('storyExport', storyObj, meta); const normalized = normalizeStory('storyExport', storyObj, meta);
expect(normalized.play).toBeUndefined(); expect(normalized.play).toBeUndefined();
}); });
it('user-provided story render function', () => { it('user-provided story render function', () => {
const storyObj = { play: jest.fn() }; const storyObj = { play: jest.fn() };
const meta = { title: 'title', play: jest.fn() }; const meta = { id: 'title', title: 'title', play: jest.fn() };
const normalized = normalizeStory('storyExport', storyObj, meta); const normalized = normalizeStory('storyExport', storyObj, meta);
expect(normalized.play).toBe(storyObj.play); expect(normalized.play).toBe(storyObj.play);
}); });
it('user-provided meta render function', () => { it('user-provided meta render function', () => {
const storyObj = {}; const storyObj = {};
const meta = { title: 'title', play: jest.fn() }; const meta = { id: 'title', title: 'title', play: jest.fn() };
const normalized = normalizeStory('storyExport', storyObj, meta); const normalized = normalizeStory('storyExport', storyObj, meta);
expect(normalized.play).toBeUndefined(); expect(normalized.play).toBeUndefined();
}); });
@ -109,7 +113,7 @@ describe('normalizeStory', () => {
describe('annotations', () => { describe('annotations', () => {
it('empty annotations', () => { it('empty annotations', () => {
const storyObj = {}; const storyObj = {};
const meta = { title: 'title' }; const meta = { id: 'title', title: 'title' };
const normalized = normalizeStory('storyExport', storyObj, meta); const normalized = normalizeStory('storyExport', storyObj, meta);
expect(normalized).toMatchInlineSnapshot(` expect(normalized).toMatchInlineSnapshot(`
Object { Object {
@ -135,7 +139,7 @@ describe('normalizeStory', () => {
args: { storyArg: 'val' }, args: { storyArg: 'val' },
argTypes: { storyArgType: { type: 'string' } }, argTypes: { storyArgType: { type: 'string' } },
}; };
const meta = { title: 'title' }; const meta = { id: 'title', title: 'title' };
const { moduleExport, ...normalized } = normalizeStory('storyExport', storyObj, meta); const { moduleExport, ...normalized } = normalizeStory('storyExport', storyObj, meta);
expect(normalized).toMatchInlineSnapshot(` expect(normalized).toMatchInlineSnapshot(`
Object { Object {
@ -182,7 +186,7 @@ describe('normalizeStory', () => {
argTypes: { storyArgType2: { type: 'string' } }, argTypes: { storyArgType2: { type: 'string' } },
}, },
}; };
const meta = { title: 'title' }; const meta = { id: 'title', title: 'title' };
const { moduleExport, ...normalized } = normalizeStory('storyExport', storyObj, meta); const { moduleExport, ...normalized } = normalizeStory('storyExport', storyObj, meta);
expect(normalized).toMatchInlineSnapshot(` expect(normalized).toMatchInlineSnapshot(`
Object { Object {

View File

@ -1,16 +1,16 @@
import type { import type {
ComponentAnnotations,
AnyFramework, AnyFramework,
LegacyStoryAnnotationsOrFn, LegacyStoryAnnotationsOrFn,
StoryId, StoryId,
StoryAnnotations, StoryAnnotations,
StoryFn, StoryFn,
ArgTypes,
} from '@storybook/csf'; } from '@storybook/csf';
import { storyNameFromExport, toId } from '@storybook/csf'; import { storyNameFromExport, toId } from '@storybook/csf';
import dedent from 'ts-dedent'; import dedent from 'ts-dedent';
import { logger } from '@storybook/client-logger'; import { logger } from '@storybook/client-logger';
import deprecate from 'util-deprecate'; import deprecate from 'util-deprecate';
import type { NormalizedStoryAnnotations } from '../types'; import type { NormalizedComponentAnnotations, NormalizedStoryAnnotations } from '../types';
import { normalizeInputTypes } from './normalizeInputTypes'; import { normalizeInputTypes } from './normalizeInputTypes';
const deprecatedStoryAnnotation = dedent` const deprecatedStoryAnnotation = dedent`
@ -25,16 +25,11 @@ const deprecatedStoryAnnotationWarning = deprecate(() => {}, deprecatedStoryAnno
export function normalizeStory<TFramework extends AnyFramework>( export function normalizeStory<TFramework extends AnyFramework>(
key: StoryId, key: StoryId,
storyAnnotations: LegacyStoryAnnotationsOrFn<TFramework>, storyAnnotations: LegacyStoryAnnotationsOrFn<TFramework>,
meta: ComponentAnnotations<TFramework> meta: NormalizedComponentAnnotations<TFramework>
): NormalizedStoryAnnotations<TFramework> { ): NormalizedStoryAnnotations<TFramework> {
let userStoryFn: StoryFn<TFramework>; const storyObject: StoryAnnotations<TFramework> = storyAnnotations;
let storyObject: StoryAnnotations<TFramework>; const userStoryFn: StoryFn<TFramework> | null =
if (typeof storyAnnotations === 'function') { typeof storyAnnotations === 'function' ? storyAnnotations : null;
userStoryFn = storyAnnotations;
storyObject = storyAnnotations;
} else {
storyObject = storyAnnotations;
}
const { story } = storyObject; const { story } = storyObject;
if (story) { if (story) {
@ -51,12 +46,12 @@ export function normalizeStory<TFramework extends AnyFramework>(
const decorators = [...(storyObject.decorators || []), ...(story?.decorators || [])]; const decorators = [...(storyObject.decorators || []), ...(story?.decorators || [])];
const parameters = { ...story?.parameters, ...storyObject.parameters }; const parameters = { ...story?.parameters, ...storyObject.parameters };
const args = { ...story?.args, ...storyObject.args }; const args = { ...story?.args, ...storyObject.args };
const argTypes = { ...story?.argTypes, ...storyObject.argTypes }; const argTypes = { ...(story?.argTypes as ArgTypes), ...(storyObject.argTypes as ArgTypes) };
const loaders = [...(storyObject.loaders || []), ...(story?.loaders || [])]; const loaders = [...(storyObject.loaders || []), ...(story?.loaders || [])];
const { render, play } = storyObject; const { render, play } = storyObject;
// eslint-disable-next-line no-underscore-dangle // eslint-disable-next-line no-underscore-dangle
const id = parameters.__id || toId(meta.id || meta.title, exportName); const id = parameters.__id || toId(meta.id, exportName);
return { return {
moduleExport: storyAnnotations, moduleExport: storyAnnotations,
id, id,

View File

@ -1,4 +1,5 @@
import global from 'global'; import global from 'global';
import { expect } from '@jest/globals';
import { addons, HooksContext } from '@storybook/addons'; import { addons, HooksContext } from '@storybook/addons';
import type { import type {
AnyFramework, AnyFramework,
@ -7,6 +8,7 @@ import type {
SBScalarType, SBScalarType,
StoryContext, StoryContext,
} from '@storybook/csf'; } from '@storybook/csf';
import { NO_TARGET_NAME } from '../args'; import { NO_TARGET_NAME } from '../args';
import { prepareStory } from './prepareStory'; import { prepareStory } from './prepareStory';
@ -193,7 +195,7 @@ describe('prepareStory', () => {
describe('argsEnhancers', () => { describe('argsEnhancers', () => {
it('are applied in the right order', () => { it('are applied in the right order', () => {
const run = []; const run: number[] = [];
const enhancerOne: ArgsEnhancer<AnyFramework> = () => { const enhancerOne: ArgsEnhancer<AnyFramework> = () => {
run.push(1); run.push(1);
return {}; return {};
@ -351,7 +353,7 @@ describe('prepareStory', () => {
it('awaits the result of a loader', async () => { it('awaits the result of a loader', async () => {
const loader = jest.fn(async () => new Promise((r) => setTimeout(() => r({ foo: 7 }), 100))); const loader = jest.fn(async () => new Promise((r) => setTimeout(() => r({ foo: 7 }), 100)));
const { applyLoaders } = prepareStory( const { applyLoaders } = prepareStory(
{ id, name, loaders: [loader], moduleExport }, { id, name, loaders: [loader as any], moduleExport },
{ id, title }, { id, title },
{ render } { render }
); );
@ -387,7 +389,7 @@ describe('prepareStory', () => {
}); });
it('later loaders override earlier loaders', async () => { it('later loaders override earlier loaders', async () => {
const loaders = [ const loaders: any[] = [
async () => new Promise((r) => setTimeout(() => r({ foo: 7 }), 100)), async () => new Promise((r) => setTimeout(() => r({ foo: 7 }), 100)),
async () => new Promise((r) => setTimeout(() => r({ foo: 3 }), 50)), async () => new Promise((r) => setTimeout(() => r({ foo: 3 }), 50)),
]; ];

View File

@ -11,6 +11,7 @@ import type {
StoryContext, StoryContext,
AnyFramework, AnyFramework,
StrictArgTypes, StrictArgTypes,
StoryContextForLoaders,
} from '@storybook/csf'; } from '@storybook/csf';
import { includeConditionalArg } from '@storybook/csf'; import { includeConditionalArg } from '@storybook/csf';
@ -84,6 +85,8 @@ export function prepareStory<TFramework extends AnyFramework>(
componentAnnotations.render || componentAnnotations.render ||
projectAnnotations.render; projectAnnotations.render;
if (!render) throw new Error(`No render function available for storyId '${id}'`);
const passedArgTypes: StrictArgTypes = combineParameters( const passedArgTypes: StrictArgTypes = combineParameters(
projectAnnotations.argTypes, projectAnnotations.argTypes,
componentAnnotations.argTypes, componentAnnotations.argTypes,
@ -154,7 +157,7 @@ export function prepareStory<TFramework extends AnyFramework>(
}; };
} }
const applyLoaders = async (context: StoryContext<TFramework>) => { const applyLoaders = async (context: StoryContextForLoaders<TFramework>) => {
const loadResults = await Promise.all(loaders.map((loader) => loader(context))); const loadResults = await Promise.all(loaders.map((loader) => loader(context)));
const loaded = Object.assign({}, ...loadResults); const loaded = Object.assign({}, ...loadResults);
return { ...context, loaded }; return { ...context, loaded };
@ -183,7 +186,7 @@ export function prepareStory<TFramework extends AnyFramework>(
const unboundStoryFn = (context: StoryContext<TFramework>) => { const unboundStoryFn = (context: StoryContext<TFramework>) => {
let finalContext: StoryContext<TFramework> = context; let finalContext: StoryContext<TFramework> = context;
if (global.FEATURES?.argTypeTargetsV7) { if (global.FEATURES?.argTypeTargetsV7) {
const argsByTarget = groupArgsByTarget({ args: context.args, ...context }); const argsByTarget = groupArgsByTarget(context);
finalContext = { finalContext = {
...context, ...context,
allArgs: context.args, allArgs: context.args,

View File

@ -1,3 +1,5 @@
import { expect } from '@jest/globals';
import { processCSFFile } from './processCSFFile'; import { processCSFFile } from './processCSFFile';
describe('processCSFFile', () => { describe('processCSFFile', () => {

View File

@ -24,10 +24,9 @@ const checkStorySort = (parameters: Parameters) => {
if (options?.storySort) logger.error('The storySort option parameter can only be set globally'); if (options?.storySort) logger.error('The storySort option parameter can only be set globally');
}; };
const checkDisallowedParameters = (parameters: Parameters) => { const checkDisallowedParameters = (parameters?: Parameters) => {
if (!parameters) { if (!parameters) return;
return;
}
checkGlobals(parameters); checkGlobals(parameters);
checkStorySort(parameters); checkStorySort(parameters);
}; };

View File

@ -8,6 +8,7 @@ import {
Args, Args,
StoryContext, StoryContext,
Parameters, Parameters,
LegacyStoryAnnotationsOrFn,
} from '@storybook/csf'; } from '@storybook/csf';
import { composeConfigs } from '../composeConfigs'; import { composeConfigs } from '../composeConfigs';
@ -50,7 +51,7 @@ export function composeStory<
TFramework extends AnyFramework = AnyFramework, TFramework extends AnyFramework = AnyFramework,
TArgs extends Args = Args TArgs extends Args = Args
>( >(
storyAnnotations: AnnotatedStoryFn<TFramework, TArgs> | StoryAnnotations<TFramework, TArgs>, storyAnnotations: LegacyStoryAnnotationsOrFn<TFramework>,
componentAnnotations: ComponentAnnotations<TFramework, TArgs>, componentAnnotations: ComponentAnnotations<TFramework, TArgs>,
projectAnnotations: ProjectAnnotations<TFramework> = GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS, projectAnnotations: ProjectAnnotations<TFramework> = GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS,
defaultConfig: ProjectAnnotations<TFramework> = {}, defaultConfig: ProjectAnnotations<TFramework> = {},
@ -63,15 +64,17 @@ export function composeStory<
// @TODO: Support auto title // @TODO: Support auto title
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
componentAnnotations.title = componentAnnotations.title ?? 'ComposedStory'; componentAnnotations.title = componentAnnotations.title ?? 'ComposedStory';
const normalizedComponentAnnotations = normalizeComponentAnnotations(componentAnnotations); const normalizedComponentAnnotations =
normalizeComponentAnnotations<TFramework>(componentAnnotations);
const storyName = const storyName =
exportsName || exportsName ||
storyAnnotations.storyName || storyAnnotations.storyName ||
storyAnnotations.story?.name || storyAnnotations.story?.name ||
storyAnnotations.name; storyAnnotations.name ||
'unknown';
const normalizedStory = normalizeStory( const normalizedStory = normalizeStory<TFramework>(
storyName, storyName,
storyAnnotations, storyAnnotations,
normalizedComponentAnnotations normalizedComponentAnnotations
@ -121,7 +124,12 @@ export function composeStories<TModule extends CSFExports>(
} }
const result = Object.assign(storiesMap, { const result = Object.assign(storiesMap, {
[exportsName]: composeStoryFn(story, meta, globalConfig, exportsName), [exportsName]: composeStoryFn(
story as LegacyStoryAnnotationsOrFn,
meta,
globalConfig,
exportsName
),
}); });
return result; return result;
}, {}); }, {});

View File

@ -1,3 +1,4 @@
import { expect } from '@jest/globals';
import type { AnyFramework, StoryContext } from '@storybook/csf'; import type { AnyFramework, StoryContext } from '@storybook/csf';
import { defaultDecorateStory } from './decorators'; import { defaultDecorateStory } from './decorators';
@ -15,7 +16,7 @@ function makeContext(input: Record<string, any> = {}): StoryContext<AnyFramework
describe('client-api.decorators', () => { describe('client-api.decorators', () => {
it('calls decorators in out to in order', () => { it('calls decorators in out to in order', () => {
const order = []; const order: number[] = [];
const decorators = [ const decorators = [
(s) => order.push(1) && s(), (s) => order.push(1) && s(),
(s) => order.push(2) && s(), (s) => order.push(2) && s(),
@ -29,7 +30,7 @@ describe('client-api.decorators', () => {
}); });
it('passes context through to sub decorators', () => { it('passes context through to sub decorators', () => {
const contexts = []; const contexts: StoryContext[] = [];
const decorators = [ const decorators = [
(s, c) => contexts.push(c) && s({ args: { k: 1 } }), (s, c) => contexts.push(c) && s({ args: { k: 1 } }),
(s, c) => contexts.push(c) && s({ args: { k: 2 } }), (s, c) => contexts.push(c) && s({ args: { k: 2 } }),
@ -43,7 +44,7 @@ describe('client-api.decorators', () => {
}); });
it('passes context through to sub decorators additively', () => { it('passes context through to sub decorators additively', () => {
const contexts = []; const contexts: StoryContext[] = [];
const decorators = [ const decorators = [
(s, c) => contexts.push(c) && s({ args: { a: 1 } }), (s, c) => contexts.push(c) && s({ args: { a: 1 } }),
(s, c) => contexts.push(c) && s({ globals: { g: 2 } }), (s, c) => contexts.push(c) && s({ globals: { g: 2 } }),
@ -78,7 +79,7 @@ describe('client-api.decorators', () => {
// both story functions would receive {story: 2}. The assumption here is that we'll never render // both story functions would receive {story: 2}. The assumption here is that we'll never render
// the same story twice at the same time. // the same story twice at the same time.
it('does not interleave contexts if two decorated stories are call simultaneously', async () => { it('does not interleave contexts if two decorated stories are call simultaneously', async () => {
const contexts = []; const contexts: StoryContext[] = [];
let resolve; let resolve;
const fence = new Promise((r) => { const fence = new Promise((r) => {
resolve = r; resolve = r;
@ -104,7 +105,7 @@ describe('client-api.decorators', () => {
}); });
it('DOES NOT merge core metadata or pass through core metadata keys in context', () => { it('DOES NOT merge core metadata or pass through core metadata keys in context', () => {
const contexts = []; const contexts: StoryContext[] = [];
const decorators = [ const decorators = [
(s, c) => (s, c) =>
contexts.push(c) && contexts.push(c) &&

View File

@ -69,6 +69,9 @@ export function defaultDecorateStory<TFramework extends AnyFramework>(
const bindWithContext = const bindWithContext =
(decoratedStoryFn: LegacyStoryFn<TFramework>): PartialStoryFn<TFramework> => (decoratedStoryFn: LegacyStoryFn<TFramework>): PartialStoryFn<TFramework> =>
(update) => { (update) => {
// This code path isn't possible because we always set `contextStore.value` before calling
// `decoratedWithContextStore`, but TS doesn't know that.
if (!contextStore.value) throw new Error('Decorated function called without init');
contextStore.value = { contextStore.value = {
...contextStore.value, ...contextStore.value,
...sanitizeStoryContextUpdate(update), ...sanitizeStoryContextUpdate(update),

View File

@ -1,3 +1,4 @@
import { expect } from '@jest/globals';
import { import {
FORCE_RE_RENDER, FORCE_RE_RENDER,
STORY_RENDERED, STORY_RENDERED,
@ -6,6 +7,8 @@ import {
UPDATE_GLOBALS, UPDATE_GLOBALS,
} from '@storybook/core-events'; } from '@storybook/core-events';
import { addons } from '@storybook/addons'; import { addons } from '@storybook/addons';
import { DecoratorFunction, StoryContext } from '@storybook/csf';
import { defaultDecorateStory } from './decorators'; import { defaultDecorateStory } from './decorators';
import { import {
applyHooks, applyHooks,
@ -60,8 +63,8 @@ beforeEach(() => {
const decorateStory = applyHooks(defaultDecorateStory); const decorateStory = applyHooks(defaultDecorateStory);
const run = (storyFn, decorators = [], context) => const run = (storyFn, decorators: DecoratorFunction[] = [], context = {}) =>
decorateStory(storyFn, decorators)({ ...context, hooks }); decorateStory(storyFn, decorators)({ ...context, hooks } as Partial<StoryContext>);
describe('Preview hooks', () => { describe('Preview hooks', () => {
describe('useEffect', () => { describe('useEffect', () => {
@ -321,7 +324,7 @@ describe('Preview hooks', () => {
expect(result).toBe(callback); expect(result).toBe(callback);
}); });
it('returns the previous callback reference if deps are unchanged', () => { it('returns the previous callback reference if deps are unchanged', () => {
const callbacks = []; const callbacks: (() => void)[] = [];
const storyFn = () => { const storyFn = () => {
const callback = useCallback(() => {}, []); const callback = useCallback(() => {}, []);
callbacks.push(callback); callbacks.push(callback);
@ -331,7 +334,7 @@ describe('Preview hooks', () => {
expect(callbacks[0]).toBe(callbacks[1]); expect(callbacks[0]).toBe(callbacks[1]);
}); });
it('creates new callback reference if deps are changed', () => { it('creates new callback reference if deps are changed', () => {
const callbacks = []; const callbacks: (() => void)[] = [];
let counter = 0; let counter = 0;
const storyFn = () => { const storyFn = () => {
counter += 1; counter += 1;

View File

@ -1,4 +1,5 @@
import { logger } from '@storybook/client-logger'; import { logger } from '@storybook/client-logger';
import { expect } from '@jest/globals';
import { inferArgTypes } from './inferArgTypes'; import { inferArgTypes } from './inferArgTypes';

View File

@ -1,5 +1,7 @@
import { StoryContextForEnhancers } from '@storybook/store'; import { expect } from '@jest/globals';
import { logger } from '@storybook/client-logger'; import { logger } from '@storybook/client-logger';
import { StoryContextForEnhancers } from '@storybook/csf';
import { argTypesEnhancers } from './inferControls'; import { argTypesEnhancers } from './inferControls';
const getStoryContext = (overrides: any = {}): StoryContextForEnhancers => ({ const getStoryContext = (overrides: any = {}): StoryContextForEnhancers => ({

View File

@ -11,13 +11,13 @@ type ControlsMatchers = {
const inferControl = (argType: StrictInputType, name: string, matchers: ControlsMatchers): any => { const inferControl = (argType: StrictInputType, name: string, matchers: ControlsMatchers): any => {
const { type, options } = argType; const { type, options } = argType;
if (!type && !options) { if (!type) {
return undefined; return undefined;
} }
// args that end with background or color e.g. iconColor // args that end with background or color e.g. iconColor
if (matchers.color && matchers.color.test(name)) { if (matchers.color && matchers.color.test(name)) {
const controlType = argType.type.name; const controlType = type.name;
if (controlType === 'string') { if (controlType === 'string') {
return { control: { type: 'color' } }; return { control: { type: 'color' } };

View File

@ -1,3 +1,4 @@
import { expect } from '@jest/globals';
import { combineParameters } from './parameters'; import { combineParameters } from './parameters';
describe('client-api.parameters', () => { describe('client-api.parameters', () => {

View File

@ -10,8 +10,9 @@ import isPlainObject from 'lodash/isPlainObject';
*/ */
export const combineParameters = (...parameterSets: (Parameters | undefined)[]) => { export const combineParameters = (...parameterSets: (Parameters | undefined)[]) => {
const mergeKeys: Record<string, boolean> = {}; const mergeKeys: Record<string, boolean> = {};
const combined = parameterSets.filter(Boolean).reduce((acc, p) => { const definedParametersSets = parameterSets.filter(Boolean) as Parameters[];
Object.entries(p).forEach(([key, value]) => { const combined = definedParametersSets.reduce((acc, parameters) => {
Object.entries(parameters).forEach(([key, value]) => {
const existing = acc[key]; const existing = acc[key];
if (Array.isArray(value) || typeof existing === 'undefined') { if (Array.isArray(value) || typeof existing === 'undefined') {
acc[key] = value; acc[key] = value;
@ -26,7 +27,7 @@ export const combineParameters = (...parameterSets: (Parameters | undefined)[])
}, {} as Parameters); }, {} as Parameters);
Object.keys(mergeKeys).forEach((key) => { Object.keys(mergeKeys).forEach((key) => {
const mergeValues = parameterSets const mergeValues = definedParametersSets
.filter(Boolean) .filter(Boolean)
.map((p) => p[key]) .map((p) => p[key])
.filter((value) => typeof value !== 'undefined'); .filter((value) => typeof value !== 'undefined');

View File

@ -37,7 +37,7 @@ export const sortStoriesV7 = (
throw new Error(dedent` throw new Error(dedent`
Error sorting stories with sort parameter ${storySortParameter}: Error sorting stories with sort parameter ${storySortParameter}:
> ${err.message} > ${(err as Error).message}
Are you using a V6-style sort function in V7 mode? Are you using a V6-style sort function in V7 mode?

View File

@ -1,25 +1,31 @@
import { expect } from '@jest/globals';
import { StoryId } from '@storybook/csf';
import { StoryIndexEntry } from '@storybook/store';
import { storySort } from './storySort'; import { storySort } from './storySort';
describe('preview.storySort', () => { describe('preview.storySort', () => {
const fixture = { const fixture: Record<StoryId, StoryIndexEntry> = Object.fromEntries(
a: { title: 'a' }, Object.entries({
á: { title: 'á' }, a: { title: 'a' },
A: { title: 'A' }, á: { title: 'á' },
b: { title: 'b' }, A: { title: 'A' },
a_a: { title: 'a / a' }, b: { title: 'b' },
a_b: { title: 'a / b' }, a_a: { title: 'a / a' },
a_c: { title: 'a / c' }, a_b: { title: 'a / b' },
b_a_a: { title: 'b / a / a' }, a_c: { title: 'a / c' },
b_b: { title: 'b / b' }, b_a_a: { title: 'b / a / a' },
c: { title: 'c' }, b_b: { title: 'b / b' },
locale1: { title: 'Б' }, c: { title: 'c' },
locale2: { title: 'Г' }, locale1: { title: 'Б' },
c__a: { title: 'c', name: 'a' }, locale2: { title: 'Г' },
c_b__a: { title: 'c / b', name: 'a' }, c__a: { title: 'c', name: 'a' },
c_b__b: { title: 'c / b', name: 'b' }, c_b__a: { title: 'c / b', name: 'a' },
c_b__c: { title: 'c / b', name: 'c' }, c_b__b: { title: 'c / b', name: 'b' },
c__c: { title: 'c', name: 'c' }, c_b__c: { title: 'c / b', name: 'c' },
}; c__c: { title: 'c', name: 'c' },
}).map(([id, entry]) => [id, { type: 'story', name: 'name', ...entry, id, importPath: id }])
);
it('uses configure order by default', () => { it('uses configure order by default', () => {
const sortFn = storySort(); const sortFn = storySort();

View File

@ -22,7 +22,9 @@ import type {
PartialStoryFn, PartialStoryFn,
Parameters, Parameters,
} from '@storybook/csf'; } from '@storybook/csf';
import type { StoryIndexEntry, DocsIndexEntry, IndexEntry } from '@storybook/addons';
export type { StoryIndexEntry, DocsIndexEntry, IndexEntry };
export type { StoryId, Parameters }; export type { StoryId, Parameters };
export type Path = string; export type Path = string;
export type ModuleExport = any; export type ModuleExport = any;
@ -51,8 +53,9 @@ export type NormalizedProjectAnnotations<TFramework extends AnyFramework = AnyFr
export type NormalizedComponentAnnotations<TFramework extends AnyFramework = AnyFramework> = export type NormalizedComponentAnnotations<TFramework extends AnyFramework = AnyFramework> =
ComponentAnnotations<TFramework> & { ComponentAnnotations<TFramework> & {
// Useful to guarantee that id exists // Useful to guarantee that id & title exists
id: ComponentId; id: ComponentId;
title: ComponentTitle;
argTypes?: StrictArgTypes; argTypes?: StrictArgTypes;
}; };
@ -64,6 +67,7 @@ export type NormalizedStoryAnnotations<TFramework extends AnyFramework = AnyFram
// You cannot actually set id on story annotations, but we normalize it to be there for convience // You cannot actually set id on story annotations, but we normalize it to be there for convience
id: StoryId; id: StoryId;
argTypes?: StrictArgTypes; argTypes?: StrictArgTypes;
name: StoryName;
userStoryFn?: StoryFn<TFramework>; userStoryFn?: StoryFn<TFramework>;
}; };
@ -80,8 +84,10 @@ export type Story<TFramework extends AnyFramework = AnyFramework> =
unboundStoryFn: LegacyStoryFn<TFramework>; unboundStoryFn: LegacyStoryFn<TFramework>;
applyLoaders: ( applyLoaders: (
context: StoryContextForLoaders<TFramework> context: StoryContextForLoaders<TFramework>
) => Promise<StoryContext<TFramework>>; ) => Promise<
playFunction: (context: StoryContext<TFramework>) => Promise<void> | void; StoryContextForLoaders<TFramework> & { loaded: StoryContext<TFramework>['loaded'] }
>;
playFunction?: (context: StoryContext<TFramework>) => Promise<void> | void;
}; };
export type BoundStory<TFramework extends AnyFramework = AnyFramework> = Story<TFramework> & { export type BoundStory<TFramework extends AnyFramework = AnyFramework> = Story<TFramework> & {
@ -99,23 +105,6 @@ export declare type RenderContext<TFramework extends AnyFramework = AnyFramework
unboundStoryFn: LegacyStoryFn<TFramework>; unboundStoryFn: LegacyStoryFn<TFramework>;
}; };
interface BaseIndexEntry {
id: StoryId;
name: StoryName;
title: ComponentTitle;
importPath: Path;
}
export type StoryIndexEntry = BaseIndexEntry & {
type: 'story';
};
export type DocsIndexEntry = BaseIndexEntry & {
storiesImports: Path[];
type: 'docs';
legacy?: boolean;
};
export type IndexEntry = StoryIndexEntry | DocsIndexEntry;
export interface V2CompatIndexEntry extends Omit<StoryIndexEntry, 'type'> { export interface V2CompatIndexEntry extends Omit<StoryIndexEntry, 'type'> {
kind: StoryIndexEntry['title']; kind: StoryIndexEntry['title'];
story: StoryIndexEntry['name']; story: StoryIndexEntry['name'];

View File

@ -1,6 +1,7 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"strict": true
}, },
"include": [ "include": [
"src/**/*" "src/**/*"

View File

@ -83,7 +83,7 @@ export function composeStory<TArgs = Args>(
exportsName?: string exportsName?: string
) { ) {
return originalComposeStory<ReactFramework, TArgs>( return originalComposeStory<ReactFramework, TArgs>(
story, story as ComposedStory<ReactFramework, Args>,
componentAnnotations, componentAnnotations,
projectAnnotations, projectAnnotations,
defaultProjectAnnotations, defaultProjectAnnotations,