From 17ad46877b29660c34a3246302fe6f32e63616f1 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 28 Sep 2026 16:21:33 +0200 Subject: [PATCH 1/5] Core: Remove argTypes from the story context of loaders, beforeEach, play and afterEach Lifecycle hooks now receive a proxied context that throws a categorized ArgTypesRemovedFromStoryContextError when argTypes is read. Decorators and render functions keep argTypes through the new StoryContextForRender type, because renderers depend on it while rendering. --- MIGRATION.md | 33 ++++++++++++ code/core/src/actions/addArgsHelpers.test.ts | 26 +++++----- code/core/src/csf/story.ts | 33 ++++++++++-- code/core/src/measure/withMeasure.test.ts | 6 +-- code/core/src/outline/withOutline.test.ts | 6 +-- .../modules/preview-web/PreviewWeb.test.ts | 19 ++----- .../preview-web/render/StoryRender.test.ts | 48 ++++++++++++++++++ .../modules/preview-web/render/StoryRender.ts | 18 ++++--- .../modules/store/csf/hideArgTypes.test.ts | 50 +++++++++++++++++++ .../modules/store/csf/hideArgTypes.ts | 29 +++++++++++ .../preview-api/modules/store/csf/index.ts | 1 + .../store/csf/portable-stories.test.ts | 31 ++++++++++++ .../modules/store/csf/portable-stories.ts | 26 ++++++---- .../modules/store/csf/prepareStory.test.ts | 7 +-- .../modules/store/csf/prepareStory.ts | 12 ++--- .../modules/store/decorators.test.ts | 14 +++--- .../preview-api/modules/store/decorators.ts | 4 +- .../preview-api/modules/store/hooks.test.ts | 4 +- code/core/src/preview-errors.ts | 18 +++++++ code/core/src/types/modules/addons.ts | 2 +- code/core/src/types/modules/csf.ts | 1 + code/core/src/types/modules/story.ts | 3 +- .../core/template/stories/argTypes.stories.ts | 15 +++++- .../src/client/decorateStory.test.ts | 17 ++++--- .../angular-vite/src/client/decorateStory.ts | 10 ++-- .../src/client/docs/sourceDecorator.ts | 4 +- .../angular/src/client/decorateStory.test.ts | 17 ++++--- .../angular/src/client/decorateStory.ts | 10 ++-- .../src/client/docs/sourceDecorator.ts | 4 +- .../src/svelte/svelte-baselines.test.ts | 4 +- .../src/__test__/RenderToCanvas.stories.tsx | 9 +++- .../react/src/docs/jsxDecorator.test.tsx | 15 ++++-- .../renderers/react/src/docs/jsxDecorator.tsx | 4 +- .../react/src/extractArgTypes.test.ts | 4 +- code/renderers/svelte/src/decorators.ts | 12 +++-- code/renderers/vue3/src/decorateStory.ts | 49 ++++++++++-------- code/renderers/vue3/src/render.ts | 3 +- .../src/docs/sourceDecorator.ts | 9 +++- docs/writing-stories/decorators.mdx | 2 +- 39 files changed, 433 insertions(+), 146 deletions(-) create mode 100644 code/core/src/preview-api/modules/store/csf/hideArgTypes.test.ts create mode 100644 code/core/src/preview-api/modules/store/csf/hideArgTypes.ts diff --git a/MIGRATION.md b/MIGRATION.md index 91b539938034..8574e8a2c99e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -4,6 +4,7 @@ - [`storybook dev` no longer opens a browser by default](#storybook-dev-no-longer-opens-a-browser-by-default) - [Raised browser support floors](#raised-browser-support-floors) - [Docs Code panel enabled by default](#docs-code-panel-enabled-by-default) + - [`argTypes` removed from loaders, `beforeEach`, `play` and `afterEach`](#argtypes-removed-from-loaders-beforeeach-play-and-aftereach) - [Node.js 22.12 or higher](#nodejs-2212-or-higher) - [TypeScript 5.9 or 6.x](#typescript-59-or-6x) - [Yarn PnP support removed](#yarn-pnp-support-removed) @@ -596,6 +597,38 @@ You can also set this parameter at the component or story level. An explicit `tr No automigration is needed. Existing boolean settings retain their meaning, and projects with no setting receive the new default. +### `argTypes` removed from loaders, `beforeEach`, `play` and `afterEach` + +The story context passed to loaders, `beforeEach`, `play`, `afterEach` and `step` callbacks no longer contains `argTypes`. Reading it throws an error that links here. + +With server-side docgen (`features.experimentalDocgenServer`, which becomes the default in Storybook 11), the preview no longer infers arg types from components or args. `context.argTypes` in these hooks only ever contained the arg types you declared by hand, so it looked complete but was not. + +```ts +// Before +export const Primary: Story = { + play: async ({ argTypes, args }) => { + for (const name of Object.keys(argTypes)) { + // ... + } + }, +}; + +// After +export const Primary: Story = { + play: async ({ args }) => { + for (const name of Object.keys(args)) { + // ... + } + }, +}; +``` + +- Use `args` for the values passed to the story. Iterate `Object.keys(args)` instead of `Object.keys(argTypes)`. +- To see the resolved arg types, including those inferred from your component, use the Controls panel or the `ArgTypes` doc block. +- In portable stories, `composeStory(Story, meta).argTypes` still exposes the story's declared arg types outside of the lifecycle hooks. + +Decorators and `render` functions keep receiving `argTypes`, because renderers rely on them while rendering. Their context type is the new `StoryContextForRender`; the `StoryContext` type no longer declares `argTypes`. Custom decorator or render helpers that annotate their context parameter as `StoryContext` and read `argTypes` should switch to `StoryContextForRender`. + ### Node.js 22.12 or higher Storybook 11 targets Node.js 22.12 or higher. Before upgrading, update Node.js in your local development environment, CI jobs, and deployment environments that build Storybook. Update any Node.js version pins, such as `.nvmrc`, `.node-version`, or your CI configuration. diff --git a/code/core/src/actions/addArgsHelpers.test.ts b/code/core/src/actions/addArgsHelpers.test.ts index 6d875f5af14c..4fce44cecc7b 100644 --- a/code/core/src/actions/addArgsHelpers.test.ts +++ b/code/core/src/actions/addArgsHelpers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { StoryContext } from 'storybook/internal/types'; +import type { StoryContextForEnhancers } from 'storybook/internal/types'; import { addActionsFromArgTypes, inferActionsFromArgTypesRegex } from './addArgsHelpers.ts'; @@ -14,7 +14,7 @@ describe('actions parameter enhancers', () => { initialArgs: {}, argTypes, parameters, - } as unknown as StoryContext); + } as unknown as StoryContextForEnhancers); expect(args).toEqual({ onClick: expect.any(Function), onFocus: expect.any(Function), @@ -26,7 +26,7 @@ describe('actions parameter enhancers', () => { initialArgs: { onClick: 'pre-existing value' }, argTypes, parameters, - } as unknown as StoryContext); + } as unknown as StoryContextForEnhancers); expect(args).toEqual({ onFocus: expect.any(Function) }); }); @@ -35,7 +35,7 @@ describe('actions parameter enhancers', () => { initialArgs: { onClick: null }, argTypes, parameters, - } as unknown as StoryContext); + } as unknown as StoryContextForEnhancers); expect(args).toEqual({ onFocus: expect.any(Function) }); }); @@ -44,7 +44,7 @@ describe('actions parameter enhancers', () => { initialArgs: {}, argTypes, parameters, - } as unknown as StoryContext); + } as unknown as StoryContextForEnhancers); expect(args).toEqual({ onClick: expect.any(Function), onFocus: expect.any(Function) }); }); @@ -53,7 +53,7 @@ describe('actions parameter enhancers', () => { initialArgs: { onClick: undefined }, argTypes, parameters, - } as unknown as StoryContext); + } as unknown as StoryContextForEnhancers); expect(args).toEqual({ onClick: undefined, onFocus: expect.any(Function) }); }); @@ -65,7 +65,7 @@ describe('actions parameter enhancers', () => { ...parameters, actions: { ...parameters.actions, disable: true }, }, - } as unknown as StoryContext); + } as unknown as StoryContextForEnhancers); expect(args).toEqual({}); }); }); @@ -81,7 +81,7 @@ describe('actions parameter enhancers', () => { initialArgs: {}, argTypes, parameters: {}, - } as unknown as StoryContext) + } as unknown as StoryContextForEnhancers) ).toEqual({ onClick: expect.any(Function), onBlur: expect.any(Function), @@ -94,7 +94,7 @@ describe('actions parameter enhancers', () => { argTypes: { onClick: { action: 'clicked!' } }, initialArgs: { onClick: 'pre-existing value' }, parameters: {}, - } as unknown as StoryContext) + } as unknown as StoryContextForEnhancers) ).toEqual({}); }); @@ -104,7 +104,7 @@ describe('actions parameter enhancers', () => { argTypes: { onClick: { action: 'clicked!' } }, initialArgs: { onClick: null }, parameters: {}, - } as unknown as StoryContext) + } as unknown as StoryContextForEnhancers) ).toEqual({}); }); @@ -114,7 +114,7 @@ describe('actions parameter enhancers', () => { argTypes: { onClick: { action: 'clicked!' } }, initialArgs: {}, parameters: {}, - } as unknown as StoryContext) + } as unknown as StoryContextForEnhancers) ).toEqual({ onClick: expect.any(Function) }); }); @@ -124,7 +124,7 @@ describe('actions parameter enhancers', () => { argTypes: { onClick: { action: 'clicked!' } }, initialArgs: { onClick: undefined }, parameters: {}, - } as unknown as StoryContext) + } as unknown as StoryContextForEnhancers) ).toEqual({ onClick: undefined }); }); @@ -134,7 +134,7 @@ describe('actions parameter enhancers', () => { initialArgs: {}, argTypes, parameters: { actions: { disable: true } }, - } as unknown as StoryContext) + } as unknown as StoryContextForEnhancers) ).toEqual({}); }); }); diff --git a/code/core/src/csf/story.ts b/code/core/src/csf/story.ts index 70ef08cc67bd..6d820b2da5d7 100644 --- a/code/core/src/csf/story.ts +++ b/code/core/src/csf/story.ts @@ -270,12 +270,22 @@ export type AfterEach = ( export interface Canvas extends BoundFunctions {} export interface StoryContext - extends StoryContextForEnhancers, Required> { + extends StoryIdentifier, Required> { + component?: (TRenderer & { T: any })['component']; + subcomponents?: Record; + parameters: Parameters; + initialArgs: TArgs; loaded: Record; abortSignal: AbortSignal; canvasElement: TRenderer['canvasElement']; hooks: unknown; - originalStoryFn: ArgsStoryFn; + // Declared as a method rather than `originalStoryFn: ArgsStoryFn`: as a property, the + // context parameter would make StoryContext invariant in TRenderer, so a generic + // PlayFunction could no longer be assigned to a framework-specific story. + originalStoryFn( + args: Args, + context: StoryContextForRender + ): (TRenderer & { T: Args })['storyResult']; viewMode: ViewMode; step: StepFunction; context: this; @@ -285,6 +295,19 @@ export interface StoryContext extends StoryContext { + argTypes: StrictArgTypes; +} + /** @deprecated Use {@link StoryContext} instead. */ export interface StoryContextForLoaders< TRenderer extends Renderer = Renderer, @@ -319,13 +342,13 @@ export type PartialStoryFn // This is a passArgsFirst: false user story function export type LegacyStoryFn = ( - context: StoryContext + context: StoryContextForRender ) => TRenderer['storyResult']; // This is a passArgsFirst: true user story function export type ArgsStoryFn = ( args: TArgs, - context: StoryContext + context: StoryContextForRender ) => (TRenderer & { T: TArgs })['storyResult']; // This is either type of user story function @@ -335,7 +358,7 @@ export type StoryFn = export type DecoratorFunction = ( fn: PartialStoryFn, - c: StoryContext + c: StoryContextForRender ) => TRenderer['storyResult']; export type DecoratorApplicator = ( diff --git a/code/core/src/measure/withMeasure.test.ts b/code/core/src/measure/withMeasure.test.ts index 7d0247b4a5ce..f2df2f5951d2 100644 --- a/code/core/src/measure/withMeasure.test.ts +++ b/code/core/src/measure/withMeasure.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { StoryContext } from 'storybook/internal/types'; +import type { StoryContextForRender } from 'storybook/internal/types'; vi.mock('storybook/preview-api', () => ({ useEffect: (cb: () => void) => { @@ -21,11 +21,11 @@ vi.mock('./box-model/visualizer.ts', () => ({ import * as canvas from './box-model/canvas.ts'; import { withMeasure } from './withMeasure.ts'; -const runDecorator = (context: Partial) => +const runDecorator = (context: Partial) => withMeasure(() => 'story', { viewMode: 'story', ...context, - } as StoryContext); + } as StoryContextForRender); describe('withMeasure', () => { beforeEach(() => { diff --git a/code/core/src/outline/withOutline.test.ts b/code/core/src/outline/withOutline.test.ts index 15316803e5aa..4facbf61cdbe 100644 --- a/code/core/src/outline/withOutline.test.ts +++ b/code/core/src/outline/withOutline.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { StoryContext } from 'storybook/internal/types'; +import type { StoryContextForRender } from 'storybook/internal/types'; vi.mock('storybook/preview-api', () => ({ useEffect: (cb: () => void) => { @@ -14,12 +14,12 @@ import { withOutline } from './withOutline.ts'; const OUTLINE_STYLE_ID = 'addon-outline'; -const runDecorator = (context: Partial) => +const runDecorator = (context: Partial) => withOutline(() => 'story', { viewMode: 'story', id: 'test--story', ...context, - } as StoryContext); + } as StoryContextForRender); describe('withOutline', () => { beforeEach(() => { diff --git a/code/core/src/preview-api/modules/preview-web/PreviewWeb.test.ts b/code/core/src/preview-api/modules/preview-web/PreviewWeb.test.ts index 714346b5f5cb..01712a252b53 100644 --- a/code/core/src/preview-api/modules/preview-web/PreviewWeb.test.ts +++ b/code/core/src/preview-api/modules/preview-web/PreviewWeb.test.ts @@ -27,6 +27,7 @@ import { UPDATE_GLOBALS, UPDATE_STORY_ARGS, } from 'storybook/internal/core-events'; +import { ArgTypesRemovedFromStoryContextError } from 'storybook/internal/preview-errors'; import type { ModuleImportFn, ProjectAnnotations, Renderer } from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -505,13 +506,11 @@ describe('PreviewWeb', () => { throwPlayFunctionExceptions: false, }, initialArgs: { foo: 'a', one: 1 }, - argTypes: { - foo: { name: 'foo', type: { name: 'string' } }, - one: { name: 'one', type: { name: 'string' }, mapping: { 1: 'mapped-1' } }, - }, args: { foo: 'a', one: 'mapped-1' }, }) ); + const loaderContext = componentOneExports.default.loaders[0].mock.calls[0][0]; + expect(() => loaderContext.argTypes).toThrow(ArgTypesRemovedFromStoryContextError); }); it('passes loaded context to renderToCanvas', async () => { @@ -2169,10 +2168,6 @@ describe('PreviewWeb', () => { fileName: './src/ComponentOne.stories.js', }), initialArgs: { foo: 'b', one: 1 }, - argTypes: { - foo: { name: 'foo', type: { name: 'string' } }, - one: { name: 'one', type: { name: 'string' }, mapping: { 1: 'mapped-1' } }, - }, args: { foo: 'b', one: 'mapped-1' }, }) ); @@ -2749,10 +2744,6 @@ describe('PreviewWeb', () => { fileName: './src/ComponentOne.stories.js', }), initialArgs: { foo: 'a', one: 1 }, - argTypes: { - foo: { name: 'foo', type: { name: 'string' } }, - one: { name: 'one', type: { name: 'string' }, mapping: { 1: 'mapped-1' } }, - }, args: { foo: 'a', one: 'mapped-1' }, }) ); @@ -3094,10 +3085,6 @@ describe('PreviewWeb', () => { throwPlayFunctionExceptions: false, }, initialArgs: { foo: 'edited', one: 1 }, - argTypes: { - foo: { name: 'foo', type: { name: 'string' } }, - one: { name: 'one', type: { name: 'string' }, mapping: { 1: 'mapped-1' } }, - }, args: { foo: 'edited', one: 'mapped-1' }, }) ); diff --git a/code/core/src/preview-api/modules/preview-web/render/StoryRender.test.ts b/code/core/src/preview-api/modules/preview-web/render/StoryRender.test.ts index fc29ea17b43b..47e46b3a659d 100644 --- a/code/core/src/preview-api/modules/preview-web/render/StoryRender.test.ts +++ b/code/core/src/preview-api/modules/preview-web/render/StoryRender.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Channel } from 'storybook/internal/channels'; import { STORY_FINISHED } from 'storybook/internal/core-events'; +import { ArgTypesRemovedFromStoryContextError } from 'storybook/internal/preview-errors'; import type { PreparedStory, Renderer, @@ -166,6 +167,53 @@ describe('StoryRender', () => { expect(mountSpy).toHaveBeenCalledOnce(); }); + it('hides argTypes from lifecycle hooks but passes them to the renderer', async () => { + const argTypes = { label: { name: 'label' } }; + const hookContexts: StoryContext[] = []; + const story = buildStory({ + applyLoaders: vi.fn(async (context) => { + hookContexts.push(context); + return {}; + }), + applyBeforeEach: vi.fn(async (context) => { + hookContexts.push(context); + return []; + }), + playFunction: vi.fn(async (context) => { + hookContexts.push(context); + await context.step('step', async (stepContext: StoryContext) => { + hookContexts.push(stepContext); + }); + }), + applyAfterEach: vi.fn(async (context) => { + hookContexts.push(context); + }), + runStep: vi.fn((label, play, context) => play(context)), + }); + const renderToScreen = vi.fn(); + const render = new StoryRender( + new Channel({}), + buildStore({ + getStoryContext: () => ({ argTypes, reporting: new ReporterAPI() }) as any, + }), + renderToScreen, + {} as any, + entry.id, + 'story', + { autoplay: true }, + story + ); + + await render.renderToElement({} as any); + + expect(hookContexts).toHaveLength(5); + for (const context of hookContexts) { + expect(() => context.argTypes).toThrow(ArgTypesRemovedFromStoryContextError); + expect('argTypes' in context).toBe(false); + } + expect(renderToScreen.mock.calls[0][0].storyContext.argTypes).toBe(argTypes); + }); + it('does not call mount twice if mount called in play function', async () => { const story = buildStory({ usesMount: true, diff --git a/code/core/src/preview-api/modules/preview-web/render/StoryRender.ts b/code/core/src/preview-api/modules/preview-web/render/StoryRender.ts index f85b01751c6a..45ba1ea1544b 100644 --- a/code/core/src/preview-api/modules/preview-web/render/StoryRender.ts +++ b/code/core/src/preview-api/modules/preview-web/render/StoryRender.ts @@ -19,6 +19,7 @@ import type { RenderToCanvas, Renderer, StoryContext, + StoryContextForRender, StoryId, StoryRenderOptions, TeardownRenderToCanvas, @@ -26,7 +27,7 @@ import type { import type { UserEventObject } from 'storybook/test'; -import type { StoryStore } from '../../store/index.ts'; +import { type StoryStore, hideArgTypes } from '../../store/index.ts'; import type { Render, RenderType } from './Render.ts'; import { PREPARE_ABORTED } from './Render.ts'; import { isTestEnvironment, pauseAnimations, waitForAnimations } from './animation-utils.ts'; @@ -225,13 +226,13 @@ export class StoryRender implements Render = { + const context: StoryContextForRender = { ...this.storyContext(), viewMode: this.viewMode, abortSignal, canvasElement, loaded: {}, - step: (label, play) => runStep(label, play, context), + step: (label, play) => runStep(label, play, hookContext), context: null!, canvas: {} as Canvas, userEvent: {} as UserEventObject, @@ -261,6 +262,7 @@ export class StoryRender implements Render = { componentId, @@ -285,14 +287,14 @@ export class StoryRender implements Render { - context.loaded = await applyLoaders(context); + context.loaded = await applyLoaders(hookContext); }); if (abortSignal.aborted) { return; } - const cleanupCallbacks = await applyBeforeEach(context); + const cleanupCallbacks = await applyBeforeEach(hookContext); this.store.addCleanupCallbacks(story, ...cleanupCallbacks); if (this.checkIfAborted(abortSignal)) { @@ -334,10 +336,10 @@ export class StoryRender implements Render { throw new MountMustBeDestructuredError({ playFunction: playFunction.toString() }); }; - await this.runPhase(abortSignal, 'playing', async () => playFunction(context)); + await this.runPhase(abortSignal, 'playing', async () => playFunction(hookContext)); } else { // when mount is used the playing phase will start later, right after mount is called in the play function - await playFunction(context); + await playFunction(hookContext); } if (!mounted) { @@ -392,7 +394,7 @@ export class StoryRender implements Render { - await applyAfterEach(context); + await applyAfterEach(hookContext); }); } diff --git a/code/core/src/preview-api/modules/store/csf/hideArgTypes.test.ts b/code/core/src/preview-api/modules/store/csf/hideArgTypes.test.ts new file mode 100644 index 000000000000..3d4d48f96086 --- /dev/null +++ b/code/core/src/preview-api/modules/store/csf/hideArgTypes.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { ArgTypesRemovedFromStoryContextError } from 'storybook/internal/preview-errors'; +import type { StoryContextForRender } from 'storybook/internal/types'; + +import { hideArgTypes } from './hideArgTypes.ts'; + +const createContext = () => { + const context = { + id: 'component--a', + args: { label: 'hello' }, + argTypes: { label: { name: 'label' } }, + loaded: {}, + } as unknown as StoryContextForRender; + context.context = context; + return context; +}; + +describe('hideArgTypes', () => { + it('throws a descriptive error when argTypes is read', () => { + const hidden = hideArgTypes(createContext()); + + expect(() => hidden.argTypes).toThrow(ArgTypesRemovedFromStoryContextError); + }); + + it('omits argTypes from enumeration and membership checks', () => { + const hidden = hideArgTypes(createContext()); + + expect('argTypes' in hidden).toBe(false); + expect(Object.keys(hidden)).toEqual(['id', 'args', 'loaded', 'context']); + expect({ ...hidden }).not.toHaveProperty('argTypes'); + }); + + it('passes other reads through and writes back to the original context', () => { + const context = createContext(); + const hidden = hideArgTypes(context); + + expect(hidden.args).toBe(context.args); + + hidden.loaded = { user: 'me' }; + expect(context.loaded).toEqual({ user: 'me' }); + }); + + it('returns itself for the self reference so argTypes stays hidden', () => { + const hidden = hideArgTypes(createContext()); + + expect(hidden.context).toBe(hidden); + expect(() => hidden.context.argTypes).toThrow(ArgTypesRemovedFromStoryContextError); + }); +}); diff --git a/code/core/src/preview-api/modules/store/csf/hideArgTypes.ts b/code/core/src/preview-api/modules/store/csf/hideArgTypes.ts new file mode 100644 index 000000000000..69a42c8cb969 --- /dev/null +++ b/code/core/src/preview-api/modules/store/csf/hideArgTypes.ts @@ -0,0 +1,29 @@ +import { ArgTypesRemovedFromStoryContextError } from 'storybook/internal/preview-errors'; +import type { Renderer, StoryContext, StoryContextForRender } from 'storybook/internal/types'; + +const HIDDEN_KEY = 'argTypes'; + +export function hideArgTypes( + context: StoryContextForRender +): StoryContext { + return new Proxy(context, { + get(target, key, receiver) { + if (key === HIDDEN_KEY) { + throw new ArgTypesRemovedFromStoryContextError(); + } + if (key === 'context') { + return receiver; + } + return Reflect.get(target, key, receiver); + }, + has(target, key) { + return key !== HIDDEN_KEY && Reflect.has(target, key); + }, + ownKeys(target) { + return Reflect.ownKeys(target).filter((key) => key !== HIDDEN_KEY); + }, + getOwnPropertyDescriptor(target, key) { + return key === HIDDEN_KEY ? undefined : Reflect.getOwnPropertyDescriptor(target, key); + }, + }); +} diff --git a/code/core/src/preview-api/modules/store/csf/index.ts b/code/core/src/preview-api/modules/store/csf/index.ts index 4e4bc26ca889..f1e87962c4a6 100644 --- a/code/core/src/preview-api/modules/store/csf/index.ts +++ b/code/core/src/preview-api/modules/store/csf/index.ts @@ -2,6 +2,7 @@ export * from './normalizeInputTypes.ts'; export * from './normalizeStory.ts'; export * from './processCSFFile.ts'; export * from './prepareStory.ts'; +export * from './hideArgTypes.ts'; export * from './normalizeComponentAnnotations.ts'; export * from './normalizeProjectAnnotations.ts'; export * from './normalizeArrays.ts'; diff --git a/code/core/src/preview-api/modules/store/csf/portable-stories.test.ts b/code/core/src/preview-api/modules/store/csf/portable-stories.test.ts index f842a26ddfc0..2594205c1d40 100644 --- a/code/core/src/preview-api/modules/store/csf/portable-stories.test.ts +++ b/code/core/src/preview-api/modules/store/csf/portable-stories.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest'; import type { ProjectAnnotations } from 'storybook/internal/csf'; +import { ArgTypesRemovedFromStoryContextError } from 'storybook/internal/preview-errors'; import type { ComponentAnnotations as Meta, Store_CSFExports, @@ -181,6 +182,36 @@ describe('composeStory', () => { ); }); + it('hides argTypes from the play function but passes them to the render function', async () => { + const hookContexts: any[] = []; + const renderContexts: any[] = []; + const Story: Story = { + argTypes: { label: { name: 'label' } }, + render: (_args, context) => { + renderContexts.push(context); + }, + play: async (context) => { + hookContexts.push(context); + }, + }; + + const composedStory = composeStory(Story, meta, { + mount: (context) => async () => { + context.renderToCanvas(); + return context.canvas; + }, + renderToCanvas: ({ storyFn }) => { + storyFn(); + }, + }); + await composedStory.run({ canvasElement: {} }); + + expect(hookContexts).toHaveLength(1); + expect(() => hookContexts[0].argTypes).toThrow(ArgTypesRemovedFromStoryContextError); + expect(renderContexts).toHaveLength(1); + expect(renderContexts[0].argTypes).toMatchObject({ label: { name: 'label' } }); + }); + it('should merge parameters with correct precedence in all combinations', async () => { const storyAnnotations = { render: () => {} }; const metaAnnotations: Meta = { parameters: { label: 'meta' } }; diff --git a/code/core/src/preview-api/modules/store/csf/portable-stories.ts b/code/core/src/preview-api/modules/store/csf/portable-stories.ts index 949593cab21f..48a276bf9ceb 100644 --- a/code/core/src/preview-api/modules/store/csf/portable-stories.ts +++ b/code/core/src/preview-api/modules/store/csf/portable-stories.ts @@ -19,6 +19,7 @@ import type { Renderer, Store_CSFExports, StoryContext, + StoryContextForRender, StrictArgTypes, } from 'storybook/internal/types'; @@ -38,6 +39,7 @@ import { getValuesFromGlobalTypes } from './getValuesFromGlobalTypes.ts'; import { normalizeComponentAnnotations } from './normalizeComponentAnnotations.ts'; import { normalizeProjectAnnotations } from './normalizeProjectAnnotations.ts'; import { normalizeStory } from './normalizeStory.ts'; +import { hideArgTypes } from './hideArgTypes.ts'; import { prepareContext, prepareStory } from './prepareStory.ts'; // TODO we should get to the bottom of the singleton issues caused by dual ESM/CJS modules @@ -139,7 +141,7 @@ export function composeStory { - const context: StoryContext = prepareContext({ + const context: StoryContextForRender = prepareContext({ hooks: new HooksContext(), globals, args: { ...story.initialArgs }, @@ -147,7 +149,7 @@ export function composeStory story.runStep(label, play, context), + step: (label, play) => story.runStep(label, play, hideArgTypes(context)), canvasElement: null!, canvas: {} as Canvas, userEvent: {} as UserEventObject, @@ -214,7 +216,7 @@ export function composeStory | undefined; + let loadedContext: StoryContextForRender | undefined; const play = async (extraContext?: Partial>>) => { const context = initializeContext(); @@ -223,7 +225,7 @@ export function composeStory>>) => { @@ -259,10 +261,11 @@ export function composeStory( // Will make a follow up PR for that async function runStory( story: PreparedStory, - context: StoryContext + context: StoryContextForRender ) { for (const callback of [...cleanups].reverse()) { await callback(); } cleanups.length = 0; + const hookContext = hideArgTypes(context); if (!context.canvasElement) { const container = document.createElement('div'); @@ -335,13 +339,13 @@ async function runStory( }); } - context.loaded = await story.applyLoaders(context); + context.loaded = await story.applyLoaders(hookContext); if (context.abortSignal.aborted) { return; } - cleanups.push(...(await story.applyBeforeEach(context)).filter(Boolean)); + cleanups.push(...(await story.applyBeforeEach(hookContext)).filter(Boolean)); const playFunction = story.playFunction; @@ -361,7 +365,7 @@ async function runStory( throw new MountMustBeDestructuredError({ playFunction: playFunction.toString() }); }; } - await playFunction(context); + await playFunction(hookContext); } let cleanUp: CleanupCallback | undefined; @@ -371,7 +375,7 @@ async function runStory( await waitForAnimations(context.abortSignal); } - await story.applyAfterEach(context); + await story.applyAfterEach(hookContext); await cleanUp?.(); } diff --git a/code/core/src/preview-api/modules/store/csf/prepareStory.test.ts b/code/core/src/preview-api/modules/store/csf/prepareStory.test.ts index 388a19b16ef8..10ecb0c54a2c 100644 --- a/code/core/src/preview-api/modules/store/csf/prepareStory.test.ts +++ b/code/core/src/preview-api/modules/store/csf/prepareStory.test.ts @@ -9,6 +9,7 @@ import type { Renderer, SBScalarType, StoryContext, + StoryContextForRender, } from 'storybook/internal/types'; import type { UserEventObject } from 'storybook/test'; @@ -47,8 +48,8 @@ export function prepareStory( // Extra fields that must be added to the story context after enhancers const addExtraContext = ( context: PreparedStory & Pick -): StoryContext => { - const extraContext: StoryContext = { +): StoryContextForRender => { + const extraContext: StoryContextForRender = { ...context, hooks: new HooksContext(), viewMode: 'story' as const, @@ -61,7 +62,7 @@ const addExtraContext = ( abortSignal: new AbortController().signal, canvasElement: {}, step: vi.fn(), - context: null! as StoryContext, + context: null! as StoryContextForRender, canvas: null!, userEvent: {} as UserEventObject, globalTypes: {}, diff --git a/code/core/src/preview-api/modules/store/csf/prepareStory.ts b/code/core/src/preview-api/modules/store/csf/prepareStory.ts index f051292cc103..3356d78d41a4 100644 --- a/code/core/src/preview-api/modules/store/csf/prepareStory.ts +++ b/code/core/src/preview-api/modules/store/csf/prepareStory.ts @@ -14,7 +14,7 @@ import type { Renderer, StoryContext, StoryContextForEnhancers, - StoryContextForLoaders, + StoryContextForRender, StrictArgTypes, } from 'storybook/internal/types'; @@ -101,7 +101,7 @@ export function prepareStory( } }; - const undecoratedStoryFn = (context: StoryContext) => + const undecoratedStoryFn = (context: StoryContextForRender) => context.originalStoryFn(context.args, context); // Currently it is only possible to set these globally @@ -122,7 +122,7 @@ export function prepareStory( projectAnnotations.render; const decoratedStoryFn = applyHooks(applyDecorators)(undecoratedStoryFn, decorators); - const unboundStoryFn = (context: StoryContext) => decoratedStoryFn(context); + const unboundStoryFn = (context: StoryContextForRender) => decoratedStoryFn(context); const playFunction = storyAnnotations?.play ?? componentAnnotations?.play; @@ -303,14 +303,14 @@ function preparePartialAnnotations( // eg. reactive proxies set by frameworks like SolidJS or Vue export function prepareContext< TRenderer extends Renderer, - TContext extends Pick, 'args' | 'argTypes' | 'globals'>, + TContext extends Pick, 'args' | 'argTypes' | 'globals'>, >( context: TContext -): TContext & Pick, 'allArgs' | 'argsByTarget' | 'unmappedArgs'> { +): TContext & Pick, 'allArgs' | 'argsByTarget' | 'unmappedArgs'> { const { args: unmappedArgs } = context; let targetedContext: TContext & - Pick, 'allArgs' | 'argsByTarget'> = { + Pick, 'allArgs' | 'argsByTarget'> = { ...context, allArgs: undefined, argsByTarget: undefined, diff --git a/code/core/src/preview-api/modules/store/decorators.test.ts b/code/core/src/preview-api/modules/store/decorators.test.ts index 0fd427752859..850673451637 100644 --- a/code/core/src/preview-api/modules/store/decorators.test.ts +++ b/code/core/src/preview-api/modules/store/decorators.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; -import type { Renderer, StoryContext } from 'storybook/internal/types'; +import type { Renderer, StoryContextForRender } from 'storybook/internal/types'; import { defaultDecorateStory } from './decorators.ts'; -function makeContext(input: Record = {}): StoryContext { +function makeContext(input: Record = {}): StoryContextForRender { return { id: 'id', kind: 'kind', @@ -12,7 +12,7 @@ function makeContext(input: Record = {}): StoryContext { viewMode: 'story', parameters: {}, ...input, - } as StoryContext; + } as StoryContextForRender; } describe('client-api.decorators', () => { @@ -34,7 +34,7 @@ describe('client-api.decorators', () => { }); it('passes context through to sub decorators', () => { - const contexts: StoryContext[] = []; + const contexts: StoryContextForRender[] = []; const decorators = [ // @ts-expect-error (not defined) (s, c) => contexts.push(c) && s({ args: { k: 1 } }), @@ -51,7 +51,7 @@ describe('client-api.decorators', () => { }); it('passes context through to sub decorators additively', () => { - const contexts: StoryContext[] = []; + const contexts: StoryContextForRender[] = []; const decorators = [ // @ts-expect-error (not defined) (s, c) => contexts.push(c) && s({ args: { a: 1 } }), @@ -92,7 +92,7 @@ describe('client-api.decorators', () => { // both story functions would receive {story: 2}. The assumption here is that we'll never render // the same story twice at the same time. it('does not interleave contexts if two decorated stories are call simultaneously', async () => { - const contexts: StoryContext[] = []; + const contexts: StoryContextForRender[] = []; let resolve: (value?: unknown) => void = () => {}; const fence = new Promise((r) => { resolve = r; @@ -119,7 +119,7 @@ describe('client-api.decorators', () => { }); it('DOES NOT merge core metadata or pass through core metadata keys in context', () => { - const contexts: StoryContext[] = []; + const contexts: StoryContextForRender[] = []; const decorators = [ // @ts-expect-error (not defined) (s, c) => diff --git a/code/core/src/preview-api/modules/store/decorators.ts b/code/core/src/preview-api/modules/store/decorators.ts index 861c358a9ac3..0953fbfd14fc 100644 --- a/code/core/src/preview-api/modules/store/decorators.ts +++ b/code/core/src/preview-api/modules/store/decorators.ts @@ -3,7 +3,7 @@ import type { LegacyStoryFn, PartialStoryFn, Renderer, - StoryContext, + StoryContextForRender, StoryContextUpdate, } from 'storybook/internal/types'; @@ -43,7 +43,7 @@ export function sanitizeStoryContextUpdate({ } type ContextStore = { - value?: StoryContext; + value?: StoryContextForRender; }; export function defaultDecorateStory( diff --git a/code/core/src/preview-api/modules/store/hooks.test.ts b/code/core/src/preview-api/modules/store/hooks.test.ts index 488e1c5e7551..9d6c28571f89 100644 --- a/code/core/src/preview-api/modules/store/hooks.test.ts +++ b/code/core/src/preview-api/modules/store/hooks.test.ts @@ -7,7 +7,7 @@ import { UPDATE_GLOBALS, UPDATE_STORY_ARGS, } from 'storybook/internal/core-events'; -import type { DecoratorFunction, StoryContext } from 'storybook/internal/types'; +import type { DecoratorFunction, StoryContextForRender } from 'storybook/internal/types'; import { HooksContext, @@ -65,7 +65,7 @@ beforeEach(() => { const decorateStory = applyHooks(defaultDecorateStory); const run = (storyFn: any, decorators: DecoratorFunction[] = [], context = {}) => - decorateStory(storyFn, decorators)({ ...context, hooks } as StoryContext); + decorateStory(storyFn, decorators)({ ...context, hooks } as StoryContextForRender); describe('Preview hooks', () => { describe('useEffect', () => { diff --git a/code/core/src/preview-errors.ts b/code/core/src/preview-errors.ts index c4a56cd50762..a03494c219c7 100644 --- a/code/core/src/preview-errors.ts +++ b/code/core/src/preview-errors.ts @@ -312,6 +312,24 @@ export class StatusTypeIdMismatchError extends StorybookError { } } +export class ArgTypesRemovedFromStoryContextError extends StorybookError { + constructor() { + super({ + name: 'ArgTypesRemovedFromStoryContextError', + category: Category.PREVIEW_API, + code: 17, + documentation: + 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#argtypes-removed-from-loaders-beforeeach-play-and-aftereach', + message: dedent` + \`argTypes\` is no longer part of the story context passed to loaders, beforeEach, play and afterEach. + + With server-side docgen the preview only holds the arg types you declared yourself, so this object was incomplete while looking complete. + - Read \`args\` for the values passed to the story. + - Inspect the resolved arg types in the Controls panel or the ArgTypes doc block.`, + }); + } +} + export class NextJsSharpError extends StorybookError { constructor() { super({ diff --git a/code/core/src/types/modules/addons.ts b/code/core/src/types/modules/addons.ts index c2abc0a134c1..31613a4119dd 100644 --- a/code/core/src/types/modules/addons.ts +++ b/code/core/src/types/modules/addons.ts @@ -14,7 +14,7 @@ import type { Parameters, PartialStoryFn as PartialStoryFnForFramework, Renderer, - StoryContext as StoryContextForFramework, + StoryContextForRender as StoryContextForFramework, StoryFn as StoryFnForFramework, StoryId, StoryKind, diff --git a/code/core/src/types/modules/csf.ts b/code/core/src/types/modules/csf.ts index 1237ca9bf95b..72c5419f7c38 100644 --- a/code/core/src/types/modules/csf.ts +++ b/code/core/src/types/modules/csf.ts @@ -53,6 +53,7 @@ export type { StoryContext, StoryContextForEnhancers, StoryContextForLoaders, + StoryContextForRender, StoryContextUpdate, StoryFn, StoryId, diff --git a/code/core/src/types/modules/story.ts b/code/core/src/types/modules/story.ts index d874f05731ed..1cb64d79dfc5 100644 --- a/code/core/src/types/modules/story.ts +++ b/code/core/src/types/modules/story.ts @@ -18,6 +18,7 @@ import type { StoryAnnotations, StoryContext, StoryContextForEnhancers, + StoryContextForRender, StoryId, StoryIdentifier, StoryName, @@ -131,7 +132,7 @@ export declare type RenderContext = Story showError: (error: { title: string; description: string }) => void; showException: (err: Error) => void; forceRemount: boolean; - storyContext: StoryContext; + storyContext: StoryContextForRender; storyFn: PartialStoryFn; unboundStoryFn: LegacyStoryFn; }; diff --git a/code/core/template/stories/argTypes.stories.ts b/code/core/template/stories/argTypes.stories.ts index bc4eb1d9e9a4..9f313aed0a47 100644 --- a/code/core/template/stories/argTypes.stories.ts +++ b/code/core/template/stories/argTypes.stories.ts @@ -1,4 +1,8 @@ -import type { PartialStoryFn, PlayFunctionContext, StoryContext } from 'storybook/internal/types'; +import type { + PartialStoryFn, + PlayFunctionContext, + StoryContextForRender, +} from 'storybook/internal/types'; import { global as globalThis } from '@storybook/global'; @@ -8,7 +12,7 @@ export default { component: globalThis.__TEMPLATE_COMPONENTS__.Pre, // Compose all the argTypes into `object`, so the pre component only needs a single prop decorators: [ - (storyFn: PartialStoryFn, context: StoryContext) => + (storyFn: PartialStoryFn, context: StoryContextForRender) => storyFn({ args: { object: { ...context.argTypes } } }), ], argTypes: { @@ -66,3 +70,10 @@ export const ArgTypeInference = { }); }, }; + +export const HiddenFromPlay = { + play: async (context: PlayFunctionContext) => { + expect('argTypes' in context).toBe(false); + expect(() => context.argTypes).toThrow(/no longer part of the story context/); + }, +}; diff --git a/code/frameworks/angular-vite/src/client/decorateStory.test.ts b/code/frameworks/angular-vite/src/client/decorateStory.test.ts index 96d80badc99f..b97bedd2a44a 100644 --- a/code/frameworks/angular-vite/src/client/decorateStory.test.ts +++ b/code/frameworks/angular-vite/src/client/decorateStory.test.ts @@ -1,5 +1,5 @@ import { Component, Input, Output } from '@angular/core'; -import type { DecoratorFunction, StoryContext } from 'storybook/internal/types'; +import type { DecoratorFunction, StoryContextForRender } from 'storybook/internal/types'; import { describe, expect, it } from 'vitest'; import { componentWrapperDecorator } from './decorators.ts'; @@ -214,7 +214,7 @@ describe('decorateStory', () => { it('passes every arg to the story, including ones carrying no control', () => { const decorated = decorateStory( - (context: StoryContext) => ({ + (context: StoryContextForRender) => ({ template: `Args available in the story : ${Object.keys(context.args).join()}`, }), [] @@ -279,7 +279,10 @@ describe('decorateStory', () => { return { ...story, props: { a: [...story.props.a, c.k] } }; }, ]; - const decorated = decorateStory((c: StoryContext) => ({ props: { a: [c.k] } }), decorators); + const decorated = decorateStory( + (c: StoryContextForRender) => ({ props: { a: [c.k] } }), + decorators + ); expect(decorated(makeContext({ k: 0 }))).toEqual({ props: { a: [1, 2, 3, 0] } }); }); @@ -309,7 +312,9 @@ describe('decorateStory', () => { }, ]; const decorated = decorateStory( - (c: StoryContext) => ({ props: { a: [c.k], p: [c.parameters.p] } }), + (c: StoryContextForRender) => ({ + props: { a: [c.k], p: [c.parameters.p] }, + }), decorators ); @@ -320,7 +325,7 @@ describe('decorateStory', () => { }); }); -function makeContext(input: Record): StoryContext { +function makeContext(input: Record): StoryContextForRender { return { id: 'id', kind: 'kind', @@ -328,7 +333,7 @@ function makeContext(input: Record): StoryContext; + } as StoryContextForRender; } @Component({ diff --git a/code/frameworks/angular-vite/src/client/decorateStory.ts b/code/frameworks/angular-vite/src/client/decorateStory.ts index 3b65a417056c..49bf0cf0ccbb 100644 --- a/code/frameworks/angular-vite/src/client/decorateStory.ts +++ b/code/frameworks/angular-vite/src/client/decorateStory.ts @@ -1,5 +1,9 @@ import { sanitizeStoryContextUpdate } from 'storybook/preview-api'; -import type { DecoratorFunction, LegacyStoryFn, StoryContext } from 'storybook/internal/types'; +import type { + DecoratorFunction, + LegacyStoryFn, + StoryContextForRender, +} from 'storybook/internal/types'; import { computesTemplateFromComponent } from './renderer/ComputesTemplateFromComponent.ts'; import type { AngularRenderer } from './types.ts'; @@ -10,7 +14,7 @@ export default function decorateStory( ): LegacyStoryFn { const returnDecorators = decorators.reduce( (previousStoryFn: LegacyStoryFn, decorator) => - (context: StoryContext) => { + (context: StoryContextForRender) => { const decoratedStory = decorator((update) => { return previousStoryFn({ ...context, @@ -30,7 +34,7 @@ export { decorateStory }; const prepareMain = ( story: AngularRenderer['storyResult'], - context: StoryContext + context: StoryContextForRender ): AngularRenderer['storyResult'] => { let { template } = story; diff --git a/code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts b/code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts index 4e4e2911ccb3..cdee748c6786 100644 --- a/code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts +++ b/code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts @@ -1,6 +1,6 @@ import { SourceType } from 'storybook/internal/docs-tools'; import { useRef, emitTransformCode, useEffect } from 'storybook/preview-api'; -import type { ArgsStoryFn, PartialStoryFn } from 'storybook/internal/types'; +import type { ArgsStoryFn, PartialStoryFn, StoryContextForRender } from 'storybook/internal/types'; import { computesTemplateSourceFromComponent } from '../../renderer'; import type { AngularRenderer, StoryContext } from '../types'; @@ -25,7 +25,7 @@ export const skipSourceRender = (context: StoryContext) => { */ export const sourceDecorator = ( storyFn: PartialStoryFn, - context: StoryContext + context: StoryContextForRender ) => { const story = storyFn(); const source = useRef(undefined); diff --git a/code/frameworks/angular/src/client/decorateStory.test.ts b/code/frameworks/angular/src/client/decorateStory.test.ts index d2ca56f304b3..2975c8751ab0 100644 --- a/code/frameworks/angular/src/client/decorateStory.test.ts +++ b/code/frameworks/angular/src/client/decorateStory.test.ts @@ -1,5 +1,5 @@ import { Component, Input, Output } from '@angular/core'; -import type { DecoratorFunction, StoryContext } from 'storybook/internal/types'; +import type { DecoratorFunction, StoryContextForRender } from 'storybook/internal/types'; import { describe, expect, it } from 'vitest'; import { componentWrapperDecorator } from './decorators.ts'; @@ -209,7 +209,7 @@ describe.skip('decorateStory', () => { it('should only keeps args with a control or an action in argTypes', () => { const decorated = decorateStory( - (context: StoryContext) => ({ + (context: StoryContextForRender) => ({ template: `Args available in the story : ${Object.keys(context.args).join()}`, }), [] @@ -274,7 +274,10 @@ describe.skip('decorateStory', () => { return { ...story, props: { a: [...story.props.a, c.k] } }; }, ]; - const decorated = decorateStory((c: StoryContext) => ({ props: { a: [c.k] } }), decorators); + const decorated = decorateStory( + (c: StoryContextForRender) => ({ props: { a: [c.k] } }), + decorators + ); expect(decorated(makeContext({ k: 0 }))).toEqual({ props: { a: [1, 2, 3, 0] } }); }); @@ -304,7 +307,9 @@ describe.skip('decorateStory', () => { }, ]; const decorated = decorateStory( - (c: StoryContext) => ({ props: { a: [c.k], p: [c.parameters.p] } }), + (c: StoryContextForRender) => ({ + props: { a: [c.k], p: [c.parameters.p] }, + }), decorators ); @@ -315,7 +320,7 @@ describe.skip('decorateStory', () => { }); }); -function makeContext(input: Record): StoryContext { +function makeContext(input: Record): StoryContextForRender { return { id: 'id', kind: 'kind', @@ -323,7 +328,7 @@ function makeContext(input: Record): StoryContext; + } as StoryContextForRender; } @Component({ diff --git a/code/frameworks/angular/src/client/decorateStory.ts b/code/frameworks/angular/src/client/decorateStory.ts index b683531a4d10..82013c001f53 100644 --- a/code/frameworks/angular/src/client/decorateStory.ts +++ b/code/frameworks/angular/src/client/decorateStory.ts @@ -1,5 +1,9 @@ import { sanitizeStoryContextUpdate } from 'storybook/preview-api'; -import type { DecoratorFunction, LegacyStoryFn, StoryContext } from 'storybook/internal/types'; +import type { + DecoratorFunction, + LegacyStoryFn, + StoryContextForRender, +} from 'storybook/internal/types'; import { computesTemplateFromComponent } from './angular-beta/ComputesTemplateFromComponent.ts'; import type { AngularRenderer } from './types.ts'; @@ -10,7 +14,7 @@ export default function decorateStory( ): LegacyStoryFn { const returnDecorators = [cleanArgsDecorator, ...decorators].reduce( (previousStoryFn: LegacyStoryFn, decorator) => - (context: StoryContext) => { + (context: StoryContextForRender) => { const decoratedStory = decorator((update) => { return previousStoryFn({ ...context, @@ -30,7 +34,7 @@ export { decorateStory }; const prepareMain = ( story: AngularRenderer['storyResult'], - context: StoryContext + context: StoryContextForRender ): AngularRenderer['storyResult'] => { let { template } = story; diff --git a/code/frameworks/angular/src/client/docs/sourceDecorator.ts b/code/frameworks/angular/src/client/docs/sourceDecorator.ts index d5315f8dff99..e2588fc4c27a 100644 --- a/code/frameworks/angular/src/client/docs/sourceDecorator.ts +++ b/code/frameworks/angular/src/client/docs/sourceDecorator.ts @@ -1,6 +1,6 @@ import { SourceType } from 'storybook/internal/docs-tools'; import { useRef, emitTransformCode, useEffect } from 'storybook/preview-api'; -import type { ArgsStoryFn, PartialStoryFn } from 'storybook/internal/types'; +import type { ArgsStoryFn, PartialStoryFn, StoryContextForRender } from 'storybook/internal/types'; import { computesTemplateSourceFromComponent } from '../../renderer'; import type { AngularRenderer, StoryContext } from '../types'; @@ -25,7 +25,7 @@ export const skipSourceRender = (context: StoryContext) => { */ export const sourceDecorator = ( storyFn: PartialStoryFn, - context: StoryContext + context: StoryContextForRender ) => { const story = storyFn(); const source = useRef(undefined); diff --git a/code/lib/docgen-harness/src/svelte/svelte-baselines.test.ts b/code/lib/docgen-harness/src/svelte/svelte-baselines.test.ts index e50008901a3f..a5b2e9c4acc3 100644 --- a/code/lib/docgen-harness/src/svelte/svelte-baselines.test.ts +++ b/code/lib/docgen-harness/src/svelte/svelte-baselines.test.ts @@ -11,7 +11,7 @@ import type { AnnotatedStoryFn, Args, StoryAnnotations, - StoryContext, + StoryContextForRender, StrictArgTypes, } from 'storybook/internal/types'; @@ -271,7 +271,7 @@ function getPlainCsfSnippetComponent( story: SvelteStory, args: Args ): ComponentWithDocgen { - const context = { args } as StoryContext; + const context = { args } as StoryContextForRender; if (typeof story === 'function') { const rendered = story(args, context); diff --git a/code/renderers/react/src/__test__/RenderToCanvas.stories.tsx b/code/renderers/react/src/__test__/RenderToCanvas.stories.tsx index d0e2d2a4f9e3..a343a430129c 100644 --- a/code/renderers/react/src/__test__/RenderToCanvas.stories.tsx +++ b/code/renderers/react/src/__test__/RenderToCanvas.stories.tsx @@ -2,7 +2,10 @@ import React, { useLayoutEffect, useState } from 'react'; import { expect } from 'storybook/test'; +import type { StoryContextForRender } from 'storybook/internal/types'; + import type { Meta, StoryObj } from '../index.ts'; +import type { ReactRenderer } from '../types.ts'; import { renderToCanvas } from '../renderToCanvas.tsx'; const LayoutEffectContent = () => { @@ -45,7 +48,11 @@ export const AwaitsDocsLayoutEffects: Story = { showError: () => {}, showException: () => {}, forceRemount: true, - storyContext: { ...context, viewMode: 'docs' }, + storyContext: { + ...context, + argTypes: {}, + viewMode: 'docs', + } as StoryContextForRender, storyFn: () => , unboundStoryFn: LayoutEffectContent, }, diff --git a/code/renderers/react/src/docs/jsxDecorator.test.tsx b/code/renderers/react/src/docs/jsxDecorator.test.tsx index f96bc9494363..6322b19ab968 100644 --- a/code/renderers/react/src/docs/jsxDecorator.test.tsx +++ b/code/renderers/react/src/docs/jsxDecorator.test.tsx @@ -8,7 +8,9 @@ import React, { Profiler, StrictMode, createElement } from 'react'; import PropTypes from 'prop-types'; import { addons, emitTransformCode, useState } from 'storybook/preview-api'; -import type { ReactRenderer, StoryContext } from '../types'; +import type { StoryContextForRender } from 'storybook/internal/types'; + +import type { ReactRenderer } from '../types'; import { getReactSymbolName, jsxDecorator, renderJsx } from './jsxDecorator'; vi.mock('storybook/preview-api', () => ({ @@ -338,8 +340,13 @@ describe('renderJsx', () => { }); }); -// @ts-expect-error (Converted from ts-ignore) -const makeContext = (name: string, parameters: any, args: any, extra?: object): StoryContext => ({ +const makeContext = ( + name: string, + parameters: any, + args: any, + extra?: object + // @ts-expect-error (Converted from ts-ignore) +): StoryContextForRender => ({ id: `jsx-test--${name}`, kind: 'js-text', name, @@ -351,7 +358,7 @@ const makeContext = (name: string, parameters: any, args: any, extra?: object): describe('jsxDecorator', () => { const channel = { emit: vi.fn() }; - let mockContext: StoryContext; + let mockContext: StoryContextForRender; let mockStoryFn: Mock; const mockSetSource = vi.fn(); diff --git a/code/renderers/react/src/docs/jsxDecorator.tsx b/code/renderers/react/src/docs/jsxDecorator.tsx index e97db5f0b71a..b00932a2e1a5 100644 --- a/code/renderers/react/src/docs/jsxDecorator.tsx +++ b/code/renderers/react/src/docs/jsxDecorator.tsx @@ -3,7 +3,7 @@ import React, { createElement, isValidElement } from 'react'; import { logger } from 'storybook/internal/client-logger'; import { SourceType, getDocgenSection } from 'storybook/internal/docs-tools'; -import type { PartialStoryFn, StoryContext } from 'storybook/internal/types'; +import type { PartialStoryFn, StoryContext, StoryContextForRender } from 'storybook/internal/types'; import type { Options } from 'react-element-to-jsx-string'; import type reactElementToJSXStringType from 'react-element-to-jsx-string'; @@ -250,7 +250,7 @@ const mdxToJsx = (node: any) => { export const jsxDecorator = ( storyFn: PartialStoryFn, - context: StoryContext + context: StoryContextForRender ) => { const jsx = useRef(undefined); const story = storyFn(); diff --git a/code/renderers/react/src/extractArgTypes.test.ts b/code/renderers/react/src/extractArgTypes.test.ts index 87ef3b01f9e2..53ce4108bd08 100644 --- a/code/renderers/react/src/extractArgTypes.test.ts +++ b/code/renderers/react/src/extractArgTypes.test.ts @@ -12,7 +12,7 @@ import { inferControls } from 'storybook/preview-api'; import { extractArgTypes } from './extractArgTypes.ts'; import { extractProps } from './extractProps.ts'; -import type { StoryContext } from './types.ts'; +import type { StoryContextForEnhancers } from 'storybook/internal/types'; // File hierarchy: // __testfixtures__ / some-test-case / input.* @@ -94,7 +94,7 @@ describe('react component properties', async () => { const rows = inferControls({ argTypes, parameters, - } as unknown as StoryContext); + } as unknown as StoryContextForEnhancers); await expect(rows).toMatchFileSnapshot(join(testDir, 'argTypes.snapshot')); }); } diff --git a/code/renderers/svelte/src/decorators.ts b/code/renderers/svelte/src/decorators.ts index caff4ebbaa42..661dcb10e8cb 100644 --- a/code/renderers/svelte/src/decorators.ts +++ b/code/renderers/svelte/src/decorators.ts @@ -1,4 +1,8 @@ -import type { DecoratorFunction, LegacyStoryFn, StoryContext } from 'storybook/internal/types'; +import type { + DecoratorFunction, + LegacyStoryFn, + StoryContextForRender, +} from 'storybook/internal/types'; /* ! DO NOT change this DecoratorHandler import to a relative path, it will break it. @@ -38,7 +42,7 @@ function unWrap(obj: { default: T } | T): T { * @param innerStory The story decorated by the current story */ function prepareStory( - context: StoryContext, + context: StoryContextForRender, rawStory: SvelteRenderer['storyResult'], rawInnerStory?: SvelteRenderer['storyResult'] ) { @@ -82,7 +86,7 @@ function prepareStory( export function decorateStory(storyFn: any, decorators: any[]) { return decorators.reduce( (decorated: LegacyStoryFn, decorator: DecoratorFunction) => - (context: StoryContext) => { + (context: StoryContextForRender) => { let story: SvelteRenderer['storyResult'] | undefined; const decoratedStory: SvelteRenderer['storyResult'] = decorator((update) => { @@ -103,6 +107,6 @@ export function decorateStory(storyFn: any, decorators: any[]) { return prepareStory(context, decoratedStory, story); }, - (context: StoryContext) => prepareStory(context, storyFn(context)) + (context: StoryContextForRender) => prepareStory(context, storyFn(context)) ); } diff --git a/code/renderers/vue3/src/decorateStory.ts b/code/renderers/vue3/src/decorateStory.ts index f3c0f8974f2f..43c88125dbde 100644 --- a/code/renderers/vue3/src/decorateStory.ts +++ b/code/renderers/vue3/src/decorateStory.ts @@ -1,4 +1,8 @@ -import type { DecoratorFunction, LegacyStoryFn, StoryContext } from 'storybook/internal/types'; +import type { + DecoratorFunction, + LegacyStoryFn, + StoryContextForRender, +} from 'storybook/internal/types'; import { sanitizeStoryContextUpdate } from 'storybook/preview-api'; import type { Component, ComponentOptions, ConcreteComponent } from 'vue'; @@ -49,32 +53,33 @@ export function decorateStory( decorators: DecoratorFunction[] ): LegacyStoryFn { return decorators.reduce( - (decorated: LegacyStoryFn, decorator) => (context: StoryContext) => { - let story: VueRenderer['storyResult'] | undefined; + (decorated: LegacyStoryFn, decorator) => + (context: StoryContextForRender) => { + let story: VueRenderer['storyResult'] | undefined; - const decoratedStory: VueRenderer['storyResult'] = decorator((update) => { - const sanitizedUpdate = sanitizeStoryContextUpdate(update); - // update the args in a reactive way + const decoratedStory: VueRenderer['storyResult'] = decorator((update) => { + const sanitizedUpdate = sanitizeStoryContextUpdate(update); + // update the args in a reactive way - // update the args in a reactive way - if (update) { - sanitizedUpdate.args = Object.assign(context.args, sanitizedUpdate.args); - } - story = decorated({ ...context, ...sanitizedUpdate }); - return story; - }, context); + // update the args in a reactive way + if (update) { + sanitizedUpdate.args = Object.assign(context.args, sanitizedUpdate.args); + } + story = decorated({ ...context, ...sanitizedUpdate }); + return story; + }, context); - if (!story) { - story = decorated(context); - } + if (!story) { + story = decorated(context); + } - if (decoratedStory === story) { - return story; - } + if (decoratedStory === story) { + return story; + } - const innerStory = () => h(story!); - return prepare(decoratedStory, innerStory) as VueRenderer['storyResult']; - }, + const innerStory = () => h(story!); + return prepare(decoratedStory, innerStory) as VueRenderer['storyResult']; + }, (context) => prepare(storyFn(context)) as LegacyStoryFn ); } diff --git a/code/renderers/vue3/src/render.ts b/code/renderers/vue3/src/render.ts index 99dd560c5848..ee4cd7d63b5e 100644 --- a/code/renderers/vue3/src/render.ts +++ b/code/renderers/vue3/src/render.ts @@ -5,6 +5,7 @@ import { type ArgsStoryFn, type RenderContext, type StoryContext, + type StoryContextForRender, } from 'storybook/internal/types'; import type { PreviewWeb } from 'storybook/preview-api'; @@ -121,7 +122,7 @@ export async function renderToCanvas( } /** Generate slots for default story without render function template */ -function getSlots(props: Args, context: StoryContext) { +function getSlots(props: Args, context: StoryContextForRender) { const { argTypes } = context; const slots = Object.entries(props) .filter(([key]) => argTypes[key]?.table?.category === 'slots') diff --git a/code/renderers/web-components/src/docs/sourceDecorator.ts b/code/renderers/web-components/src/docs/sourceDecorator.ts index 88df3aeb6308..0094b75e25bd 100644 --- a/code/renderers/web-components/src/docs/sourceDecorator.ts +++ b/code/renderers/web-components/src/docs/sourceDecorator.ts @@ -1,5 +1,10 @@ import { SourceType } from 'storybook/internal/docs-tools'; -import type { ArgsStoryFn, PartialStoryFn, StoryContext } from 'storybook/internal/types'; +import type { + ArgsStoryFn, + PartialStoryFn, + StoryContext, + StoryContextForRender, +} from 'storybook/internal/types'; import { render } from 'lit'; import { emitTransformCode, useEffect } from 'storybook/preview-api'; @@ -35,7 +40,7 @@ export function renderStorySource(storyResult: WebComponentsRenderer['storyResul export function sourceDecorator( storyFn: PartialStoryFn, - context: StoryContext + context: StoryContextForRender ): WebComponentsRenderer['storyResult'] { const story = storyFn(); const renderedForSource = context?.parameters.docs?.source?.excludeDecorators diff --git a/docs/writing-stories/decorators.mdx b/docs/writing-stories/decorators.mdx index 905cb9b427a0..a0ca0972a6e0 100644 --- a/docs/writing-stories/decorators.mdx +++ b/docs/writing-stories/decorators.mdx @@ -47,7 +47,7 @@ Some components require a “harness” to render in a useful way. For instance, The second argument to a decorator function is the **story context** which contains the properties: - `args` - the story arguments. You can use some [`args`](./args.mdx) in your decorators and drop them in the story implementation itself. -- `argTypes`- Storybook's [argTypes](../api/arg-types.mdx) allow you to customize and fine-tune your stories [`args`](./args.mdx). +- `argTypes`- the [argTypes](../api/arg-types.mdx) declared in the story file. This property is only available in decorators and `render` functions, not in loaders, `beforeEach`, `play` or `afterEach`. - `globals` - Storybook-wide [globals](../essentials/toolbars-and-globals.mdx#globals). In particular you can use the [toolbars feature](../essentials/toolbars-and-globals.mdx#global-types-and-the-toolbar-annotation) to allow you to change these values using Storybook’s UI. - `hooks` - Storybook's API hooks (e.g., `useArgs`, `useGlobals`). These are available in both decorators and story render functions. When using these hooks in a render function alongside framework hooks (e.g., React's `useState`, `useEffect`), use Storybook's hook equivalents from `storybook/preview-api` instead to avoid errors on re-render. - `parameters`- the story's static metadata, most commonly used to control Storybook's behavior of features and addons. From 3a059a7da4e5b4315059218e4801dbcdb28e3f3b Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Tue, 29 Sep 2026 11:08:14 +0200 Subject: [PATCH 2/5] Core: Type the decorator that calls originalStoryFn as StoryContextForRender The Angular webpack sandbox typechecks template stories, and passing a StoryContext into ArgsStoryFn fails now that its context requires argTypes. --- code/core/template/stories/decorators.stories.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/code/core/template/stories/decorators.stories.ts b/code/core/template/stories/decorators.stories.ts index 4581d1c2ed1b..ea220afdf2c8 100644 --- a/code/core/template/stories/decorators.stories.ts +++ b/code/core/template/stories/decorators.stories.ts @@ -8,6 +8,7 @@ import type { PartialStoryFn, PlayFunctionContext, StoryContext, + StoryContextForRender, } from 'storybook/internal/types'; import { global as globalThis } from '@storybook/global'; @@ -48,7 +49,7 @@ export const Hooks = { return storyFn({ args: { ...context.args, text: `story ${context.args.text}` } }); }, // conditional decorator, runs before the above - (storyFn: PartialStoryFn, context: StoryContext) => + (storyFn: PartialStoryFn, context: StoryContextForRender) => context.args.condition ? storyFn() : (context.originalStoryFn as ArgsStoryFn)(context.args, context), From 3644f7a2e109dfd45ea073d93af9db1ea434dd90 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Tue, 29 Sep 2026 14:26:10 +0200 Subject: [PATCH 3/5] Core: Derive StoryContext from StoryContextForEnhancers again --- code/core/src/csf/story.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/code/core/src/csf/story.ts b/code/core/src/csf/story.ts index 6d820b2da5d7..5b43caa2600c 100644 --- a/code/core/src/csf/story.ts +++ b/code/core/src/csf/story.ts @@ -270,11 +270,9 @@ export type AfterEach = ( export interface Canvas extends BoundFunctions {} export interface StoryContext - extends StoryIdentifier, Required> { - component?: (TRenderer & { T: any })['component']; - subcomponents?: Record; - parameters: Parameters; - initialArgs: TArgs; + extends + Omit, 'argTypes'>, + Required> { loaded: Record; abortSignal: AbortSignal; canvasElement: TRenderer['canvasElement']; From 2d362e8cd35656a7de064fb0990ce44c5a4ab410 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Wed, 30 Sep 2026 13:24:42 +0200 Subject: [PATCH 4/5] Docs: Drop the claim that server-side docgen is the Storybook 11 default --- MIGRATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index a932312f9f1d..9c47a3a93ecd 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -658,7 +658,7 @@ No automigration is needed. Existing boolean settings retain their meaning, and The story context passed to loaders, `beforeEach`, `play`, `afterEach` and `step` callbacks no longer contains `argTypes`. Reading it throws an error that links here. -With server-side docgen (`features.experimentalDocgenServer`, which becomes the default in Storybook 11), the preview no longer infers arg types from components or args. `context.argTypes` in these hooks only ever contained the arg types you declared by hand, so it looked complete but was not. +With server-side docgen (`features.experimentalDocgenServer`), the preview no longer infers arg types from components or args. `context.argTypes` in these hooks only ever contained the arg types you declared by hand, so it looked complete but was not. ```ts // Before From e341d27dda1b458e2da20345ba1477fb874c85dd Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Wed, 30 Sep 2026 14:31:38 +0200 Subject: [PATCH 5/5] Core: Name the story in the argTypes removal error --- .../src/preview-api/modules/store/csf/hideArgTypes.test.ts | 1 + code/core/src/preview-api/modules/store/csf/hideArgTypes.ts | 2 +- code/core/src/preview-errors.ts | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/code/core/src/preview-api/modules/store/csf/hideArgTypes.test.ts b/code/core/src/preview-api/modules/store/csf/hideArgTypes.test.ts index 3d4d48f96086..d85ace3fea13 100644 --- a/code/core/src/preview-api/modules/store/csf/hideArgTypes.test.ts +++ b/code/core/src/preview-api/modules/store/csf/hideArgTypes.test.ts @@ -21,6 +21,7 @@ describe('hideArgTypes', () => { const hidden = hideArgTypes(createContext()); expect(() => hidden.argTypes).toThrow(ArgTypesRemovedFromStoryContextError); + expect(() => hidden.argTypes).toThrow(/Story: component--a/); }); it('omits argTypes from enumeration and membership checks', () => { diff --git a/code/core/src/preview-api/modules/store/csf/hideArgTypes.ts b/code/core/src/preview-api/modules/store/csf/hideArgTypes.ts index 69a42c8cb969..a4cf7f477e17 100644 --- a/code/core/src/preview-api/modules/store/csf/hideArgTypes.ts +++ b/code/core/src/preview-api/modules/store/csf/hideArgTypes.ts @@ -9,7 +9,7 @@ export function hideArgTypes( return new Proxy(context, { get(target, key, receiver) { if (key === HIDDEN_KEY) { - throw new ArgTypesRemovedFromStoryContextError(); + throw new ArgTypesRemovedFromStoryContextError({ storyId: target.id }); } if (key === 'context') { return receiver; diff --git a/code/core/src/preview-errors.ts b/code/core/src/preview-errors.ts index a03494c219c7..2809d633c016 100644 --- a/code/core/src/preview-errors.ts +++ b/code/core/src/preview-errors.ts @@ -313,7 +313,7 @@ export class StatusTypeIdMismatchError extends StorybookError { } export class ArgTypesRemovedFromStoryContextError extends StorybookError { - constructor() { + constructor(public data: { storyId: string }) { super({ name: 'ArgTypesRemovedFromStoryContextError', category: Category.PREVIEW_API, @@ -323,6 +323,8 @@ export class ArgTypesRemovedFromStoryContextError extends StorybookError { message: dedent` \`argTypes\` is no longer part of the story context passed to loaders, beforeEach, play and afterEach. + Story: ${data.storyId} + With server-side docgen the preview only holds the arg types you declared yourself, so this object was incomplete while looking complete. - Read \`args\` for the values passed to the story. - Inspect the resolved arg types in the Controls panel or the ArgTypes doc block.`,