From 0739dc779aa8d52cb2c765b6cae325c202b1e1fc Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 28 May 2026 15:28:53 +0200 Subject: [PATCH 01/11] core: add DocgenService (phase 1 with mocked data) First concrete open-service consumer: a per-component docgen service backed by an `experimental_docgen` middleware-style preset. Renderers and addons can register extractors that wrap the previous accumulated extractor and merge results. Phase 1 ships the service definition, registration, static-snapshot wiring under `docgen/.json`, and a mock extractor in the React renderer. Phase 2 will teach RCM per-component extraction; phase 3 replaces the mock with the real RCM-backed extractor. Co-Authored-By: Claude Opus 4.7 (1M context) --- code/core/src/common/index.ts | 1 + code/core/src/common/utils/component-id.ts | 13 ++ .../src/core-server/presets/common-preset.ts | 33 ++- code/core/src/manager/globals/exports.ts | 1 + code/core/src/server-errors.ts | 11 + .../services/docgen/definition.ts | 78 +++++++ .../services/docgen/server.test.ts | 210 ++++++++++++++++++ .../open-service/services/docgen/server.ts | 73 ++++++ .../open-service/services/docgen/types.ts | 37 +++ code/core/src/types/modules/core-common.ts | 20 ++ .../react/src/componentManifest/generator.ts | 5 +- code/renderers/react/src/docgen/preset.ts | 36 +++ code/renderers/react/src/preset.ts | 2 + 13 files changed, 517 insertions(+), 3 deletions(-) create mode 100644 code/core/src/common/utils/component-id.ts create mode 100644 code/core/src/shared/open-service/services/docgen/definition.ts create mode 100644 code/core/src/shared/open-service/services/docgen/server.test.ts create mode 100644 code/core/src/shared/open-service/services/docgen/server.ts create mode 100644 code/core/src/shared/open-service/services/docgen/types.ts create mode 100644 code/renderers/react/src/docgen/preset.ts diff --git a/code/core/src/common/index.ts b/code/core/src/common/index.ts index 45fed303b598..045a45b1b9c3 100644 --- a/code/core/src/common/index.ts +++ b/code/core/src/common/index.ts @@ -37,6 +37,7 @@ export * from './utils/validate-configuration-files.ts'; export * from './utils/satisfies.ts'; export * from './utils/formatter.ts'; export * from './utils/get-story-id.ts'; +export * from './utils/component-id.ts'; export * from './utils/posix.ts'; export * from './utils/sync-main-preview-addons.ts'; export * from './utils/setup-addon-in-config.ts'; diff --git a/code/core/src/common/utils/component-id.ts b/code/core/src/common/utils/component-id.ts new file mode 100644 index 000000000000..d35d8d5958e6 --- /dev/null +++ b/code/core/src/common/utils/component-id.ts @@ -0,0 +1,13 @@ +import type { IndexEntry } from 'storybook/internal/types'; + +/** + * Derives the componentId portion of a story index entry id. + * + * Storybook story ids have the shape `--`; the prefix before the first + * `--` is the stable component identifier shared by every story (and attached docs entry) that + * targets the same component. Centralising the split keeps the docgen service, manifest generator, + * and any future consumers on one definition. + */ +export function getComponentIdFromEntry(entry: Pick): string { + return entry.id.split('--')[0]; +} diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 1bab61adaac0..e9fe718badba 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -18,6 +18,7 @@ import { logger } from 'storybook/internal/node-logger'; import { telemetry } from 'storybook/internal/telemetry'; import type { CoreConfig, + DocgenExtractor, Indexer, Options, PresetProperty, @@ -25,6 +26,8 @@ import type { StorybookConfigRaw, } from 'storybook/internal/types'; +import { registerDocgenService } from '../../shared/open-service/services/docgen/server.ts'; + import { isAbsolute, join } from 'pathe'; import * as pathe from 'pathe'; import { dedent } from 'ts-dedent'; @@ -311,13 +314,41 @@ export const managerEntries = async (existing: any) => { }; globalThis.STORYBOOK_SERVICES_LOADED = globalThis.STORYBOOK_SERVICES_LOADED ?? false; -export const services = async () => { + +/** + * Seed extractor for the experimental_docgen middleware chain. + * + * Returns an empty payload so a chain with zero registered extractors still produces a defined + * result (rather than throwing). Real extractors registered through `experimental_docgen` wrap + * this and either replace or merge with its output. + */ +const identityDocgenExtractor: DocgenExtractor = async (input) => ({ + componentId: input.componentId, + name: '', + description: '', + props: [], +}); + +export const services = async (_value: void, options: Options): Promise => { if (globalThis.STORYBOOK_SERVICES_LOADED) { throw new Error( 'The "services" preset property was applied twice, but should only be applied once. Multiple code paths applying it will cause service registration to fail.' ); } globalThis.STORYBOOK_SERVICES_LOADED = true; + + const generator = + await options.presets.apply>('storyIndexGenerator'); + + const extractor = await options.presets.apply( + 'experimental_docgen', + identityDocgenExtractor + ); + + registerDocgenService({ + getIndex: () => generator.getIndex(), + extractor, + }); }; // Store the promise (not the result) to prevent race conditions. diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 7ba63d146412..1836eedcf4e2 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -677,6 +677,7 @@ export default { 'CoreWebpackCompiler', 'Feature', 'SupportedBuilder', + 'SupportedFramework', 'SupportedLanguage', 'SupportedRenderer', ], diff --git a/code/core/src/server-errors.ts b/code/core/src/server-errors.ts index c61d52717a15..91d7f7234160 100644 --- a/code/core/src/server-errors.ts +++ b/code/core/src/server-errors.ts @@ -241,6 +241,17 @@ export class OpenServiceLoadedDrainExceededError extends StorybookError { } } +export class OpenServiceDocgenMissingComponentError extends StorybookError { + constructor(public data: { componentId: string }) { + super({ + name: 'OpenServiceDocgenMissingComponentError', + category: Category.CORE_COMMON, + code: 12, + message: `No story or attached docs entry was found for componentId "${data.componentId}". The docgen service can only return docs for components that are present in the story index.`, + }); + } +} + export class WebpackMissingStatsError extends StorybookError { constructor() { super({ diff --git a/code/core/src/shared/open-service/services/docgen/definition.ts b/code/core/src/shared/open-service/services/docgen/definition.ts new file mode 100644 index 000000000000..e79884d264bd --- /dev/null +++ b/code/core/src/shared/open-service/services/docgen/definition.ts @@ -0,0 +1,78 @@ +import * as v from 'valibot'; + +import { defineService } from '../../service-definition.ts'; +import type { DocgenPayload } from './types.ts'; + +/** Caller-facing input to the `getDocgen` query and the `extractDocgen` command. */ +export const docgenInputSchema = v.object({ componentId: v.string() }); + +/** + * Phase-1 docgen payload schema. + * + * `props` is intentionally a permissive `array(unknown)` slot so the service can ship before its + * real shape is designed in phase 3 (RCM-backed extraction). + */ +export const docgenPayloadSchema = v.object({ + componentId: v.string(), + name: v.string(), + description: v.string(), + props: v.array(v.unknown()), +}); + +/** Output of `getDocgen` — undefined when the component has not been extracted yet. */ +export const docgenOutputSchema = v.optional(docgenPayloadSchema); + +const voidOutputSchema = v.void(); + +// Compile-time guard that the schema's inferred output matches the published DocgenPayload type. +// If a future schema change diverges from the public type the file will fail typecheck here, so +// the two definitions stay in lockstep without a runtime duplication. +type _DocgenPayloadShapeMatches = + DocgenPayload extends v.InferOutput + ? v.InferOutput extends DocgenPayload + ? true + : never + : never; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _assertDocgenPayloadShapeMatches: _DocgenPayloadShapeMatches = true; + +export type DocgenServiceState = { + /** Extracted docgen keyed by componentId. Populated by the `extractDocgen` command. */ + components: Record; +}; + +/** + * Definition for the `core/docgen` open service. + * + * The query is a thin synchronous read of `state.components[componentId]` — it returns undefined + * when nothing has been extracted yet rather than throwing, matching the open-service convention + * for sync reads. The real work — story index lookup, extractor invocation, error handling — + * lives in the `extractDocgen` command, whose body is supplied at registration time because it + * needs to close over the server-only story index and the composed `experimental_docgen` + * extractor chain. The query's `load` hook (also supplied at registration) just calls + * `extractDocgen`, so `getDocgen.loaded()` is the awaitable form and surfaces extraction errors. + */ +export const docgenServiceDef = defineService({ + id: 'core/docgen', + description: + 'Component documentation (name, description, props, JSDoc tags) keyed by componentId.', + initialState: { components: {} } as DocgenServiceState, + queries: { + getDocgen: { + description: 'Returns the docgen payload for one componentId, or undefined when not loaded.', + input: docgenInputSchema, + output: docgenOutputSchema, + handler: (input, ctx) => ctx.self.state.components[input.componentId], + }, + }, + commands: { + extractDocgen: { + description: + 'Resolves story entries for a componentId, runs the registered extractor chain, and writes the result into state.', + input: docgenInputSchema, + output: voidOutputSchema, + // Handler is supplied at registration time so it can close over the story index and the + // composed experimental_docgen extractor. + }, + }, +}); diff --git a/code/core/src/shared/open-service/services/docgen/server.test.ts b/code/core/src/shared/open-service/services/docgen/server.test.ts new file mode 100644 index 000000000000..7964f287bd35 --- /dev/null +++ b/code/core/src/shared/open-service/services/docgen/server.test.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { IndexEntry, StoryIndex } from '../../../../types/modules/indexer.ts'; +import { buildStaticFiles, clearRegistry } from '../../server.ts'; +import { registerDocgenService } from './server.ts'; +import type { DocgenExtractor } from './types.ts'; + +afterEach(() => { + clearRegistry(); +}); + +function makeStoryEntry(id: string, title = 'Comp'): IndexEntry { + return { + id, + name: id.split('--').slice(1).join('--') || 'Default', + title, + type: 'story', + subtype: 'story', + importPath: `./${title.toLowerCase()}.stories.tsx`, + }; +} + +function makeGetIndex(entries: IndexEntry[]) { + const index: StoryIndex = { + v: 5, + entries: Object.fromEntries(entries.map((entry) => [entry.id, entry])), + }; + return () => Promise.resolve(index); +} + +describe('docgen open service', () => { + describe('extractDocgen command', () => { + it('runs the extractor and populates state so getDocgen returns the payload', async () => { + const extractor = vi.fn(async (input) => ({ + componentId: input.componentId, + name: `Name for ${input.componentId}`, + description: `Description for ${input.componentId}`, + props: [], + })); + + const service = registerDocgenService({ + getIndex: makeGetIndex([ + makeStoryEntry('button--primary', 'Button'), + makeStoryEntry('button--secondary', 'Button'), + ]), + extractor, + }); + + await service.commands.extractDocgen({ componentId: 'button' }); + + expect(service.queries.getDocgen({ componentId: 'button' })).toEqual({ + componentId: 'button', + name: 'Name for button', + description: 'Description for button', + props: [], + }); + + // Extractor receives the pre-resolved entries for this componentId, not the whole index. + expect(extractor).toHaveBeenCalledTimes(1); + expect(extractor.mock.calls[0][0].componentId).toBe('button'); + expect(extractor.mock.calls[0][0].entries.map((entry) => entry.id)).toEqual([ + 'button--primary', + 'button--secondary', + ]); + }); + + it('throws when the componentId has no entries in the story index', async () => { + const service = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), + extractor: async (input) => ({ + componentId: input.componentId, + name: '', + description: '', + props: [], + }), + }); + + await expect(service.commands.extractDocgen({ componentId: 'unknown' })).rejects.toThrow( + /No story or attached docs entry was found for componentId "unknown"/ + ); + }); + + it('propagates extractor errors out of the command', async () => { + const service = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), + extractor: async () => { + throw new Error('extractor blew up'); + }, + }); + + await expect(service.commands.extractDocgen({ componentId: 'button' })).rejects.toThrow( + 'extractor blew up' + ); + }); + }); + + describe('getDocgen query', () => { + it('returns undefined synchronously when nothing has been extracted yet', async () => { + const service = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), + extractor: async (input) => ({ + componentId: input.componentId, + name: 'Button', + description: '', + props: [], + }), + }); + + expect(service.queries.getDocgen({ componentId: 'button' })).toBeUndefined(); + }); + + it('.loaded() drives the load body which calls extractDocgen', async () => { + const service = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), + extractor: async (input) => ({ + componentId: input.componentId, + name: 'Button', + description: 'from-loaded', + props: [], + }), + }); + + await expect(service.queries.getDocgen.loaded({ componentId: 'button' })).resolves.toEqual({ + componentId: 'button', + name: 'Button', + description: 'from-loaded', + props: [], + }); + }); + + it('.loaded() surfaces missing-component errors from the command', async () => { + const service = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), + extractor: async (input) => ({ + componentId: input.componentId, + name: '', + description: '', + props: [], + }), + }); + + await expect(service.queries.getDocgen.loaded({ componentId: 'unknown' })).rejects.toThrow( + /No story or attached docs entry was found for componentId "unknown"/ + ); + }); + }); + + describe('static build', () => { + it('writes one docgen JSON per unique componentId in the index', async () => { + registerDocgenService({ + getIndex: makeGetIndex([ + makeStoryEntry('button--primary', 'Button'), + makeStoryEntry('button--secondary', 'Button'), + makeStoryEntry('card--default', 'Card'), + ]), + extractor: async (input) => ({ + componentId: input.componentId, + name: `name-${input.componentId}`, + description: `desc-${input.componentId}`, + props: [], + }), + }); + + const store = await buildStaticFiles(); + + expect(Object.keys(store).sort()).toEqual(['docgen/button.json', 'docgen/card.json']); + expect(store['docgen/button.json']).toMatchObject({ + components: { + button: { + componentId: 'button', + name: 'name-button', + description: 'desc-button', + props: [], + }, + }, + }); + }); + }); + + describe('extractor middleware composition', () => { + it('lets a wrapping extractor delegate to nextDocgen and merge its output', async () => { + const inner: DocgenExtractor = async (input) => ({ + componentId: input.componentId, + name: 'inner-name', + description: '', + props: [], + }); + + const outer: DocgenExtractor = async (input) => { + const downstream = await inner(input); + return { + ...downstream, + description: 'outer-description', + }; + }; + + const service = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), + extractor: outer, + }); + + await expect(service.queries.getDocgen.loaded({ componentId: 'button' })).resolves.toEqual({ + componentId: 'button', + name: 'inner-name', + description: 'outer-description', + props: [], + }); + }); + }); +}); diff --git a/code/core/src/shared/open-service/services/docgen/server.ts b/code/core/src/shared/open-service/services/docgen/server.ts new file mode 100644 index 000000000000..56fbcc455eb1 --- /dev/null +++ b/code/core/src/shared/open-service/services/docgen/server.ts @@ -0,0 +1,73 @@ +import { getComponentIdFromEntry } from '../../../../common/utils/component-id.ts'; +import { OpenServiceDocgenMissingComponentError } from '../../../../server-errors.ts'; +import type { StoryIndex } from '../../../../types/modules/indexer.ts'; +import { registerService } from '../../service-registration.ts'; +import { docgenServiceDef } from './definition.ts'; +import type { DocgenExtractor } from './types.ts'; + +export type RegisterDocgenServiceOptions = { + /** + * Returns the current story index when the service needs it. Callers should bind this to a + * pre-resolved generator so each docgen call does not re-await generator initialization. + */ + getIndex: () => Promise; + /** + * Fully composed docgen extractor chain produced by `presets.apply('experimental_docgen', ...)`. + * Wraps each registered preset on top of the identity extractor seed. + */ + extractor: DocgenExtractor; +}; + +/** + * Registers the docgen open service against the process-global registry. + * + * The `extractDocgen` command does the work: it reads the story index, resolves entries for the + * requested componentId, delegates to the composed extractor chain, and writes the payload into + * state. The `getDocgen` query's load hook simply invokes that command. `static.inputs` + * enumerates every distinct componentId for the static-build snapshot pass. + */ +export function registerDocgenService(options: RegisterDocgenServiceOptions) { + return registerService(docgenServiceDef, { + queries: { + getDocgen: { + load: async (input, ctx) => { + await ctx.self.commands.extractDocgen(input); + }, + static: { + path: (input) => `docgen/${input.componentId}.json`, + inputs: async () => { + const index = await options.getIndex(); + const componentIds = new Set(); + for (const entry of Object.values(index.entries)) { + componentIds.add(getComponentIdFromEntry(entry)); + } + return Array.from(componentIds, (componentId) => ({ componentId })); + }, + }, + }, + }, + commands: { + extractDocgen: { + handler: async (input, ctx) => { + const index = await options.getIndex(); + const entries = Object.values(index.entries).filter( + (entry) => getComponentIdFromEntry(entry) === input.componentId + ); + + if (entries.length === 0) { + throw new OpenServiceDocgenMissingComponentError({ componentId: input.componentId }); + } + + const payload = await options.extractor({ + componentId: input.componentId, + entries, + }); + + ctx.self.setState((draft) => { + draft.components[input.componentId] = payload; + }); + }, + }, + }, + }); +} diff --git a/code/core/src/shared/open-service/services/docgen/types.ts b/code/core/src/shared/open-service/services/docgen/types.ts new file mode 100644 index 000000000000..5adc51d38fe0 --- /dev/null +++ b/code/core/src/shared/open-service/services/docgen/types.ts @@ -0,0 +1,37 @@ +import type { IndexEntry } from '../../../../types/modules/indexer.ts'; + +/** + * Caller-facing input to the docgen extractor middleware. + * + * `componentId` is the story-index componentId (the prefix before the first `--` in a story id). + * `entries` is the set of story / attached-docs entries that resolve to that componentId — the + * docgen service pre-resolves them from the story index so each extractor can act without + * re-reading the index. + */ +export interface DocgenExtractorInput { + componentId: string; + entries: IndexEntry[]; +} + +/** + * Phase-1 docgen payload returned by `core/docgen`'s `getDocgen` query. + * + * The schema is intentionally minimal so the first slice ships without committing to a final + * props/subcomponent shape. Phase 3 will extend this with real `props`, `subcomponents`, and + * `stories[]` fields backed by RCM output. + */ +export interface DocgenPayload { + componentId: string; + name: string; + description: string; + props: unknown[]; +} + +/** + * Middleware-style extractor function registered through the `experimental_docgen` preset. + * + * Each registrant returns a wrapper around the previous accumulated extractor (received as the + * preset's `config` argument). The wrapper may call its inner `nextDocgen` to merge with + * downstream extractors, and must produce a complete {@link DocgenPayload}. + */ +export type DocgenExtractor = (input: DocgenExtractorInput) => Promise; diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index ce9188e48e19..0c44c23b3f84 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -10,11 +10,18 @@ import type { Server as NetServer } from 'net'; import type { Options as TelejsonOptions } from 'telejson'; import type { PackageJson as PackageJsonFromTypeFest } from 'type-fest'; +import type { DocgenExtractor } from '../../shared/open-service/services/docgen/types.ts'; import type { SupportedBuilder } from './builders.ts'; import type { SupportedFramework } from './frameworks.ts'; import type { Indexer, StoriesEntry } from './indexer.ts'; import type { SupportedRenderer } from './renderers.ts'; +export type { + DocgenExtractor, + DocgenExtractorInput, + DocgenPayload, +} from '../../shared/open-service/services/docgen/types.ts'; + /** ⚠️ This file contains internal WIP types they MUST NOT be exported outside this package for now! */ export type BuilderName = 'webpack5' | '@storybook/builder-webpack5' | string; @@ -114,6 +121,11 @@ export interface Presets { args?: any ): Promise; apply(extension: 'services', config?: StorybookConfigRaw['services'], args?: any): Promise; + apply( + extension: 'experimental_docgen', + config: DocgenExtractor, + args?: any + ): Promise; /** The second and third parameter are not needed. And make type inference easier. */ apply(extension: T): Promise; @@ -437,6 +449,7 @@ export interface StorybookConfigRaw { core?: CoreConfig; experimental_manifests?: Manifests; experimental_enrichCsf?: CsfEnricher; + experimental_docgen?: DocgenExtractor; staticDirs?: (DirectoryMapping | string)[]; logLevel?: string; features?: { @@ -749,6 +762,13 @@ export interface StorybookConfig { /** Run open-service registration side effects for the server environment. */ services?: PresetValue; + + /** + * Middleware-style extractor for the experimental docgen service. Each registrant receives the + * previously accumulated extractor as its config argument and returns a wrapping extractor that + * may delegate to it via the input forwarding pattern. + */ + experimental_docgen?: PresetValue; } export type PresetValue = T | ((config: T, options: Options) => T | Promise); diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index cdf01c078949..d7683b29a41d 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -1,4 +1,5 @@ import { recast } from 'storybook/internal/babel'; +import { getComponentIdFromEntry } from 'storybook/internal/common'; import { Tag } from 'storybook/internal/core-server'; import { storyNameFromExport } from 'storybook/internal/csf'; import { extractDescription, loadCsf } from 'storybook/internal/csf-tools'; @@ -87,7 +88,7 @@ function selectComponentEntries(manifestEntries: IndexEntry[]) { isAttachedDocsEntry(entry) ) .forEach((entry) => { - const componentId = entry.id.split('--')[0]; + const componentId = getComponentIdFromEntry(entry); const existingEntry = entriesByComponentId.get(componentId); if (!existingEntry) { @@ -413,7 +414,7 @@ export const manifests: PresetPropertyFn< allComponents, subcomponents, }): ReactComponentManifest | undefined => { - const id = entry.id.split('--')[0]; + const id = getComponentIdFromEntry(entry); const title = entry.title.split('/').at(-1)!.replace(/\s+/g, ''); const packageName = getPackageInfo(component?.path, storyPath); diff --git a/code/renderers/react/src/docgen/preset.ts b/code/renderers/react/src/docgen/preset.ts new file mode 100644 index 000000000000..0ca23ba481d0 --- /dev/null +++ b/code/renderers/react/src/docgen/preset.ts @@ -0,0 +1,36 @@ +import type { DocgenExtractor, PresetPropertyFn } from 'storybook/internal/types'; + +// Seed used as a defensive default for `nextDocgen`. In practice core seeds the middleware chain +// with its own identity extractor, so `nextDocgen` is always supplied at runtime — this default +// just satisfies the optional-typed preset slot without a non-null assertion. +const passthroughDocgen: DocgenExtractor = async (input) => ({ + componentId: input.componentId, + name: '', + description: '', + props: [], +}); + +/** + * Phase-1 mock docgen extractor for the React renderer. + * + * Wraps the previously accumulated extractor (received as the preset `config`) and returns a new + * extractor that synthesizes a deterministic name + description from the componentId. The wrapper + * still calls `nextDocgen` so the middleware-merge code path is exercised end-to-end before phase + * 3 replaces this body with a real RCM-backed extractor. + */ +export const experimental_docgen: PresetPropertyFn<'experimental_docgen'> = async ( + nextDocgen = passthroughDocgen +) => { + const wrapped: DocgenExtractor = async (input) => { + const downstream = await nextDocgen(input); + const fallbackName = input.entries[0]?.title.split('/').at(-1) ?? input.componentId; + return { + componentId: input.componentId, + name: downstream.name || fallbackName, + description: downstream.description || `Mocked docgen for ${input.componentId}`, + props: downstream.props, + }; + }; + + return wrapped; +}; diff --git a/code/renderers/react/src/preset.ts b/code/renderers/react/src/preset.ts index 362edc5c1854..4eed25ef5921 100644 --- a/code/renderers/react/src/preset.ts +++ b/code/renderers/react/src/preset.ts @@ -30,6 +30,8 @@ export { manifests as experimental_manifests } from './componentManifest/generat export { enrichCsf as experimental_enrichCsf } from './enrichCsf.ts'; +export { experimental_docgen } from './docgen/preset.ts'; + export const previewAnnotations: PresetProperty<'previewAnnotations'> = async ( input = [], options From e389ca7ec677cc6f43d520061a720f51fe29fa4c Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 28 May 2026 16:02:49 +0200 Subject: [PATCH 02/11] =?UTF-8?q?core,addon-docs:=20rename=20docgen=20extr?= =?UTF-8?q?actor=20=E2=86=92=20provider,=20add=20addon-docs=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename DocgenExtractor → DocgenProvider (and matching field/variable names) to better reflect that registrants supply data rather than extract it from a single source. Adds a second docgen provider in addon-docs that enriches description and appends a synthetic prop. With the React mock provider also registered, this exercises the middleware-merge path end-to-end across two packages, validating that the chosen preset chaining pattern composes correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- code/addons/docs/src/docgen.ts | 34 ++++++++ code/addons/docs/src/preset.ts | 1 + .../src/core-server/presets/common-preset.ts | 16 ++-- .../services/docgen/server.test.ts | 86 ++++++++++++++----- .../open-service/services/docgen/server.ts | 14 +-- .../open-service/services/docgen/types.ts | 14 +-- code/core/src/types/modules/core-common.ts | 16 ++-- code/renderers/react/src/docgen/preset.ts | 18 ++-- 8 files changed, 139 insertions(+), 60 deletions(-) create mode 100644 code/addons/docs/src/docgen.ts diff --git a/code/addons/docs/src/docgen.ts b/code/addons/docs/src/docgen.ts new file mode 100644 index 000000000000..9df17522f5cb --- /dev/null +++ b/code/addons/docs/src/docgen.ts @@ -0,0 +1,34 @@ +import type { DocgenProvider, PresetPropertyFn } from 'storybook/internal/types'; + +// Defensive fallback: in practice core seeds the middleware chain with its own identity provider. +const identityDocgenProvider: DocgenProvider = async (input) => ({ + componentId: input.componentId, + name: '', + description: '', + props: [], +}); + +/** + * Addon-docs docgen provider. + * + * This is a phase-1 placeholder whose only job is to prove that multiple providers can stack and + * merge — it appends a marker to the description and adds a synthetic prop entry on every + * component. The renderer-side provider runs alongside it; their outputs combine through the + * middleware chain. + */ +export const experimental_docgen: PresetPropertyFn<'experimental_docgen'> = async ( + nextDocgen = identityDocgenProvider +) => { + const wrapped: DocgenProvider = async (input) => { + const downstream = await nextDocgen(input); + return { + ...downstream, + description: downstream.description + ? `${downstream.description} (docs enabled)` + : 'docs enabled', + props: [...downstream.props, { source: '@storybook/addon-docs', kind: 'docs-marker' }], + }; + }; + + return wrapped; +}; diff --git a/code/addons/docs/src/preset.ts b/code/addons/docs/src/preset.ts index 315489865720..d0e78ef37c52 100644 --- a/code/addons/docs/src/preset.ts +++ b/code/addons/docs/src/preset.ts @@ -225,3 +225,4 @@ const optimizeViteDeps = [ export { webpackX as webpack, docsX as docs, optimizeViteDeps }; export { manifests as experimental_manifests } from './manifest'; +export { experimental_docgen } from './docgen'; diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index e9fe718badba..b8b9b11a6b49 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -18,7 +18,7 @@ import { logger } from 'storybook/internal/node-logger'; import { telemetry } from 'storybook/internal/telemetry'; import type { CoreConfig, - DocgenExtractor, + DocgenProvider, Indexer, Options, PresetProperty, @@ -316,13 +316,13 @@ export const managerEntries = async (existing: any) => { globalThis.STORYBOOK_SERVICES_LOADED = globalThis.STORYBOOK_SERVICES_LOADED ?? false; /** - * Seed extractor for the experimental_docgen middleware chain. + * Seed provider for the experimental_docgen middleware chain. * - * Returns an empty payload so a chain with zero registered extractors still produces a defined - * result (rather than throwing). Real extractors registered through `experimental_docgen` wrap + * Returns an empty payload so a chain with zero registered providers still produces a defined + * result (rather than throwing). Real providers registered through `experimental_docgen` wrap * this and either replace or merge with its output. */ -const identityDocgenExtractor: DocgenExtractor = async (input) => ({ +const identityDocgenProvider: DocgenProvider = async (input) => ({ componentId: input.componentId, name: '', description: '', @@ -340,14 +340,14 @@ export const services = async (_value: void, options: Options): Promise => const generator = await options.presets.apply>('storyIndexGenerator'); - const extractor = await options.presets.apply( + const provider = await options.presets.apply( 'experimental_docgen', - identityDocgenExtractor + identityDocgenProvider ); registerDocgenService({ getIndex: () => generator.getIndex(), - extractor, + provider, }); }; diff --git a/code/core/src/shared/open-service/services/docgen/server.test.ts b/code/core/src/shared/open-service/services/docgen/server.test.ts index 7964f287bd35..10927594896a 100644 --- a/code/core/src/shared/open-service/services/docgen/server.test.ts +++ b/code/core/src/shared/open-service/services/docgen/server.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { IndexEntry, StoryIndex } from '../../../../types/modules/indexer.ts'; import { buildStaticFiles, clearRegistry } from '../../server.ts'; import { registerDocgenService } from './server.ts'; -import type { DocgenExtractor } from './types.ts'; +import type { DocgenProvider } from './types.ts'; afterEach(() => { clearRegistry(); @@ -30,8 +30,8 @@ function makeGetIndex(entries: IndexEntry[]) { describe('docgen open service', () => { describe('extractDocgen command', () => { - it('runs the extractor and populates state so getDocgen returns the payload', async () => { - const extractor = vi.fn(async (input) => ({ + it('runs the provider and populates state so getDocgen returns the payload', async () => { + const provider = vi.fn(async (input) => ({ componentId: input.componentId, name: `Name for ${input.componentId}`, description: `Description for ${input.componentId}`, @@ -43,7 +43,7 @@ describe('docgen open service', () => { makeStoryEntry('button--primary', 'Button'), makeStoryEntry('button--secondary', 'Button'), ]), - extractor, + provider, }); await service.commands.extractDocgen({ componentId: 'button' }); @@ -55,10 +55,10 @@ describe('docgen open service', () => { props: [], }); - // Extractor receives the pre-resolved entries for this componentId, not the whole index. - expect(extractor).toHaveBeenCalledTimes(1); - expect(extractor.mock.calls[0][0].componentId).toBe('button'); - expect(extractor.mock.calls[0][0].entries.map((entry) => entry.id)).toEqual([ + // Provider receives the pre-resolved entries for this componentId, not the whole index. + expect(provider).toHaveBeenCalledTimes(1); + expect(provider.mock.calls[0][0].componentId).toBe('button'); + expect(provider.mock.calls[0][0].entries.map((entry) => entry.id)).toEqual([ 'button--primary', 'button--secondary', ]); @@ -67,7 +67,7 @@ describe('docgen open service', () => { it('throws when the componentId has no entries in the story index', async () => { const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), - extractor: async (input) => ({ + provider: async (input) => ({ componentId: input.componentId, name: '', description: '', @@ -80,16 +80,16 @@ describe('docgen open service', () => { ); }); - it('propagates extractor errors out of the command', async () => { + it('propagates provider errors out of the command', async () => { const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), - extractor: async () => { - throw new Error('extractor blew up'); + provider: async () => { + throw new Error('provider blew up'); }, }); await expect(service.commands.extractDocgen({ componentId: 'button' })).rejects.toThrow( - 'extractor blew up' + 'provider blew up' ); }); }); @@ -98,7 +98,7 @@ describe('docgen open service', () => { it('returns undefined synchronously when nothing has been extracted yet', async () => { const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), - extractor: async (input) => ({ + provider: async (input) => ({ componentId: input.componentId, name: 'Button', description: '', @@ -112,7 +112,7 @@ describe('docgen open service', () => { it('.loaded() drives the load body which calls extractDocgen', async () => { const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), - extractor: async (input) => ({ + provider: async (input) => ({ componentId: input.componentId, name: 'Button', description: 'from-loaded', @@ -131,7 +131,7 @@ describe('docgen open service', () => { it('.loaded() surfaces missing-component errors from the command', async () => { const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), - extractor: async (input) => ({ + provider: async (input) => ({ componentId: input.componentId, name: '', description: '', @@ -153,7 +153,7 @@ describe('docgen open service', () => { makeStoryEntry('button--secondary', 'Button'), makeStoryEntry('card--default', 'Card'), ]), - extractor: async (input) => ({ + provider: async (input) => ({ componentId: input.componentId, name: `name-${input.componentId}`, description: `desc-${input.componentId}`, @@ -177,16 +177,16 @@ describe('docgen open service', () => { }); }); - describe('extractor middleware composition', () => { - it('lets a wrapping extractor delegate to nextDocgen and merge its output', async () => { - const inner: DocgenExtractor = async (input) => ({ + describe('provider middleware composition', () => { + it('lets a wrapping provider delegate to nextDocgen and merge its output', async () => { + const inner: DocgenProvider = async (input) => ({ componentId: input.componentId, name: 'inner-name', description: '', props: [], }); - const outer: DocgenExtractor = async (input) => { + const outer: DocgenProvider = async (input) => { const downstream = await inner(input); return { ...downstream, @@ -196,7 +196,7 @@ describe('docgen open service', () => { const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), - extractor: outer, + provider: outer, }); await expect(service.queries.getDocgen.loaded({ componentId: 'button' })).resolves.toEqual({ @@ -206,5 +206,47 @@ describe('docgen open service', () => { props: [], }); }); + + it('merges output from three stacked providers (identity → A → B)', async () => { + // Identity seed produced by core's services preset. + const identity: DocgenProvider = async (input) => ({ + componentId: input.componentId, + name: '', + description: '', + props: [], + }); + + // First provider: sets a name and adds a prop. + const providerA: DocgenProvider = async (input) => { + const downstream = await identity(input); + return { + ...downstream, + name: 'A-name', + props: [...downstream.props, { source: 'A' }], + }; + }; + + // Second provider: appends to description and stacks another prop. + const providerB: DocgenProvider = async (input) => { + const downstream = await providerA(input); + return { + ...downstream, + description: `${downstream.description || ''}B-description`, + props: [...downstream.props, { source: 'B' }], + }; + }; + + const service = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), + provider: providerB, + }); + + await expect(service.queries.getDocgen.loaded({ componentId: 'button' })).resolves.toEqual({ + componentId: 'button', + name: 'A-name', + description: 'B-description', + props: [{ source: 'A' }, { source: 'B' }], + }); + }); }); }); diff --git a/code/core/src/shared/open-service/services/docgen/server.ts b/code/core/src/shared/open-service/services/docgen/server.ts index 56fbcc455eb1..36de4a912f5f 100644 --- a/code/core/src/shared/open-service/services/docgen/server.ts +++ b/code/core/src/shared/open-service/services/docgen/server.ts @@ -3,7 +3,7 @@ import { OpenServiceDocgenMissingComponentError } from '../../../../server-error import type { StoryIndex } from '../../../../types/modules/indexer.ts'; import { registerService } from '../../service-registration.ts'; import { docgenServiceDef } from './definition.ts'; -import type { DocgenExtractor } from './types.ts'; +import type { DocgenProvider } from './types.ts'; export type RegisterDocgenServiceOptions = { /** @@ -12,17 +12,17 @@ export type RegisterDocgenServiceOptions = { */ getIndex: () => Promise; /** - * Fully composed docgen extractor chain produced by `presets.apply('experimental_docgen', ...)`. - * Wraps each registered preset on top of the identity extractor seed. + * Fully composed docgen provider chain produced by `presets.apply('experimental_docgen', ...)`. + * Wraps each registered preset on top of the identity provider seed. */ - extractor: DocgenExtractor; + provider: DocgenProvider; }; /** * Registers the docgen open service against the process-global registry. * * The `extractDocgen` command does the work: it reads the story index, resolves entries for the - * requested componentId, delegates to the composed extractor chain, and writes the payload into + * requested componentId, delegates to the composed provider chain, and writes the payload into * state. The `getDocgen` query's load hook simply invokes that command. `static.inputs` * enumerates every distinct componentId for the static-build snapshot pass. */ @@ -58,7 +58,9 @@ export function registerDocgenService(options: RegisterDocgenServiceOptions) { throw new OpenServiceDocgenMissingComponentError({ componentId: input.componentId }); } - const payload = await options.extractor({ + // Provider errors bubble out of the command unchanged; consumers see the underlying + // failure rather than a generic "missing". + const payload = await options.provider({ componentId: input.componentId, entries, }); diff --git a/code/core/src/shared/open-service/services/docgen/types.ts b/code/core/src/shared/open-service/services/docgen/types.ts index 5adc51d38fe0..f70d918d0b45 100644 --- a/code/core/src/shared/open-service/services/docgen/types.ts +++ b/code/core/src/shared/open-service/services/docgen/types.ts @@ -1,14 +1,14 @@ import type { IndexEntry } from '../../../../types/modules/indexer.ts'; /** - * Caller-facing input to the docgen extractor middleware. + * Caller-facing input to a docgen provider middleware. * * `componentId` is the story-index componentId (the prefix before the first `--` in a story id). * `entries` is the set of story / attached-docs entries that resolve to that componentId — the - * docgen service pre-resolves them from the story index so each extractor can act without + * docgen service pre-resolves them from the story index so each provider can act without * re-reading the index. */ -export interface DocgenExtractorInput { +export interface DocgenProviderInput { componentId: string; entries: IndexEntry[]; } @@ -28,10 +28,10 @@ export interface DocgenPayload { } /** - * Middleware-style extractor function registered through the `experimental_docgen` preset. + * Middleware-style provider function registered through the `experimental_docgen` preset. * - * Each registrant returns a wrapper around the previous accumulated extractor (received as the + * Each registrant returns a wrapper around the previous accumulated provider (received as the * preset's `config` argument). The wrapper may call its inner `nextDocgen` to merge with - * downstream extractors, and must produce a complete {@link DocgenPayload}. + * downstream providers, and must produce a complete {@link DocgenPayload}. */ -export type DocgenExtractor = (input: DocgenExtractorInput) => Promise; +export type DocgenProvider = (input: DocgenProviderInput) => Promise; diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 0c44c23b3f84..ad61823d932d 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -10,16 +10,16 @@ import type { Server as NetServer } from 'net'; import type { Options as TelejsonOptions } from 'telejson'; import type { PackageJson as PackageJsonFromTypeFest } from 'type-fest'; -import type { DocgenExtractor } from '../../shared/open-service/services/docgen/types.ts'; +import type { DocgenProvider } from '../../shared/open-service/services/docgen/types.ts'; import type { SupportedBuilder } from './builders.ts'; import type { SupportedFramework } from './frameworks.ts'; import type { Indexer, StoriesEntry } from './indexer.ts'; import type { SupportedRenderer } from './renderers.ts'; export type { - DocgenExtractor, - DocgenExtractorInput, DocgenPayload, + DocgenProvider, + DocgenProviderInput, } from '../../shared/open-service/services/docgen/types.ts'; /** ⚠️ This file contains internal WIP types they MUST NOT be exported outside this package for now! */ @@ -123,9 +123,9 @@ export interface Presets { apply(extension: 'services', config?: StorybookConfigRaw['services'], args?: any): Promise; apply( extension: 'experimental_docgen', - config: DocgenExtractor, + config: DocgenProvider, args?: any - ): Promise; + ): Promise; /** The second and third parameter are not needed. And make type inference easier. */ apply(extension: T): Promise; @@ -449,7 +449,7 @@ export interface StorybookConfigRaw { core?: CoreConfig; experimental_manifests?: Manifests; experimental_enrichCsf?: CsfEnricher; - experimental_docgen?: DocgenExtractor; + experimental_docgen?: DocgenProvider; staticDirs?: (DirectoryMapping | string)[]; logLevel?: string; features?: { @@ -764,8 +764,8 @@ export interface StorybookConfig { services?: PresetValue; /** - * Middleware-style extractor for the experimental docgen service. Each registrant receives the - * previously accumulated extractor as its config argument and returns a wrapping extractor that + * Middleware-style provider for the experimental docgen service. Each registrant receives the + * previously accumulated provider as its config argument and returns a wrapping provider that * may delegate to it via the input forwarding pattern. */ experimental_docgen?: PresetValue; diff --git a/code/renderers/react/src/docgen/preset.ts b/code/renderers/react/src/docgen/preset.ts index 0ca23ba481d0..1fffa93957c7 100644 --- a/code/renderers/react/src/docgen/preset.ts +++ b/code/renderers/react/src/docgen/preset.ts @@ -1,9 +1,9 @@ -import type { DocgenExtractor, PresetPropertyFn } from 'storybook/internal/types'; +import type { DocgenProvider, PresetPropertyFn } from 'storybook/internal/types'; // Seed used as a defensive default for `nextDocgen`. In practice core seeds the middleware chain -// with its own identity extractor, so `nextDocgen` is always supplied at runtime — this default +// with its own identity provider, so `nextDocgen` is always supplied at runtime — this default // just satisfies the optional-typed preset slot without a non-null assertion. -const passthroughDocgen: DocgenExtractor = async (input) => ({ +const identityDocgenProvider: DocgenProvider = async (input) => ({ componentId: input.componentId, name: '', description: '', @@ -11,17 +11,17 @@ const passthroughDocgen: DocgenExtractor = async (input) => ({ }); /** - * Phase-1 mock docgen extractor for the React renderer. + * Phase-1 mock docgen provider for the React renderer. * - * Wraps the previously accumulated extractor (received as the preset `config`) and returns a new - * extractor that synthesizes a deterministic name + description from the componentId. The wrapper + * Wraps the previously accumulated provider (received as the preset `config`) and returns a new + * provider that synthesizes a deterministic name + description from the componentId. The wrapper * still calls `nextDocgen` so the middleware-merge code path is exercised end-to-end before phase - * 3 replaces this body with a real RCM-backed extractor. + * 3 replaces this body with a real RCM-backed provider. */ export const experimental_docgen: PresetPropertyFn<'experimental_docgen'> = async ( - nextDocgen = passthroughDocgen + nextDocgen = identityDocgenProvider ) => { - const wrapped: DocgenExtractor = async (input) => { + const wrapped: DocgenProvider = async (input) => { const downstream = await nextDocgen(input); const fallbackName = input.entries[0]?.title.split('/').at(-1) ?? input.componentId; return { From 5288d7a900dcc918aa7da97a09b4dc21ab2fe096 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 28 May 2026 16:07:50 +0200 Subject: [PATCH 03/11] core,addon-docs,react: rename preset key to experimental_docgenProvider; drop per-provider identity fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preset slot, type re-exports, Presets.apply overload, and every preset file's named export now use `experimental_docgenProvider` — keeps the preset name consistent with the DocgenProvider type. Each provider file no longer carries its own identity-provider seed. Providers call `nextDocgen?.(input)` and treat the downstream payload as optional, falling back to literals where needed. Core still seeds the chain with an identity provider so the composed result is always a defined function, but individual provider files no longer need to know about that detail. Co-Authored-By: Claude Opus 4.7 (1M context) --- code/addons/docs/src/docgen.ts | 28 ++++++++--------- code/addons/docs/src/preset.ts | 2 +- .../src/core-server/presets/common-preset.ts | 8 ++--- .../services/docgen/definition.ts | 8 ++--- .../open-service/services/docgen/server.ts | 2 +- .../open-service/services/docgen/types.ts | 2 +- code/core/src/types/modules/core-common.ts | 6 ++-- code/renderers/react/src/docgen/preset.ts | 30 +++++++------------ code/renderers/react/src/preset.ts | 2 +- 9 files changed, 38 insertions(+), 50 deletions(-) diff --git a/code/addons/docs/src/docgen.ts b/code/addons/docs/src/docgen.ts index 9df17522f5cb..8565e9f651a9 100644 --- a/code/addons/docs/src/docgen.ts +++ b/code/addons/docs/src/docgen.ts @@ -1,32 +1,28 @@ import type { DocgenProvider, PresetPropertyFn } from 'storybook/internal/types'; -// Defensive fallback: in practice core seeds the middleware chain with its own identity provider. -const identityDocgenProvider: DocgenProvider = async (input) => ({ - componentId: input.componentId, - name: '', - description: '', - props: [], -}); - /** * Addon-docs docgen provider. * * This is a phase-1 placeholder whose only job is to prove that multiple providers can stack and * merge — it appends a marker to the description and adds a synthetic prop entry on every - * component. The renderer-side provider runs alongside it; their outputs combine through the - * middleware chain. + * component. Calls `nextDocgen?.(input)` so it works whether or not core's identity seed is + * present at the bottom of the chain. */ -export const experimental_docgen: PresetPropertyFn<'experimental_docgen'> = async ( - nextDocgen = identityDocgenProvider +export const experimental_docgenProvider: PresetPropertyFn<'experimental_docgenProvider'> = async ( + nextDocgen ) => { const wrapped: DocgenProvider = async (input) => { - const downstream = await nextDocgen(input); + const downstream = await nextDocgen?.(input); return { - ...downstream, - description: downstream.description + componentId: input.componentId, + name: downstream?.name ?? '', + description: downstream?.description ? `${downstream.description} (docs enabled)` : 'docs enabled', - props: [...downstream.props, { source: '@storybook/addon-docs', kind: 'docs-marker' }], + props: [ + ...(downstream?.props ?? []), + { source: '@storybook/addon-docs', kind: 'docs-marker' }, + ], }; }; diff --git a/code/addons/docs/src/preset.ts b/code/addons/docs/src/preset.ts index d0e78ef37c52..cee1c6d00b4a 100644 --- a/code/addons/docs/src/preset.ts +++ b/code/addons/docs/src/preset.ts @@ -225,4 +225,4 @@ const optimizeViteDeps = [ export { webpackX as webpack, docsX as docs, optimizeViteDeps }; export { manifests as experimental_manifests } from './manifest'; -export { experimental_docgen } from './docgen'; +export { experimental_docgenProvider } from './docgen'; diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index b8b9b11a6b49..f4f64427d12f 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -316,11 +316,11 @@ export const managerEntries = async (existing: any) => { globalThis.STORYBOOK_SERVICES_LOADED = globalThis.STORYBOOK_SERVICES_LOADED ?? false; /** - * Seed provider for the experimental_docgen middleware chain. + * Seed provider for the experimental_docgenProvider middleware chain. * * Returns an empty payload so a chain with zero registered providers still produces a defined - * result (rather than throwing). Real providers registered through `experimental_docgen` wrap - * this and either replace or merge with its output. + * result (rather than throwing). Real providers registered through `experimental_docgenProvider` + * wrap this and either replace or merge with its output. */ const identityDocgenProvider: DocgenProvider = async (input) => ({ componentId: input.componentId, @@ -341,7 +341,7 @@ export const services = async (_value: void, options: Options): Promise => await options.presets.apply>('storyIndexGenerator'); const provider = await options.presets.apply( - 'experimental_docgen', + 'experimental_docgenProvider', identityDocgenProvider ); diff --git a/code/core/src/shared/open-service/services/docgen/definition.ts b/code/core/src/shared/open-service/services/docgen/definition.ts index e79884d264bd..ddf1a152228b 100644 --- a/code/core/src/shared/open-service/services/docgen/definition.ts +++ b/code/core/src/shared/open-service/services/docgen/definition.ts @@ -48,9 +48,9 @@ export type DocgenServiceState = { * when nothing has been extracted yet rather than throwing, matching the open-service convention * for sync reads. The real work — story index lookup, extractor invocation, error handling — * lives in the `extractDocgen` command, whose body is supplied at registration time because it - * needs to close over the server-only story index and the composed `experimental_docgen` - * extractor chain. The query's `load` hook (also supplied at registration) just calls - * `extractDocgen`, so `getDocgen.loaded()` is the awaitable form and surfaces extraction errors. + * needs to close over the server-only story index and the composed `experimental_docgenProvider` + * chain. The query's `load` hook (also supplied at registration) just calls `extractDocgen`, so + * `getDocgen.loaded()` is the awaitable form and surfaces extraction errors. */ export const docgenServiceDef = defineService({ id: 'core/docgen', @@ -72,7 +72,7 @@ export const docgenServiceDef = defineService({ input: docgenInputSchema, output: voidOutputSchema, // Handler is supplied at registration time so it can close over the story index and the - // composed experimental_docgen extractor. + // composed experimental_docgenProvider chain. }, }, }); diff --git a/code/core/src/shared/open-service/services/docgen/server.ts b/code/core/src/shared/open-service/services/docgen/server.ts index 36de4a912f5f..8255fcbfd98f 100644 --- a/code/core/src/shared/open-service/services/docgen/server.ts +++ b/code/core/src/shared/open-service/services/docgen/server.ts @@ -12,7 +12,7 @@ export type RegisterDocgenServiceOptions = { */ getIndex: () => Promise; /** - * Fully composed docgen provider chain produced by `presets.apply('experimental_docgen', ...)`. + * Fully composed docgen provider chain produced by `presets.apply('experimental_docgenProvider', ...)`. * Wraps each registered preset on top of the identity provider seed. */ provider: DocgenProvider; diff --git a/code/core/src/shared/open-service/services/docgen/types.ts b/code/core/src/shared/open-service/services/docgen/types.ts index f70d918d0b45..8f2cca50f6f2 100644 --- a/code/core/src/shared/open-service/services/docgen/types.ts +++ b/code/core/src/shared/open-service/services/docgen/types.ts @@ -28,7 +28,7 @@ export interface DocgenPayload { } /** - * Middleware-style provider function registered through the `experimental_docgen` preset. + * Middleware-style provider function registered through the `experimental_docgenProvider` preset. * * Each registrant returns a wrapper around the previous accumulated provider (received as the * preset's `config` argument). The wrapper may call its inner `nextDocgen` to merge with diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index ad61823d932d..4805b3cb0e16 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -122,7 +122,7 @@ export interface Presets { ): Promise; apply(extension: 'services', config?: StorybookConfigRaw['services'], args?: any): Promise; apply( - extension: 'experimental_docgen', + extension: 'experimental_docgenProvider', config: DocgenProvider, args?: any ): Promise; @@ -449,7 +449,7 @@ export interface StorybookConfigRaw { core?: CoreConfig; experimental_manifests?: Manifests; experimental_enrichCsf?: CsfEnricher; - experimental_docgen?: DocgenProvider; + experimental_docgenProvider?: DocgenProvider; staticDirs?: (DirectoryMapping | string)[]; logLevel?: string; features?: { @@ -768,7 +768,7 @@ export interface StorybookConfig { * previously accumulated provider as its config argument and returns a wrapping provider that * may delegate to it via the input forwarding pattern. */ - experimental_docgen?: PresetValue; + experimental_docgenProvider?: PresetValue; } export type PresetValue = T | ((config: T, options: Options) => T | Promise); diff --git a/code/renderers/react/src/docgen/preset.ts b/code/renderers/react/src/docgen/preset.ts index 1fffa93957c7..00c7189fd977 100644 --- a/code/renderers/react/src/docgen/preset.ts +++ b/code/renderers/react/src/docgen/preset.ts @@ -1,34 +1,26 @@ import type { DocgenProvider, PresetPropertyFn } from 'storybook/internal/types'; -// Seed used as a defensive default for `nextDocgen`. In practice core seeds the middleware chain -// with its own identity provider, so `nextDocgen` is always supplied at runtime — this default -// just satisfies the optional-typed preset slot without a non-null assertion. -const identityDocgenProvider: DocgenProvider = async (input) => ({ - componentId: input.componentId, - name: '', - description: '', - props: [], -}); - /** * Phase-1 mock docgen provider for the React renderer. * * Wraps the previously accumulated provider (received as the preset `config`) and returns a new - * provider that synthesizes a deterministic name + description from the componentId. The wrapper - * still calls `nextDocgen` so the middleware-merge code path is exercised end-to-end before phase - * 3 replaces this body with a real RCM-backed provider. + * provider that synthesizes a deterministic name + description from the componentId. Calls + * `nextDocgen?.(input)` optionally — core's `services` preset normally seeds the chain with an + * identity provider, but the optional chain keeps this file independent of that detail. + * + * Phase 3 will replace this body with a real RCM-backed provider. */ -export const experimental_docgen: PresetPropertyFn<'experimental_docgen'> = async ( - nextDocgen = identityDocgenProvider +export const experimental_docgenProvider: PresetPropertyFn<'experimental_docgenProvider'> = async ( + nextDocgen ) => { const wrapped: DocgenProvider = async (input) => { - const downstream = await nextDocgen(input); + const downstream = await nextDocgen?.(input); const fallbackName = input.entries[0]?.title.split('/').at(-1) ?? input.componentId; return { componentId: input.componentId, - name: downstream.name || fallbackName, - description: downstream.description || `Mocked docgen for ${input.componentId}`, - props: downstream.props, + name: downstream?.name || fallbackName, + description: downstream?.description || `Mocked docgen for ${input.componentId}`, + props: downstream?.props ?? [], }; }; diff --git a/code/renderers/react/src/preset.ts b/code/renderers/react/src/preset.ts index 4eed25ef5921..a16bd2865a3c 100644 --- a/code/renderers/react/src/preset.ts +++ b/code/renderers/react/src/preset.ts @@ -30,7 +30,7 @@ export { manifests as experimental_manifests } from './componentManifest/generat export { enrichCsf as experimental_enrichCsf } from './enrichCsf.ts'; -export { experimental_docgen } from './docgen/preset.ts'; +export { experimental_docgenProvider } from './docgen/preset.ts'; export const previewAnnotations: PresetProperty<'previewAnnotations'> = async ( input = [], From b048376993fa383b5c4ba67dda6b3b9c88960cd8 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 28 May 2026 22:10:51 +0200 Subject: [PATCH 04/11] core: adapt docgen service to filePath + staticInputs split The open-service registration API recently moved from a single `static: { path, inputs }` block to a definition-time `filePath` plus a registration-time `staticInputs`. Snapshot paths now also auto-prefix by service id, so the docgen `filePath` only needs to produce `.json` (the runtime turns it into `core/docgen/.json`). Updates server.ts, the definition, and the matching static-build test to the new shape. Phase-1 tests all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../open-service/services/docgen/definition.ts | 1 + .../open-service/services/docgen/server.test.ts | 7 +++++-- .../open-service/services/docgen/server.ts | 17 +++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/code/core/src/shared/open-service/services/docgen/definition.ts b/code/core/src/shared/open-service/services/docgen/definition.ts index ddf1a152228b..d995e635d25f 100644 --- a/code/core/src/shared/open-service/services/docgen/definition.ts +++ b/code/core/src/shared/open-service/services/docgen/definition.ts @@ -63,6 +63,7 @@ export const docgenServiceDef = defineService({ input: docgenInputSchema, output: docgenOutputSchema, handler: (input, ctx) => ctx.self.state.components[input.componentId], + filePath: (input) => `${input.componentId}.json`, }, }, commands: { diff --git a/code/core/src/shared/open-service/services/docgen/server.test.ts b/code/core/src/shared/open-service/services/docgen/server.test.ts index 10927594896a..9e6a8afc31c3 100644 --- a/code/core/src/shared/open-service/services/docgen/server.test.ts +++ b/code/core/src/shared/open-service/services/docgen/server.test.ts @@ -163,8 +163,11 @@ describe('docgen open service', () => { const store = await buildStaticFiles(); - expect(Object.keys(store).sort()).toEqual(['docgen/button.json', 'docgen/card.json']); - expect(store['docgen/button.json']).toMatchObject({ + expect(Object.keys(store).sort()).toEqual([ + 'core/docgen/button.json', + 'core/docgen/card.json', + ]); + expect(store['core/docgen/button.json']).toMatchObject({ components: { button: { componentId: 'button', diff --git a/code/core/src/shared/open-service/services/docgen/server.ts b/code/core/src/shared/open-service/services/docgen/server.ts index 8255fcbfd98f..d1a8780dc487 100644 --- a/code/core/src/shared/open-service/services/docgen/server.ts +++ b/code/core/src/shared/open-service/services/docgen/server.ts @@ -33,16 +33,13 @@ export function registerDocgenService(options: RegisterDocgenServiceOptions) { load: async (input, ctx) => { await ctx.self.commands.extractDocgen(input); }, - static: { - path: (input) => `docgen/${input.componentId}.json`, - inputs: async () => { - const index = await options.getIndex(); - const componentIds = new Set(); - for (const entry of Object.values(index.entries)) { - componentIds.add(getComponentIdFromEntry(entry)); - } - return Array.from(componentIds, (componentId) => ({ componentId })); - }, + staticInputs: async () => { + const index = await options.getIndex(); + const componentIds = new Set(); + for (const entry of Object.values(index.entries)) { + componentIds.add(getComponentIdFromEntry(entry)); + } + return Array.from(componentIds, (componentId) => ({ componentId })); }, }, }, From 39f7a9b380a864246ce41f63267fd74eb654885b Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 29 May 2026 07:21:40 +0200 Subject: [PATCH 05/11] core-server: log "Building open services.." during static build Matches the existing "Loading presets" / "Building manager.." / "Building preview.." CLI breadcrumbs so users can see when the open-service snapshot step is running. Only emits the log (and only schedules the work) when at least one service is registered, so installations without any service consumers stay quiet. Co-Authored-By: Claude Opus 4.7 (1M context) --- code/core/src/core-server/build-static.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 640a30c9f870..e5a0817e7c7e 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -17,7 +17,10 @@ import { global } from '@storybook/global'; import { join, relative, resolve } from 'pathe'; import picocolors from 'picocolors'; -import { writeOpenServiceStaticFiles } from '../shared/open-service/server.ts'; +import { + getRegisteredServices, + writeOpenServiceStaticFiles, +} from '../shared/open-service/server.ts'; import { resolvePackageDir } from '../shared/utils/module.ts'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator.ts'; import { buildOrThrow } from './utils/build-or-throw.ts'; @@ -146,7 +149,11 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const coreServerPublicDir = join(resolvePackageDir('storybook'), 'assets/browser'); effects.push(cp(coreServerPublicDir, options.outputDir, { recursive: true, force: true })); - effects.push(writeOpenServiceStaticFiles(options.outputDir)); + + if (getRegisteredServices().length > 0) { + logger.info('Building open services..'); + effects.push(writeOpenServiceStaticFiles(options.outputDir)); + } let storyIndexGeneratorPromise: Promise = Promise.resolve(undefined); From a9ada24161e0a851215427f8c80ae8d4ca76b4bc Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 29 May 2026 10:25:12 +0200 Subject: [PATCH 06/11] core,react,addon-docs: simplify docgen provider shape; gate behind experimentalDocgenServer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to the phase-1 docgen surface: 1. DocgenProvider input is now { importPath } instead of { componentId, entries }. The service resolves an entry for the requested componentId and hands the raw importPath to the provider chain — providers that don't know how to handle a given path (e.g. the React mock provider seeing an .mdx attached docs file) bail to nextDocgen. 2. DocgenProvider can return undefined. The identity seed in core's services preset now returns undefined so a chain with no real providers signals "no docgen here" instead of producing an empty payload. The addon-docs enricher mirrors this — it returns undefined when nothing downstream produced a payload, rather than fabricating one. 3. The whole docgen-service registration is gated behind a new `experimentalDocgenServer` feature flag (sibling of experimentalReactComponentMeta). When the flag is off, no service is registered, no preset chain is built, and the static-build log step already correctly skips itself via the existing registered-services length check. Also picks up an upstream rename: query `filePath` → `staticPath`. Co-Authored-By: Claude Opus 4.7 (1M context) --- code/addons/docs/src/docgen.ts | 19 ++- .../src/core-server/presets/common-preset.ts | 37 +++--- .../services/docgen/definition.ts | 2 +- .../services/docgen/server.test.ts | 123 +++++++----------- .../open-service/services/docgen/server.ts | 30 +++-- .../open-service/services/docgen/types.ts | 18 ++- code/core/src/types/modules/core-common.ts | 12 ++ code/renderers/react/src/docgen/preset.ts | 21 +-- 8 files changed, 123 insertions(+), 139 deletions(-) diff --git a/code/addons/docs/src/docgen.ts b/code/addons/docs/src/docgen.ts index 8565e9f651a9..09937c1e9d8a 100644 --- a/code/addons/docs/src/docgen.ts +++ b/code/addons/docs/src/docgen.ts @@ -4,25 +4,24 @@ import type { DocgenProvider, PresetPropertyFn } from 'storybook/internal/types' * Addon-docs docgen provider. * * This is a phase-1 placeholder whose only job is to prove that multiple providers can stack and - * merge — it appends a marker to the description and adds a synthetic prop entry on every - * component. Calls `nextDocgen?.(input)` so it works whether or not core's identity seed is - * present at the bottom of the chain. + * merge — it appends a marker to the downstream description and adds a synthetic prop entry. It + * does NOT produce docgen on its own; if no downstream provider supplied a payload, it returns + * undefined so the chain falls through. */ export const experimental_docgenProvider: PresetPropertyFn<'experimental_docgenProvider'> = async ( nextDocgen ) => { const wrapped: DocgenProvider = async (input) => { const downstream = await nextDocgen?.(input); + if (!downstream) { + return undefined; + } return { - componentId: input.componentId, - name: downstream?.name ?? '', - description: downstream?.description + ...downstream, + description: downstream.description ? `${downstream.description} (docs enabled)` : 'docs enabled', - props: [ - ...(downstream?.props ?? []), - { source: '@storybook/addon-docs', kind: 'docs-marker' }, - ], + props: [...downstream.props, { source: '@storybook/addon-docs', kind: 'docs-marker' }], }; }; diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index f4f64427d12f..48e427a4f47c 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -318,16 +318,11 @@ globalThis.STORYBOOK_SERVICES_LOADED = globalThis.STORYBOOK_SERVICES_LOADED ?? f /** * Seed provider for the experimental_docgenProvider middleware chain. * - * Returns an empty payload so a chain with zero registered providers still produces a defined - * result (rather than throwing). Real providers registered through `experimental_docgenProvider` - * wrap this and either replace or merge with its output. + * Returns `undefined` so the bottom of the chain signals "no docgen here" — each upstream + * provider can either replace this with its own payload, return its own undefined, or call + * `nextDocgen` and merge with downstream output. */ -const identityDocgenProvider: DocgenProvider = async (input) => ({ - componentId: input.componentId, - name: '', - description: '', - props: [], -}); +const identityDocgenProvider: DocgenProvider = async () => undefined; export const services = async (_value: void, options: Options): Promise => { if (globalThis.STORYBOOK_SERVICES_LOADED) { @@ -337,18 +332,22 @@ export const services = async (_value: void, options: Options): Promise => } globalThis.STORYBOOK_SERVICES_LOADED = true; - const generator = - await options.presets.apply>('storyIndexGenerator'); + const features = await options.presets.apply('features'); - const provider = await options.presets.apply( - 'experimental_docgenProvider', - identityDocgenProvider - ); + if (features?.experimentalDocgenServer) { + const generator = + await options.presets.apply>('storyIndexGenerator'); - registerDocgenService({ - getIndex: () => generator.getIndex(), - provider, - }); + const provider = await options.presets.apply( + 'experimental_docgenProvider', + identityDocgenProvider + ); + + registerDocgenService({ + getIndex: () => generator.getIndex(), + provider, + }); + } }; // Store the promise (not the result) to prevent race conditions. diff --git a/code/core/src/shared/open-service/services/docgen/definition.ts b/code/core/src/shared/open-service/services/docgen/definition.ts index d995e635d25f..93eac6f5adb3 100644 --- a/code/core/src/shared/open-service/services/docgen/definition.ts +++ b/code/core/src/shared/open-service/services/docgen/definition.ts @@ -63,7 +63,7 @@ export const docgenServiceDef = defineService({ input: docgenInputSchema, output: docgenOutputSchema, handler: (input, ctx) => ctx.self.state.components[input.componentId], - filePath: (input) => `${input.componentId}.json`, + staticPath: (input) => `${input.componentId}.json`, }, }, commands: { diff --git a/code/core/src/shared/open-service/services/docgen/server.test.ts b/code/core/src/shared/open-service/services/docgen/server.test.ts index 9e6a8afc31c3..b75a12ea4041 100644 --- a/code/core/src/shared/open-service/services/docgen/server.test.ts +++ b/code/core/src/shared/open-service/services/docgen/server.test.ts @@ -30,11 +30,11 @@ function makeGetIndex(entries: IndexEntry[]) { describe('docgen open service', () => { describe('extractDocgen command', () => { - it('runs the provider and populates state so getDocgen returns the payload', async () => { - const provider = vi.fn(async (input) => ({ - componentId: input.componentId, - name: `Name for ${input.componentId}`, - description: `Description for ${input.componentId}`, + it('hands the entry importPath to the provider and stores its payload', async () => { + const provider = vi.fn(async () => ({ + componentId: 'button', + name: 'Button', + description: 'A button', props: [], })); @@ -50,29 +50,30 @@ describe('docgen open service', () => { expect(service.queries.getDocgen({ componentId: 'button' })).toEqual({ componentId: 'button', - name: 'Name for button', - description: 'Description for button', + name: 'Button', + description: 'A button', props: [], }); - // Provider receives the pre-resolved entries for this componentId, not the whole index. expect(provider).toHaveBeenCalledTimes(1); - expect(provider.mock.calls[0][0].componentId).toBe('button'); - expect(provider.mock.calls[0][0].entries.map((entry) => entry.id)).toEqual([ - 'button--primary', - 'button--secondary', - ]); + expect(provider.mock.calls[0][0]).toEqual({ importPath: './button.stories.tsx' }); }); - it('throws when the componentId has no entries in the story index', async () => { + it('leaves state untouched when the provider returns undefined', async () => { const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), - provider: async (input) => ({ - componentId: input.componentId, - name: '', - description: '', - props: [], - }), + provider: async () => undefined, + }); + + await service.commands.extractDocgen({ componentId: 'button' }); + + expect(service.queries.getDocgen({ componentId: 'button' })).toBeUndefined(); + }); + + it('throws when no entry exists for the componentId', async () => { + const service = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), + provider: async () => undefined, }); await expect(service.commands.extractDocgen({ componentId: 'unknown' })).rejects.toThrow( @@ -98,8 +99,8 @@ describe('docgen open service', () => { it('returns undefined synchronously when nothing has been extracted yet', async () => { const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), - provider: async (input) => ({ - componentId: input.componentId, + provider: async () => ({ + componentId: 'button', name: 'Button', description: '', props: [], @@ -112,8 +113,8 @@ describe('docgen open service', () => { it('.loaded() drives the load body which calls extractDocgen', async () => { const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), - provider: async (input) => ({ - componentId: input.componentId, + provider: async () => ({ + componentId: 'button', name: 'Button', description: 'from-loaded', props: [], @@ -131,12 +132,7 @@ describe('docgen open service', () => { it('.loaded() surfaces missing-component errors from the command', async () => { const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), - provider: async (input) => ({ - componentId: input.componentId, - name: '', - description: '', - props: [], - }), + provider: async () => undefined, }); await expect(service.queries.getDocgen.loaded({ componentId: 'unknown' })).rejects.toThrow( @@ -146,17 +142,17 @@ describe('docgen open service', () => { }); describe('static build', () => { - it('writes one docgen JSON per unique componentId in the index', async () => { + it('writes one docgen JSON per componentId whose provider produced a payload', async () => { registerDocgenService({ getIndex: makeGetIndex([ makeStoryEntry('button--primary', 'Button'), makeStoryEntry('button--secondary', 'Button'), makeStoryEntry('card--default', 'Card'), ]), - provider: async (input) => ({ - componentId: input.componentId, - name: `name-${input.componentId}`, - description: `desc-${input.componentId}`, + provider: async ({ importPath }) => ({ + componentId: importPath.includes('button') ? 'button' : 'card', + name: importPath.includes('button') ? 'Button' : 'Card', + description: `from ${importPath}`, props: [], }), }); @@ -171,8 +167,8 @@ describe('docgen open service', () => { components: { button: { componentId: 'button', - name: 'name-button', - description: 'desc-button', + name: 'Button', + description: 'from ./button.stories.tsx', props: [], }, }, @@ -182,8 +178,8 @@ describe('docgen open service', () => { describe('provider middleware composition', () => { it('lets a wrapping provider delegate to nextDocgen and merge its output', async () => { - const inner: DocgenProvider = async (input) => ({ - componentId: input.componentId, + const inner: DocgenProvider = async () => ({ + componentId: 'button', name: 'inner-name', description: '', props: [], @@ -191,10 +187,10 @@ describe('docgen open service', () => { const outer: DocgenProvider = async (input) => { const downstream = await inner(input); - return { - ...downstream, - description: 'outer-description', - }; + if (!downstream) { + return undefined; + } + return { ...downstream, description: 'outer-description' }; }; const service = registerDocgenService({ @@ -210,46 +206,17 @@ describe('docgen open service', () => { }); }); - it('merges output from three stacked providers (identity → A → B)', async () => { - // Identity seed produced by core's services preset. - const identity: DocgenProvider = async (input) => ({ - componentId: input.componentId, - name: '', - description: '', - props: [], - }); - - // First provider: sets a name and adds a prop. - const providerA: DocgenProvider = async (input) => { - const downstream = await identity(input); - return { - ...downstream, - name: 'A-name', - props: [...downstream.props, { source: 'A' }], - }; - }; - - // Second provider: appends to description and stacks another prop. - const providerB: DocgenProvider = async (input) => { - const downstream = await providerA(input); - return { - ...downstream, - description: `${downstream.description || ''}B-description`, - props: [...downstream.props, { source: 'B' }], - }; - }; + it('propagates undefined from the bottom of the chain when no provider has docgen', async () => { + const identity: DocgenProvider = async () => undefined; + const passthrough: DocgenProvider = async (input) => identity(input); const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), - provider: providerB, + provider: passthrough, }); - await expect(service.queries.getDocgen.loaded({ componentId: 'button' })).resolves.toEqual({ - componentId: 'button', - name: 'A-name', - description: 'B-description', - props: [{ source: 'A' }, { source: 'B' }], - }); + await service.commands.extractDocgen({ componentId: 'button' }); + expect(service.queries.getDocgen({ componentId: 'button' })).toBeUndefined(); }); }); }); diff --git a/code/core/src/shared/open-service/services/docgen/server.ts b/code/core/src/shared/open-service/services/docgen/server.ts index d1a8780dc487..849ff44dfc33 100644 --- a/code/core/src/shared/open-service/services/docgen/server.ts +++ b/code/core/src/shared/open-service/services/docgen/server.ts @@ -12,8 +12,9 @@ export type RegisterDocgenServiceOptions = { */ getIndex: () => Promise; /** - * Fully composed docgen provider chain produced by `presets.apply('experimental_docgenProvider', ...)`. - * Wraps each registered preset on top of the identity provider seed. + * Fully composed docgen provider chain produced by + * `presets.apply('experimental_docgenProvider', ...)`. May return `undefined` when no provider + * in the chain has docgen for the requested file. */ provider: DocgenProvider; }; @@ -21,10 +22,10 @@ export type RegisterDocgenServiceOptions = { /** * Registers the docgen open service against the process-global registry. * - * The `extractDocgen` command does the work: it reads the story index, resolves entries for the - * requested componentId, delegates to the composed provider chain, and writes the payload into - * state. The `getDocgen` query's load hook simply invokes that command. `static.inputs` - * enumerates every distinct componentId for the static-build snapshot pass. + * The `extractDocgen` command does the work: it reads the story index, picks an entry for the + * requested componentId, hands the entry's `importPath` to the provider chain, and stores the + * returned payload (if any) into state. The `getDocgen` query's load hook simply invokes that + * command. `static.inputs` enumerates every distinct componentId for the static-build pass. */ export function registerDocgenService(options: RegisterDocgenServiceOptions) { return registerService(docgenServiceDef, { @@ -47,20 +48,23 @@ export function registerDocgenService(options: RegisterDocgenServiceOptions) { extractDocgen: { handler: async (input, ctx) => { const index = await options.getIndex(); - const entries = Object.values(index.entries).filter( - (entry) => getComponentIdFromEntry(entry) === input.componentId + const entry = Object.values(index.entries).find( + (e) => getComponentIdFromEntry(e) === input.componentId ); - if (entries.length === 0) { + if (!entry) { throw new OpenServiceDocgenMissingComponentError({ componentId: input.componentId }); } // Provider errors bubble out of the command unchanged; consumers see the underlying // failure rather than a generic "missing". - const payload = await options.provider({ - componentId: input.componentId, - entries, - }); + const payload = await options.provider({ importPath: entry.importPath }); + + if (!payload) { + // No provider produced docgen for this file — leave state untouched so the query + // returns undefined. + return; + } ctx.self.setState((draft) => { draft.components[input.componentId] = payload; diff --git a/code/core/src/shared/open-service/services/docgen/types.ts b/code/core/src/shared/open-service/services/docgen/types.ts index 8f2cca50f6f2..3fa08f93d603 100644 --- a/code/core/src/shared/open-service/services/docgen/types.ts +++ b/code/core/src/shared/open-service/services/docgen/types.ts @@ -1,16 +1,13 @@ -import type { IndexEntry } from '../../../../types/modules/indexer.ts'; - /** * Caller-facing input to a docgen provider middleware. * - * `componentId` is the story-index componentId (the prefix before the first `--` in a story id). - * `entries` is the set of story / attached-docs entries that resolve to that componentId — the - * docgen service pre-resolves them from the story index so each provider can act without - * re-reading the index. + * `importPath` is the value taken directly from the matching {@link IndexEntry.importPath} — a + * relative path to a CSF story file (or an .mdx file for attached-docs entries). Providers that + * only know how to read CSF should bail (return `undefined` or forward to `nextDocgen`) when the + * path does not point at a story file they understand. */ export interface DocgenProviderInput { - componentId: string; - entries: IndexEntry[]; + importPath: string; } /** @@ -32,6 +29,7 @@ export interface DocgenPayload { * * Each registrant returns a wrapper around the previous accumulated provider (received as the * preset's `config` argument). The wrapper may call its inner `nextDocgen` to merge with - * downstream providers, and must produce a complete {@link DocgenPayload}. + * downstream providers, and either returns a complete {@link DocgenPayload} or `undefined` when + * no docgen is available for the given file. */ -export type DocgenProvider = (input: DocgenProviderInput) => Promise; +export type DocgenProvider = (input: DocgenProviderInput) => Promise; diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 4805b3cb0e16..b720fdc6c882 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -584,6 +584,18 @@ export interface StorybookConfigRaw { */ experimentalCodeExamples?: boolean; + /** + * Enable the experimental docgen open service. + * + * When true, Storybook registers the `core/docgen` service in the open-service registry and + * generates per-component docgen JSON snapshots during static builds. Renderer and addon + * providers contribute through the `experimental_docgenProvider` preset. + * + * @default false + * @experimental This feature is in early development and may change significantly in future releases. + */ + experimentalDocgenServer?: boolean; + /** * Enable change detection * TODO: Turn to true before 10.4 release diff --git a/code/renderers/react/src/docgen/preset.ts b/code/renderers/react/src/docgen/preset.ts index 00c7189fd977..eff84ab113df 100644 --- a/code/renderers/react/src/docgen/preset.ts +++ b/code/renderers/react/src/docgen/preset.ts @@ -3,10 +3,9 @@ import type { DocgenProvider, PresetPropertyFn } from 'storybook/internal/types' /** * Phase-1 mock docgen provider for the React renderer. * - * Wraps the previously accumulated provider (received as the preset `config`) and returns a new - * provider that synthesizes a deterministic name + description from the componentId. Calls - * `nextDocgen?.(input)` optionally — core's `services` preset normally seeds the chain with an - * identity provider, but the optional chain keeps this file independent of that detail. + * Wraps the previously accumulated provider (received as the preset `config`) and returns a + * new provider that synthesizes a deterministic name + description from the importPath. Bails to + * `nextDocgen` for paths that don't look like CSF story files (e.g. `.mdx` attached-docs). * * Phase 3 will replace this body with a real RCM-backed provider. */ @@ -14,12 +13,18 @@ export const experimental_docgenProvider: PresetPropertyFn<'experimental_docgenP nextDocgen ) => { const wrapped: DocgenProvider = async (input) => { + if (!/\.stories\.[cm]?[jt]sx?$/.test(input.importPath)) { + return nextDocgen?.(input); + } + const downstream = await nextDocgen?.(input); - const fallbackName = input.entries[0]?.title.split('/').at(-1) ?? input.componentId; + const componentId = input.importPath + .replace(/^.*\//, '') + .replace(/\.stories\.[cm]?[jt]sx?$/, ''); return { - componentId: input.componentId, - name: downstream?.name || fallbackName, - description: downstream?.description || `Mocked docgen for ${input.componentId}`, + componentId: downstream?.componentId ?? componentId, + name: downstream?.name || componentId, + description: downstream?.description || `Mocked docgen for ${input.importPath}`, props: downstream?.props ?? [], }; }; From 1dc6ba257595f282107fffa1d124ed3b8cc2e925 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 29 May 2026 10:33:33 +0200 Subject: [PATCH 07/11] core: extractDocgen returns the extracted payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command's output schema was `void`, so callers had to follow up with a separate `getDocgen` read to see what was extracted. The work was already in scope — return the payload (or undefined) directly so a single extractDocgen call gives consumers the data alongside the state write. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/docgen/definition.ts | 6 ++--- .../services/docgen/server.test.ts | 22 +++++++++---------- .../open-service/services/docgen/server.ts | 7 +++--- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/code/core/src/shared/open-service/services/docgen/definition.ts b/code/core/src/shared/open-service/services/docgen/definition.ts index 93eac6f5adb3..9f283ba2f65e 100644 --- a/code/core/src/shared/open-service/services/docgen/definition.ts +++ b/code/core/src/shared/open-service/services/docgen/definition.ts @@ -22,8 +22,6 @@ export const docgenPayloadSchema = v.object({ /** Output of `getDocgen` — undefined when the component has not been extracted yet. */ export const docgenOutputSchema = v.optional(docgenPayloadSchema); -const voidOutputSchema = v.void(); - // Compile-time guard that the schema's inferred output matches the published DocgenPayload type. // If a future schema change diverges from the public type the file will fail typecheck here, so // the two definitions stay in lockstep without a runtime duplication. @@ -69,9 +67,9 @@ export const docgenServiceDef = defineService({ commands: { extractDocgen: { description: - 'Resolves story entries for a componentId, runs the registered extractor chain, and writes the result into state.', + 'Resolves story entries for a componentId, runs the registered provider chain, writes the result into state, and returns it (or undefined when no provider produced docgen).', input: docgenInputSchema, - output: voidOutputSchema, + output: docgenOutputSchema, // Handler is supplied at registration time so it can close over the story index and the // composed experimental_docgenProvider chain. }, diff --git a/code/core/src/shared/open-service/services/docgen/server.test.ts b/code/core/src/shared/open-service/services/docgen/server.test.ts index b75a12ea4041..ed3e56eda075 100644 --- a/code/core/src/shared/open-service/services/docgen/server.test.ts +++ b/code/core/src/shared/open-service/services/docgen/server.test.ts @@ -30,13 +30,14 @@ function makeGetIndex(entries: IndexEntry[]) { describe('docgen open service', () => { describe('extractDocgen command', () => { - it('hands the entry importPath to the provider and stores its payload', async () => { - const provider = vi.fn(async () => ({ + it('hands the entry importPath to the provider, stores its payload, and returns it', async () => { + const payload = { componentId: 'button', name: 'Button', description: 'A button', props: [], - })); + }; + const provider = vi.fn(async () => payload); const service = registerDocgenService({ getIndex: makeGetIndex([ @@ -46,27 +47,24 @@ describe('docgen open service', () => { provider, }); - await service.commands.extractDocgen({ componentId: 'button' }); + const returned = await service.commands.extractDocgen({ componentId: 'button' }); - expect(service.queries.getDocgen({ componentId: 'button' })).toEqual({ - componentId: 'button', - name: 'Button', - description: 'A button', - props: [], - }); + expect(returned).toEqual(payload); + expect(service.queries.getDocgen({ componentId: 'button' })).toEqual(payload); expect(provider).toHaveBeenCalledTimes(1); expect(provider.mock.calls[0][0]).toEqual({ importPath: './button.stories.tsx' }); }); - it('leaves state untouched when the provider returns undefined', async () => { + it('returns undefined and leaves state untouched when the provider returns undefined', async () => { const service = registerDocgenService({ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), provider: async () => undefined, }); - await service.commands.extractDocgen({ componentId: 'button' }); + const returned = await service.commands.extractDocgen({ componentId: 'button' }); + expect(returned).toBeUndefined(); expect(service.queries.getDocgen({ componentId: 'button' })).toBeUndefined(); }); diff --git a/code/core/src/shared/open-service/services/docgen/server.ts b/code/core/src/shared/open-service/services/docgen/server.ts index 849ff44dfc33..6989b21c2df4 100644 --- a/code/core/src/shared/open-service/services/docgen/server.ts +++ b/code/core/src/shared/open-service/services/docgen/server.ts @@ -61,14 +61,15 @@ export function registerDocgenService(options: RegisterDocgenServiceOptions) { const payload = await options.provider({ importPath: entry.importPath }); if (!payload) { - // No provider produced docgen for this file — leave state untouched so the query - // returns undefined. - return; + // No provider produced docgen for this file — leave state untouched and signal + // "nothing here" to the caller. + return undefined; } ctx.self.setState((draft) => { draft.components[input.componentId] = payload; }); + return payload; }, }, }, From cb5e62f47d5847d6f1b52acca4cc667423b79ce6 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 29 May 2026 10:37:34 +0200 Subject: [PATCH 08/11] open-service: document the load-stays-thin, work-lives-in-commands pattern Captures a lesson from building the docgen service: when query.load and a command both look plausible for the same work, the work belongs in the command. The load body should be a one-line passthrough that calls it. Extends the Load concept section with the rationale (reusability, testability, clarity) and adds a matching bullet to the Design Rules. Co-Authored-By: Claude Opus 4.7 (1M context) --- code/core/src/shared/open-service/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/code/core/src/shared/open-service/README.md b/code/core/src/shared/open-service/README.md index 2043f6febb5d..c7fdf4fec00b 100644 --- a/code/core/src/shared/open-service/README.md +++ b/code/core/src/shared/open-service/README.md @@ -94,6 +94,14 @@ Query handlers do **not** receive `commands` or `setState`. Mutations belong in `load` mutations must go through commands. Cross-service `getService(...).queries.*` calls inside a load body are not auto-tracked for the drain; use `await ctx.getService(id).queries.foo.loaded(input)` when you need a cross-service dependency awaited before your own load completes. +**Keep `load` bodies as small as possible.** Almost always, `load` should be a one-liner that calls a command — the real work (input resolution, side effects, validation, state mutation) belongs in the command. This pays off for three reasons: + +- **Reusability.** Anyone can call the command directly (other services, tests, integrations) without going through the query's load path. Logic stuck inside a load is unreachable from outside the drain. +- **Testability.** Commands have a typed input/output contract you can assert against. Load bodies don't return anything useful. +- **Clear contract.** A query says "read state". A command says "do work that produces state". A bloated load blurs the line and makes the service harder to reason about. + +A good rule of thumb: if `load` does anything more than `await ctx.self.commands.someCommand(input)`, ask whether that "more" belongs in the command instead. + ### Command A command is: @@ -376,6 +384,7 @@ const ready = await exampleService.queries.getValue.loaded({ entryId: 'a' }); - Always declare both `input` and `output` schemas on every query and command. - Use `load` for read-side warming. The hook is async and must mutate via commands. +- **Keep `load` bodies minimal — ideally one line that calls a command.** Push input resolution, side effects, and state mutation into the command itself so it stays callable, testable, and reusable on its own. - Query handlers are strict readers: sync, no commands, no `setState`. - Use commands for all state mutation. - Keep environment-agnostic imports on [index.ts](./index.ts) and server-only imports on [server.ts](./server.ts). Import internal modules directly only from tests or implementation code in this directory. From e4f55601e2e5e6d24e1dcf01124298c4717998eb Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 29 May 2026 11:17:40 +0200 Subject: [PATCH 09/11] core,react,addon-docs: tighten docgen provider DX (review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review nits, addressed together because they overlap: H2 — Docgen static build wasn't gated by ignorePreview, so a manager-only build forced full story-index generation (staticInputs calls getIndex()). Mirror the !options.ignorePreview gate around index.json / writeManifests at the registration boundary: don't register the service at all when previewing is off. L1 — `nextDocgen?.(input)` defended an impossible state (core always seeds the chain). New `defineDocgenProvider` helper from `storybook/internal/common` types `next` as required and throws a clear error if the seed is ever missing, instead of silently degrading to empty payloads. M3 + M5 — The two phase-1 mocks merged downstream inconsistently (React rebuilt the payload field-by-field; addon-docs used spread). A future provider adding a `DocgenPayload` field would be silently dropped by the React mock. Document the spread + `??` convention on the DocgenProvider type's JSDoc and on `defineDocgenProvider`, and update the React mock to match. The convention preserves both unknown fields and explicit downstream values. Co-Authored-By: Claude Opus 4.7 (1M context) --- code/addons/docs/src/docgen.ts | 38 +++++------- code/core/src/common/index.ts | 4 ++ .../src/core-server/presets/common-preset.ts | 6 +- .../services/docgen/defineProvider.ts | 60 +++++++++++++++++++ .../open-service/services/docgen/types.ts | 15 +++-- code/renderers/react/src/docgen/preset.ts | 43 ++++++------- 6 files changed, 114 insertions(+), 52 deletions(-) create mode 100644 code/core/src/shared/open-service/services/docgen/defineProvider.ts diff --git a/code/addons/docs/src/docgen.ts b/code/addons/docs/src/docgen.ts index 09937c1e9d8a..e9f1adfd69be 100644 --- a/code/addons/docs/src/docgen.ts +++ b/code/addons/docs/src/docgen.ts @@ -1,29 +1,21 @@ -import type { DocgenProvider, PresetPropertyFn } from 'storybook/internal/types'; +import { defineDocgenProvider } from 'storybook/internal/common'; /** * Addon-docs docgen provider. * - * This is a phase-1 placeholder whose only job is to prove that multiple providers can stack and - * merge — it appends a marker to the downstream description and adds a synthetic prop entry. It - * does NOT produce docgen on its own; if no downstream provider supplied a payload, it returns - * undefined so the chain falls through. + * A small enrichment layer: appends a `(docs enabled)` marker to the downstream description so + * consumers can tell that addon-docs participated. Does NOT produce docgen on its own — when no + * downstream provider supplied a payload it returns undefined so the chain falls through. */ -export const experimental_docgenProvider: PresetPropertyFn<'experimental_docgenProvider'> = async ( - nextDocgen -) => { - const wrapped: DocgenProvider = async (input) => { - const downstream = await nextDocgen?.(input); - if (!downstream) { - return undefined; - } - return { - ...downstream, - description: downstream.description - ? `${downstream.description} (docs enabled)` - : 'docs enabled', - props: [...downstream.props, { source: '@storybook/addon-docs', kind: 'docs-marker' }], - }; +export const experimental_docgenProvider = defineDocgenProvider((next) => async (input) => { + const downstream = await next(input); + if (!downstream) { + return undefined; + } + return { + ...downstream, + description: downstream.description + ? `${downstream.description} (docs enabled)` + : 'docs enabled', }; - - return wrapped; -}; +}); diff --git a/code/core/src/common/index.ts b/code/core/src/common/index.ts index 045a45b1b9c3..4cf5a5642e07 100644 --- a/code/core/src/common/index.ts +++ b/code/core/src/common/index.ts @@ -38,6 +38,10 @@ export * from './utils/satisfies.ts'; export * from './utils/formatter.ts'; export * from './utils/get-story-id.ts'; export * from './utils/component-id.ts'; +export { + defineDocgenProvider, + type DocgenProviderMiddleware, +} from '../shared/open-service/services/docgen/defineProvider.ts'; export * from './utils/posix.ts'; export * from './utils/sync-main-preview-addons.ts'; export * from './utils/setup-addon-in-config.ts'; diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 48e427a4f47c..3d42633c781f 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -334,7 +334,11 @@ export const services = async (_value: void, options: Options): Promise => const features = await options.presets.apply('features'); - if (features?.experimentalDocgenServer) { + // Skip when previewing is off — the docgen service's staticInputs depends on the story index, + // so registering it would force full story-index generation during manager-only builds (and + // produce docgen files that wouldn't be served anywhere). Mirrors the !options.ignorePreview + // gate around index.json and writeManifests in build-static.ts. + if (features?.experimentalDocgenServer && !options.ignorePreview) { const generator = await options.presets.apply>('storyIndexGenerator'); diff --git a/code/core/src/shared/open-service/services/docgen/defineProvider.ts b/code/core/src/shared/open-service/services/docgen/defineProvider.ts new file mode 100644 index 000000000000..2d7823dea37e --- /dev/null +++ b/code/core/src/shared/open-service/services/docgen/defineProvider.ts @@ -0,0 +1,60 @@ +import type { Options, PresetPropertyFn } from '../../../../types/modules/core-common.ts'; +import type { DocgenProvider, DocgenProviderInput, DocgenPayload } from './types.ts'; + +/** + * Middleware factory for the `experimental_docgenProvider` preset. + * + * Receives the previously accumulated provider as `next` (always defined — core's services + * preset seeds the chain with an identity provider) and the resolved preset options, and returns + * a new {@link DocgenProvider}. + */ +export type DocgenProviderMiddleware = ( + next: DocgenProvider, + options: Options +) => DocgenProvider | Promise; + +/** + * Helper for authoring an `experimental_docgenProvider` preset. + * + * Wraps a middleware factory so it conforms to Storybook's preset surface, with two ergonomic + * upgrades over writing the raw `PresetPropertyFn` by hand: + * + * 1. **`next` is non-nullable.** Core's services preset always seeds the chain with an identity + * provider, so `next?.(input)` is impossible-state defense. This helper throws clearly if the + * seed is ever missing — signaling a preset misconfiguration instead of silently degrading + * payloads into empty strings. + * 2. **One documented merge idiom for providers.** When wrapping `next`, return + * `{ ...downstream, ...yourOverrides }` and reach for `downstream?.field ?? yours` (not `||`) + * when filling gaps. The spread guarantees that {@link DocgenPayload} fields added by future + * providers — or future schema growth — are not silently dropped by a provider that doesn't + * know about them. `??` preserves any explicit value downstream produced (including empty + * strings), so a downstream provider that intentionally set a field to "" is not overridden + * by your own defaults. + * + * @example + * export const experimental_docgenProvider = defineDocgenProvider((next) => async (input) => { + * const downstream = await next(input); + * if (!downstream) return undefined; // I'm an enricher, nothing to enrich + * return { + * ...downstream, + * description: `${downstream.description} (enriched)`, + * }; + * }); + */ +export function defineDocgenProvider( + middleware: DocgenProviderMiddleware +): PresetPropertyFn<'experimental_docgenProvider'> { + return async (next, options) => { + if (!next) { + throw new Error( + '`experimental_docgenProvider` was applied without a downstream provider. ' + + "The core 'services' preset seeds the chain with an identity provider — if this fires, " + + 'the docgen preset chain is misconfigured.' + ); + } + return middleware(next, options); + }; +} + +// Re-exporting these so provider authors only need one import path. +export type { DocgenPayload, DocgenProvider, DocgenProviderInput }; diff --git a/code/core/src/shared/open-service/services/docgen/types.ts b/code/core/src/shared/open-service/services/docgen/types.ts index 3fa08f93d603..5c793d30cfc4 100644 --- a/code/core/src/shared/open-service/services/docgen/types.ts +++ b/code/core/src/shared/open-service/services/docgen/types.ts @@ -27,9 +27,16 @@ export interface DocgenPayload { /** * Middleware-style provider function registered through the `experimental_docgenProvider` preset. * - * Each registrant returns a wrapper around the previous accumulated provider (received as the - * preset's `config` argument). The wrapper may call its inner `nextDocgen` to merge with - * downstream providers, and either returns a complete {@link DocgenPayload} or `undefined` when - * no docgen is available for the given file. + * Each registrant returns a wrapper around the previous accumulated provider; it may call that + * inner provider to merge with downstream output, and either returns a complete + * {@link DocgenPayload} or `undefined` when no docgen is available for the given file. + * + * **Merge convention.** When combining your output with downstream's, use spread + * (`{ ...downstream, ...yourOverrides }`) and `downstream?.field ?? yours` rather than rebuilding + * the payload field-by-field. Manual reconstruction silently drops any fields a future provider + * (or future schema change) adds and your provider doesn't know about. `??` preserves explicit + * values from downstream — including empty strings — so providers that intentionally set a field + * are not overridden by a later provider's defaults. Prefer authoring with `defineDocgenProvider` + * from `storybook/internal/common`, which encodes the contract. */ export type DocgenProvider = (input: DocgenProviderInput) => Promise; diff --git a/code/renderers/react/src/docgen/preset.ts b/code/renderers/react/src/docgen/preset.ts index eff84ab113df..a3b4b2b3ebf8 100644 --- a/code/renderers/react/src/docgen/preset.ts +++ b/code/renderers/react/src/docgen/preset.ts @@ -1,33 +1,28 @@ -import type { DocgenProvider, PresetPropertyFn } from 'storybook/internal/types'; +import { defineDocgenProvider } from 'storybook/internal/common'; /** * Phase-1 mock docgen provider for the React renderer. * - * Wraps the previously accumulated provider (received as the preset `config`) and returns a - * new provider that synthesizes a deterministic name + description from the importPath. Bails to - * `nextDocgen` for paths that don't look like CSF story files (e.g. `.mdx` attached-docs). + * Bails to `next` for paths that don't look like CSF story files (e.g. `.mdx` attached-docs). + * For CSF paths it synthesizes a deterministic name + description from the importPath, merged + * with downstream via the documented spread + `??` idiom so unknown fields are preserved. * * Phase 3 will replace this body with a real RCM-backed provider. */ -export const experimental_docgenProvider: PresetPropertyFn<'experimental_docgenProvider'> = async ( - nextDocgen -) => { - const wrapped: DocgenProvider = async (input) => { - if (!/\.stories\.[cm]?[jt]sx?$/.test(input.importPath)) { - return nextDocgen?.(input); - } +export const experimental_docgenProvider = defineDocgenProvider((next) => async (input) => { + if (!/\.stories\.[cm]?[jt]sx?$/.test(input.importPath)) { + return next(input); + } - const downstream = await nextDocgen?.(input); - const componentId = input.importPath - .replace(/^.*\//, '') - .replace(/\.stories\.[cm]?[jt]sx?$/, ''); - return { - componentId: downstream?.componentId ?? componentId, - name: downstream?.name || componentId, - description: downstream?.description || `Mocked docgen for ${input.importPath}`, - props: downstream?.props ?? [], - }; - }; + const downstream = await next(input); + const componentId = input.importPath.replace(/^.*\//, '').replace(/\.stories\.[cm]?[jt]sx?$/, ''); + const fallbackDescription = `Mocked docgen for ${input.importPath}`; - return wrapped; -}; + return { + ...downstream, + componentId: downstream?.componentId ?? componentId, + name: downstream?.name ?? componentId, + description: downstream?.description ?? fallbackDescription, + props: downstream?.props ?? [], + }; +}); From 70f750ff30680b97d5171f76452442607e7c95b3 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 29 May 2026 11:34:11 +0200 Subject: [PATCH 10/11] .storybook: enable experimentalDocgenServer in internal Storybook Lets us dogfood the docgen service against real components in storybook:ui:build and storybook:ui dev runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- code/.storybook/main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index edbc9e4bd8ee..ebc3b6661417 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -152,6 +152,7 @@ const config = defineMain({ features: { developmentModeForBuild: true, experimentalTestSyntax: true, + experimentalDocgenServer: true, changeDetection: true, }, staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }], From 88740ec7e02eeaf908024144a72f3726027fab84 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 29 May 2026 13:03:59 +0200 Subject: [PATCH 11/11] simplify docgen provider preset api --- code/addons/docs/src/docgen.ts | 27 +++++---- code/core/src/common/index.ts | 4 -- .../src/core-server/presets/common-preset.ts | 18 +++--- .../services/docgen/defineProvider.ts | 60 ------------------- .../open-service/services/docgen/types.ts | 20 ++++++- code/core/src/types/modules/core-common.ts | 1 + code/renderers/react/src/docgen/preset.ts | 41 +++++++------ 7 files changed, 65 insertions(+), 106 deletions(-) delete mode 100644 code/core/src/shared/open-service/services/docgen/defineProvider.ts diff --git a/code/addons/docs/src/docgen.ts b/code/addons/docs/src/docgen.ts index e9f1adfd69be..36f4602a636f 100644 --- a/code/addons/docs/src/docgen.ts +++ b/code/addons/docs/src/docgen.ts @@ -1,4 +1,4 @@ -import { defineDocgenProvider } from 'storybook/internal/common'; +import type { DocgenProviderPreset } from 'storybook/internal/types'; /** * Addon-docs docgen provider. @@ -7,15 +7,18 @@ import { defineDocgenProvider } from 'storybook/internal/common'; * consumers can tell that addon-docs participated. Does NOT produce docgen on its own — when no * downstream provider supplied a payload it returns undefined so the chain falls through. */ -export const experimental_docgenProvider = defineDocgenProvider((next) => async (input) => { - const downstream = await next(input); - if (!downstream) { - return undefined; - } - return { - ...downstream, - description: downstream.description - ? `${downstream.description} (docs enabled)` - : 'docs enabled', +export const experimental_docgenProvider: DocgenProviderPreset = async (nextDocgen) => { + return async (input) => { + const downstream = await nextDocgen(input); + if (!downstream) { + return undefined; + } + return { + ...downstream, + description: downstream.description + ? `${downstream.description} (docs enabled)` + : 'docs enabled', + props: [...downstream.props, { source: '@storybook/addon-docs', kind: 'docs-marker' }], + }; }; -}); +}; diff --git a/code/core/src/common/index.ts b/code/core/src/common/index.ts index 4cf5a5642e07..045a45b1b9c3 100644 --- a/code/core/src/common/index.ts +++ b/code/core/src/common/index.ts @@ -38,10 +38,6 @@ export * from './utils/satisfies.ts'; export * from './utils/formatter.ts'; export * from './utils/get-story-id.ts'; export * from './utils/component-id.ts'; -export { - defineDocgenProvider, - type DocgenProviderMiddleware, -} from '../shared/open-service/services/docgen/defineProvider.ts'; export * from './utils/posix.ts'; export * from './utils/sync-main-preview-addons.ts'; export * from './utils/setup-addon-in-config.ts'; diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 3d42633c781f..ce52199d8136 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -315,15 +315,6 @@ export const managerEntries = async (existing: any) => { globalThis.STORYBOOK_SERVICES_LOADED = globalThis.STORYBOOK_SERVICES_LOADED ?? false; -/** - * Seed provider for the experimental_docgenProvider middleware chain. - * - * Returns `undefined` so the bottom of the chain signals "no docgen here" — each upstream - * provider can either replace this with its own payload, return its own undefined, or call - * `nextDocgen` and merge with downstream output. - */ -const identityDocgenProvider: DocgenProvider = async () => undefined; - export const services = async (_value: void, options: Options): Promise => { if (globalThis.STORYBOOK_SERVICES_LOADED) { throw new Error( @@ -344,7 +335,14 @@ export const services = async (_value: void, options: Options): Promise => const provider = await options.presets.apply( 'experimental_docgenProvider', - identityDocgenProvider + /** + * Seed provider for the experimental_docgenProvider middleware chain. + * + * Returns `undefined` so the bottom of the chain signals "no docgen here" — each upstream + * provider can either replace this with its own payload, return its own undefined, or call + * `nextDocgen` and merge with downstream output. + */ + async () => undefined ); registerDocgenService({ diff --git a/code/core/src/shared/open-service/services/docgen/defineProvider.ts b/code/core/src/shared/open-service/services/docgen/defineProvider.ts deleted file mode 100644 index 2d7823dea37e..000000000000 --- a/code/core/src/shared/open-service/services/docgen/defineProvider.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Options, PresetPropertyFn } from '../../../../types/modules/core-common.ts'; -import type { DocgenProvider, DocgenProviderInput, DocgenPayload } from './types.ts'; - -/** - * Middleware factory for the `experimental_docgenProvider` preset. - * - * Receives the previously accumulated provider as `next` (always defined — core's services - * preset seeds the chain with an identity provider) and the resolved preset options, and returns - * a new {@link DocgenProvider}. - */ -export type DocgenProviderMiddleware = ( - next: DocgenProvider, - options: Options -) => DocgenProvider | Promise; - -/** - * Helper for authoring an `experimental_docgenProvider` preset. - * - * Wraps a middleware factory so it conforms to Storybook's preset surface, with two ergonomic - * upgrades over writing the raw `PresetPropertyFn` by hand: - * - * 1. **`next` is non-nullable.** Core's services preset always seeds the chain with an identity - * provider, so `next?.(input)` is impossible-state defense. This helper throws clearly if the - * seed is ever missing — signaling a preset misconfiguration instead of silently degrading - * payloads into empty strings. - * 2. **One documented merge idiom for providers.** When wrapping `next`, return - * `{ ...downstream, ...yourOverrides }` and reach for `downstream?.field ?? yours` (not `||`) - * when filling gaps. The spread guarantees that {@link DocgenPayload} fields added by future - * providers — or future schema growth — are not silently dropped by a provider that doesn't - * know about them. `??` preserves any explicit value downstream produced (including empty - * strings), so a downstream provider that intentionally set a field to "" is not overridden - * by your own defaults. - * - * @example - * export const experimental_docgenProvider = defineDocgenProvider((next) => async (input) => { - * const downstream = await next(input); - * if (!downstream) return undefined; // I'm an enricher, nothing to enrich - * return { - * ...downstream, - * description: `${downstream.description} (enriched)`, - * }; - * }); - */ -export function defineDocgenProvider( - middleware: DocgenProviderMiddleware -): PresetPropertyFn<'experimental_docgenProvider'> { - return async (next, options) => { - if (!next) { - throw new Error( - '`experimental_docgenProvider` was applied without a downstream provider. ' + - "The core 'services' preset seeds the chain with an identity provider — if this fires, " + - 'the docgen preset chain is misconfigured.' - ); - } - return middleware(next, options); - }; -} - -// Re-exporting these so provider authors only need one import path. -export type { DocgenPayload, DocgenProvider, DocgenProviderInput }; diff --git a/code/core/src/shared/open-service/services/docgen/types.ts b/code/core/src/shared/open-service/services/docgen/types.ts index 5c793d30cfc4..09de91186907 100644 --- a/code/core/src/shared/open-service/services/docgen/types.ts +++ b/code/core/src/shared/open-service/services/docgen/types.ts @@ -1,3 +1,5 @@ +import type { Options } from '../../../../types/modules/core-common.ts'; + /** * Caller-facing input to a docgen provider middleware. * @@ -36,7 +38,21 @@ export interface DocgenPayload { * the payload field-by-field. Manual reconstruction silently drops any fields a future provider * (or future schema change) adds and your provider doesn't know about. `??` preserves explicit * values from downstream — including empty strings — so providers that intentionally set a field - * are not overridden by a later provider's defaults. Prefer authoring with `defineDocgenProvider` - * from `storybook/internal/common`, which encodes the contract. + * are not overridden by a later provider's defaults. */ export type DocgenProvider = (input: DocgenProviderInput) => Promise; + +/** + * Preset signature for `experimental_docgenProvider`. + * + * Like `PresetPropertyFn<'experimental_docgenProvider'>` but with `nextDocgen` typed as + * non-nullable. Core's `services` preset always seeds the middleware chain with an identity + * provider, so the optional typing inherited from `StorybookConfigRaw` is impossible-state + * defense at the provider-author level — use this type to drop the `?.` noise. If the seed is + * ever missing at runtime, that's a preset-wiring bug and the provider will throw on the first + * `nextDocgen(...)` call rather than silently degrading. + */ +export type DocgenProviderPreset = ( + nextDocgen: DocgenProvider, + options: Options +) => DocgenProvider | Promise; diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index b720fdc6c882..9616b30a62dc 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -20,6 +20,7 @@ export type { DocgenPayload, DocgenProvider, DocgenProviderInput, + DocgenProviderPreset, } from '../../shared/open-service/services/docgen/types.ts'; /** ⚠️ This file contains internal WIP types they MUST NOT be exported outside this package for now! */ diff --git a/code/renderers/react/src/docgen/preset.ts b/code/renderers/react/src/docgen/preset.ts index a3b4b2b3ebf8..2e650d75315e 100644 --- a/code/renderers/react/src/docgen/preset.ts +++ b/code/renderers/react/src/docgen/preset.ts @@ -1,28 +1,33 @@ -import { defineDocgenProvider } from 'storybook/internal/common'; +import type { DocgenProviderPreset } from 'storybook/internal/types'; /** * Phase-1 mock docgen provider for the React renderer. * - * Bails to `next` for paths that don't look like CSF story files (e.g. `.mdx` attached-docs). - * For CSF paths it synthesizes a deterministic name + description from the importPath, merged - * with downstream via the documented spread + `??` idiom so unknown fields are preserved. + * Bails to `nextDocgen` for paths that don't look like CSF story files (e.g. `.mdx` + * attached-docs). For CSF paths it synthesizes a deterministic name + description from the + * importPath, merged with downstream via the documented spread + `??` idiom so unknown fields + * are preserved. * * Phase 3 will replace this body with a real RCM-backed provider. */ -export const experimental_docgenProvider = defineDocgenProvider((next) => async (input) => { - if (!/\.stories\.[cm]?[jt]sx?$/.test(input.importPath)) { - return next(input); - } +export const experimental_docgenProvider: DocgenProviderPreset = async (nextDocgen) => { + return async (input) => { + if (!/\.stories\.[cm]?[jt]sx?$/.test(input.importPath)) { + return nextDocgen(input); + } - const downstream = await next(input); - const componentId = input.importPath.replace(/^.*\//, '').replace(/\.stories\.[cm]?[jt]sx?$/, ''); - const fallbackDescription = `Mocked docgen for ${input.importPath}`; + const downstream = await nextDocgen(input); + const componentId = input.importPath + .replace(/^.*\//, '') + .replace(/\.stories\.[cm]?[jt]sx?$/, ''); + const fallbackDescription = `Mocked docgen for ${input.importPath}`; - return { - ...downstream, - componentId: downstream?.componentId ?? componentId, - name: downstream?.name ?? componentId, - description: downstream?.description ?? fallbackDescription, - props: downstream?.props ?? [], + return { + ...downstream, + componentId: downstream?.componentId ?? componentId, + name: downstream?.name ?? componentId, + description: downstream?.description ?? fallbackDescription, + props: downstream?.props ?? [], + }; }; -}); +};