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' }],
diff --git a/code/addons/docs/src/docgen.ts b/code/addons/docs/src/docgen.ts
new file mode 100644
index 000000000000..36f4602a636f
--- /dev/null
+++ b/code/addons/docs/src/docgen.ts
@@ -0,0 +1,24 @@
+import type { DocgenProviderPreset } from 'storybook/internal/types';
+
+/**
+ * Addon-docs docgen provider.
+ *
+ * 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: 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/addons/docs/src/preset.ts b/code/addons/docs/src/preset.ts
index 315489865720..cee1c6d00b4a 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_docgenProvider } from './docgen';
diff --git a/code/addons/docs/template/stories/docs2/UtfSymbolScroll.mdx b/code/addons/docs/template/stories/docs2/UtfSymbolScroll.mdx
index d8aa64b1bf1c..4c67a5aa7b02 100644
--- a/code/addons/docs/template/stories/docs2/UtfSymbolScroll.mdx
+++ b/code/addons/docs/template/stories/docs2/UtfSymbolScroll.mdx
@@ -6,9 +6,11 @@ import { Meta } from '@storybook/addon-docs/blocks';
> Instruction below works only in iframe.html. Unknown code in normal mode (with manager) removes hash from url.
-Click on [link](#anchor-with-utf-symbols-абвг). That will jump scroll to anchor after green block below. Then reload page and
-it should smooth-scroll to that anchor.
+Click on [link](#anchor-with-utf-symbols-абвг). That will jump scroll to anchor after green block below. Then reload page and
+it should smooth-scroll to that anchor.
-
Space for scroll test
+
+ Space for scroll test
+
-## Anchor with utf symbols (абвг)
\ No newline at end of file
+## Anchor with utf symbols (абвг)
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/build-static.ts b/code/core/src/core-server/build-static.ts
index 71c5b22f27c9..225bd97b56df 100644
--- a/code/core/src/core-server/build-static.ts
+++ b/code/core/src/core-server/build-static.ts
@@ -17,8 +17,11 @@ import { global } from '@storybook/global';
import { join, relative, resolve } from 'pathe';
import picocolors from 'picocolors';
+import {
+ getRegisteredServices,
+ writeOpenServiceStaticFiles,
+} from '../shared/open-service/server.ts';
import { applyServicesPresetOnce } from './utils/apply-services-preset-once.ts';
-import { 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';
@@ -147,7 +150,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);
diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts
index 1bab61adaac0..ce52199d8136 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,
+ DocgenProvider,
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,42 @@ export const managerEntries = async (existing: any) => {
};
globalThis.STORYBOOK_SERVICES_LOADED = globalThis.STORYBOOK_SERVICES_LOADED ?? false;
-export const services = async () => {
+
+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 features = await options.presets.apply('features');
+
+ // 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');
+
+ const provider = await options.presets.apply(
+ 'experimental_docgenProvider',
+ /**
+ * 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({
+ getIndex: () => generator.getIndex(),
+ provider,
+ });
+ }
};
// Store the promise (not the result) to prevent race conditions.
diff --git a/code/core/src/csf/story.ts b/code/core/src/csf/story.ts
index 5d8a3d27704d..d74b7df0c482 100644
--- a/code/core/src/csf/story.ts
+++ b/code/core/src/csf/story.ts
@@ -1,3 +1,6 @@
+import type { BoundFunctions, queries } from '@testing-library/dom';
+import type { userEvent } from '@testing-library/user-event';
+
import type { OmitIndexSignature, Simplify, UnionToIntersection } from 'type-fest';
import type { ToolbarArgType } from '../toolbar/index.ts';
@@ -264,7 +267,7 @@ export type AfterEach = (
context: StoryContext
) => Awaitable;
-export interface Canvas {}
+export interface Canvas extends BoundFunctions {}
export interface StoryContext
extends StoryContextForEnhancers, Required> {
@@ -277,6 +280,7 @@ export interface StoryContext;
context: this;
canvas: Canvas;
+ userEvent: ReturnType;
mount: TRenderer['mount'];
reporting: ReportingAPI;
}
diff --git a/code/core/src/preview-api/modules/preview-web/PreviewWithSelection.tsx b/code/core/src/preview-api/modules/preview-web/PreviewWithSelection.tsx
index b37d5ed11939..9ccc49101e63 100644
--- a/code/core/src/preview-api/modules/preview-web/PreviewWithSelection.tsx
+++ b/code/core/src/preview-api/modules/preview-web/PreviewWithSelection.tsx
@@ -458,7 +458,7 @@ export class PreviewWithSelection extends Preview {
// Get ready to render a story, returning the element to render to
prepareForStory(story: PreparedStory): TStorybookRoot;
- prepareForDocs(): TStorybookRoot;
+ prepareForDocs(options?: { scrollReset?: boolean }): TStorybookRoot;
showErrorDisplay(err: { message?: string; stack?: string }): void;
diff --git a/code/core/src/preview-api/modules/preview-web/WebView.ts b/code/core/src/preview-api/modules/preview-web/WebView.ts
index 86e857907868..51e15fdb1102 100644
--- a/code/core/src/preview-api/modules/preview-web/WebView.ts
+++ b/code/core/src/preview-api/modules/preview-web/WebView.ts
@@ -81,13 +81,19 @@ export class WebView implements View {
return document.getElementById('storybook-root')!;
}
- prepareForDocs() {
+ prepareForDocs({ scrollReset = true }: { scrollReset?: boolean } = {}) {
this.showMain();
this.showDocs();
this.applyLayout('fullscreen');
- document.documentElement.scrollTop = 0;
- document.documentElement.scrollLeft = 0;
+ // Only reset scroll when navigating to a new docs page, not on HMR re-renders.
+ // Without this guard, hot-reloading a story file while scrolled down on a docs page
+ // causes the page to jump back to the top.
+
+ if (scrollReset) {
+ document.documentElement.scrollTop = 0;
+ document.documentElement.scrollLeft = 0;
+ }
return this.docsRoot();
}
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/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.
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..9f283ba2f65e
--- /dev/null
+++ b/code/core/src/shared/open-service/services/docgen/definition.ts
@@ -0,0 +1,77 @@
+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);
+
+// 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_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',
+ 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],
+ staticPath: (input) => `${input.componentId}.json`,
+ },
+ },
+ commands: {
+ extractDocgen: {
+ description:
+ '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: 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
new file mode 100644
index 000000000000..ed3e56eda075
--- /dev/null
+++ b/code/core/src/shared/open-service/services/docgen/server.test.ts
@@ -0,0 +1,220 @@
+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 { DocgenProvider } 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('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([
+ makeStoryEntry('button--primary', 'Button'),
+ makeStoryEntry('button--secondary', 'Button'),
+ ]),
+ provider,
+ });
+
+ const returned = await service.commands.extractDocgen({ componentId: 'button' });
+
+ 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('returns undefined and leaves state untouched when the provider returns undefined', async () => {
+ const service = registerDocgenService({
+ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]),
+ provider: async () => undefined,
+ });
+
+ const returned = await service.commands.extractDocgen({ componentId: 'button' });
+
+ expect(returned).toBeUndefined();
+ 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(
+ /No story or attached docs entry was found for componentId "unknown"/
+ );
+ });
+
+ it('propagates provider errors out of the command', async () => {
+ const service = registerDocgenService({
+ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]),
+ provider: async () => {
+ throw new Error('provider blew up');
+ },
+ });
+
+ await expect(service.commands.extractDocgen({ componentId: 'button' })).rejects.toThrow(
+ 'provider 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')]),
+ provider: async () => ({
+ componentId: 'button',
+ 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')]),
+ provider: async () => ({
+ componentId: 'button',
+ 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')]),
+ provider: async () => undefined,
+ });
+
+ 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 componentId whose provider produced a payload', async () => {
+ registerDocgenService({
+ getIndex: makeGetIndex([
+ makeStoryEntry('button--primary', 'Button'),
+ makeStoryEntry('button--secondary', 'Button'),
+ makeStoryEntry('card--default', 'Card'),
+ ]),
+ provider: async ({ importPath }) => ({
+ componentId: importPath.includes('button') ? 'button' : 'card',
+ name: importPath.includes('button') ? 'Button' : 'Card',
+ description: `from ${importPath}`,
+ props: [],
+ }),
+ });
+
+ const store = await buildStaticFiles();
+
+ 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',
+ name: 'Button',
+ description: 'from ./button.stories.tsx',
+ props: [],
+ },
+ },
+ });
+ });
+ });
+
+ describe('provider middleware composition', () => {
+ it('lets a wrapping provider delegate to nextDocgen and merge its output', async () => {
+ const inner: DocgenProvider = async () => ({
+ componentId: 'button',
+ name: 'inner-name',
+ description: '',
+ props: [],
+ });
+
+ const outer: DocgenProvider = async (input) => {
+ const downstream = await inner(input);
+ if (!downstream) {
+ return undefined;
+ }
+ return { ...downstream, description: 'outer-description' };
+ };
+
+ const service = registerDocgenService({
+ getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]),
+ provider: outer,
+ });
+
+ await expect(service.queries.getDocgen.loaded({ componentId: 'button' })).resolves.toEqual({
+ componentId: 'button',
+ name: 'inner-name',
+ description: 'outer-description',
+ props: [],
+ });
+ });
+
+ 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: passthrough,
+ });
+
+ 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
new file mode 100644
index 000000000000..6989b21c2df4
--- /dev/null
+++ b/code/core/src/shared/open-service/services/docgen/server.ts
@@ -0,0 +1,77 @@
+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 { DocgenProvider } 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 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;
+};
+
+/**
+ * Registers the docgen open service against the process-global registry.
+ *
+ * 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, {
+ queries: {
+ getDocgen: {
+ load: async (input, ctx) => {
+ await ctx.self.commands.extractDocgen(input);
+ },
+ 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 }));
+ },
+ },
+ },
+ commands: {
+ extractDocgen: {
+ handler: async (input, ctx) => {
+ const index = await options.getIndex();
+ const entry = Object.values(index.entries).find(
+ (e) => getComponentIdFromEntry(e) === input.componentId
+ );
+
+ 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({ importPath: entry.importPath });
+
+ if (!payload) {
+ // 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;
+ },
+ },
+ },
+ });
+}
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..09de91186907
--- /dev/null
+++ b/code/core/src/shared/open-service/services/docgen/types.ts
@@ -0,0 +1,58 @@
+import type { Options } from '../../../../types/modules/core-common.ts';
+
+/**
+ * Caller-facing input to a docgen provider middleware.
+ *
+ * `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 {
+ importPath: string;
+}
+
+/**
+ * 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 provider function registered through the `experimental_docgenProvider` preset.
+ *
+ * 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.
+ */
+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/test/index.ts b/code/core/src/test/index.ts
index 8771c5e0282c..53293bb41d59 100644
--- a/code/core/src/test/index.ts
+++ b/code/core/src/test/index.ts
@@ -1,4 +1,3 @@
-import type { BoundFunctions } from '@testing-library/dom';
import type { userEvent } from '@testing-library/user-event';
import { instrument } from 'storybook/internal/instrumenter';
@@ -6,22 +5,12 @@ import { instrument } from 'storybook/internal/instrumenter';
import { Assertion } from 'chai';
import { expect as rawExpect } from './expect.ts';
-import { type queries } from './testing-library.ts';
export * from './spy.ts';
export type { Assertion, Expect } from './expect.ts';
-type Queries = BoundFunctions;
-
export type UserEventObject = ReturnType;
-declare module 'storybook/internal/csf' {
- interface Canvas extends Queries {}
- interface StoryContext {
- userEvent: UserEventObject;
- }
-}
-
export const { expect } = instrument(
{ expect: rawExpect },
{
diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts
index ce9188e48e19..9616b30a62dc 100644
--- a/code/core/src/types/modules/core-common.ts
+++ b/code/core/src/types/modules/core-common.ts
@@ -10,11 +10,19 @@ import type { Server as NetServer } from 'net';
import type { Options as TelejsonOptions } from 'telejson';
import type { PackageJson as PackageJsonFromTypeFest } from 'type-fest';
+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 {
+ 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! */
export type BuilderName = 'webpack5' | '@storybook/builder-webpack5' | string;
@@ -114,6 +122,11 @@ export interface Presets {
args?: any
): Promise;
apply(extension: 'services', config?: StorybookConfigRaw['services'], args?: any): Promise;
+ apply(
+ extension: 'experimental_docgenProvider',
+ config: DocgenProvider,
+ args?: any
+ ): Promise;
/** The second and third parameter are not needed. And make type inference easier. */
apply(extension: T): Promise;
@@ -437,6 +450,7 @@ export interface StorybookConfigRaw {
core?: CoreConfig;
experimental_manifests?: Manifests;
experimental_enrichCsf?: CsfEnricher;
+ experimental_docgenProvider?: DocgenProvider;
staticDirs?: (DirectoryMapping | string)[];
logLevel?: string;
features?: {
@@ -571,6 +585,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
@@ -749,6 +775,13 @@ export interface StorybookConfig {
/** Run open-service registration side effects for the server environment. */
services?: PresetValue;
+
+ /**
+ * 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_docgenProvider?: PresetValue;
}
export type PresetValue = T | ((config: T, options: Options) => T | Promise);
diff --git a/code/frameworks/tanstack-react/src/export-mocks/react-router.test.ts b/code/frameworks/tanstack-react/src/export-mocks/react-router.test.ts
new file mode 100644
index 000000000000..c1dde615cfcb
--- /dev/null
+++ b/code/frameworks/tanstack-react/src/export-mocks/react-router.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { createRootRoute } from '@tanstack/react-router';
+
+import { createFileRoute } from './react-router.ts';
+
+const build = (path: string) => {
+ const root = createRootRoute();
+ return createFileRoute(path)({
+ component: () => null,
+ getParentRoute: () => root,
+ }) as any;
+};
+
+describe('createFileRoute', () => {
+ it('keeps the route id while normalizing pathless group segments', () => {
+ const Route = build('/(group)/page');
+
+ expect(Route.options.id).toBe('/(group)/page');
+ expect(Route.options.path).toBe('/page');
+ expect(Route.options.fullPath).toBe('/page');
+ });
+
+ it('normalizes nested pathless group segments', () => {
+ const Route = build('/(a)/(b)/page');
+
+ expect(Route.options.id).toBe('/(a)/(b)/page');
+ expect(Route.options.path).toBe('/page');
+ expect(Route.options.fullPath).toBe('/page');
+ });
+
+ it('normalizes a pure pathless group route to root path', () => {
+ const Route = build('/(group)');
+
+ expect(Route.options.id).toBe('/(group)');
+ expect(Route.options.path).toBe('/');
+ expect(Route.options.fullPath).toBe('/');
+ });
+
+ it('normalizes pathless `_layout` segments the same way the generator does', () => {
+ const Route = build('/_layout/page');
+
+ expect(Route.options.id).toBe('/_layout/page');
+ expect(Route.options.path).toBe('/page');
+ expect(Route.options.fullPath).toBe('/page');
+ });
+
+ it('normalizes a mix of `_layout` and `(group)` segments', () => {
+ const Route = build('/_layout/(group)/page');
+
+ expect(Route.options.id).toBe('/_layout/(group)/page');
+ expect(Route.options.path).toBe('/page');
+ expect(Route.options.fullPath).toBe('/page');
+ });
+});
diff --git a/code/frameworks/tanstack-react/src/export-mocks/react-router.ts b/code/frameworks/tanstack-react/src/export-mocks/react-router.ts
index 42c89f249188..d8b70efe0e28 100644
--- a/code/frameworks/tanstack-react/src/export-mocks/react-router.ts
+++ b/code/frameworks/tanstack-react/src/export-mocks/react-router.ts
@@ -26,6 +26,7 @@ import {
} from '@tanstack/react-router';
import type { Navigate as _Navigate } from '@tanstack/react-router';
import { onNavigate } from './spies.ts';
+import { normalizeFileRoutePath } from '../routing/path-utils.ts';
// Mock navigation hooks — backed by real implementations so they work in stories
export const useNavigate = fn(_useNavigate).mockName('@tanstack/react-router::useNavigate');
@@ -82,15 +83,17 @@ export const Link = ({
* because the org `createFileRoute` doesn't set the path in the Route
*/
export function createFileRoute(path: string) {
+ const normalizedPath = normalizeFileRoutePath(path);
+
return (options: any) => {
return createRoute({
- path,
+ path: normalizedPath,
...options,
isRoot: false,
}).update({
id: path,
- path: path,
- fullPath: path,
+ path: normalizedPath,
+ fullPath: normalizedPath,
// any because tanstack router does that
} as any);
};
diff --git a/code/frameworks/tanstack-react/src/routing/decorator.tsx b/code/frameworks/tanstack-react/src/routing/decorator.tsx
index abb15b23a7c0..486a13766c13 100644
--- a/code/frameworks/tanstack-react/src/routing/decorator.tsx
+++ b/code/frameworks/tanstack-react/src/routing/decorator.tsx
@@ -20,6 +20,7 @@ import {
type DuplicatedTree,
} from './duplicate-tree.ts';
import { isRoute } from './utils.ts';
+import { normalizeFileRoutePath } from './path-utils.ts';
interface TanStackRouterStoryProps {
Story: ComponentType;
@@ -87,11 +88,15 @@ function createStoryRouter({
// Infer the initial path for the router. The priority is:
// 1. `parameters.tanstack.router.path` (explicit path override)
// 2. `leaf.fullPath` (the full path of the resolved leaf route, if it has one)
- // 3. The full path of the first child of the route tree, if it exists (e.g. the Story's parent route)
- // 4. `/` as a last resort
+ // 3. The normalized `leaf.id` (defensive fallback: a leaf may carry an unnormalized id
+ // like `/(group)/page` with an empty `fullPath`, which would otherwise silently
+ // coerce to `/`).
+ // 4. The full path of the first child of the route tree, if it exists (e.g. the Story's parent route)
+ // 5. `/` as a last resort
const inferredPath =
routerParameters?.path ||
leaf.fullPath ||
+ (leaf.id ? normalizeFileRoutePath(leaf.id) : undefined) ||
(routeTree.children as AnyRoute[] | undefined)?.[0]?.fullPath ||
'/';
diff --git a/code/frameworks/tanstack-react/src/routing/path-utils.ts b/code/frameworks/tanstack-react/src/routing/path-utils.ts
new file mode 100644
index 000000000000..d92dce7af8ee
--- /dev/null
+++ b/code/frameworks/tanstack-react/src/routing/path-utils.ts
@@ -0,0 +1,28 @@
+/**
+ * Utility taken from @tanstack/router-generator
+ */
+
+const possiblyNestedRouteGroupPatternRegex = /\([^/]+\)\/?/g;
+
+export function removeGroups(s: string) {
+ return s.replace(possiblyNestedRouteGroupPatternRegex, '');
+}
+
+export function removeLayoutSegments(routePath = '/'): string {
+ return routePath
+ .split('/')
+ .filter((segment) => !segment.startsWith('_'))
+ .join('/');
+}
+
+const underscoreStartEndRegex = /(^_|_$)/gi;
+const underscoreSlashRegex = /(\/_|_\/)/gi;
+
+export function removeUnderscores(s?: string) {
+ return s?.replace(underscoreStartEndRegex, '').replace(underscoreSlashRegex, '/');
+}
+
+export function normalizeFileRoutePath(path: string): string {
+ const stripped = removeGroups(removeUnderscores(removeLayoutSegments(path)) ?? '');
+ return stripped || '/';
+}
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..2e650d75315e
--- /dev/null
+++ b/code/renderers/react/src/docgen/preset.ts
@@ -0,0 +1,33 @@
+import type { DocgenProviderPreset } from 'storybook/internal/types';
+
+/**
+ * Phase-1 mock docgen provider for the React renderer.
+ *
+ * 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: DocgenProviderPreset = async (nextDocgen) => {
+ return async (input) => {
+ if (!/\.stories\.[cm]?[jt]sx?$/.test(input.importPath)) {
+ return nextDocgen(input);
+ }
+
+ 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 ?? [],
+ };
+ };
+};
diff --git a/code/renderers/react/src/docs/jsxDecorator.test.tsx b/code/renderers/react/src/docs/jsxDecorator.test.tsx
index 8010177a9160..4ca9206f9e69 100644
--- a/code/renderers/react/src/docs/jsxDecorator.test.tsx
+++ b/code/renderers/react/src/docs/jsxDecorator.test.tsx
@@ -298,6 +298,34 @@ describe('renderJsx', () => {
`);
});
+ // arrow functions with an empty .name, so without help they rendered as .
+ /* eslint-disable react/display-name */
+ it('resolves subcomponents attached as properties of a parent component', () => {
+ const Modal: any = ({ children }: { children?: React.ReactNode }) => {children}
;
+ Modal.Title = ({ children }: { children?: React.ReactNode }) => {children}
;
+ Modal.Content = ({ children }: { children?: React.ReactNode }) => {children}
;
+
+ expect(
+ renderJsx(
+
+ Hi
+ Body
+ ,
+ { parentComponent: Modal }
+ )
+ ).toMatchInlineSnapshot(`
+
+
+ Hi
+
+
+ Body
+
+
+ `);
+ });
+ /* eslint-enable react/display-name */
+
// Regression for #27127: react-element-to-jsx-string used to omit boolean
// props explicitly set to `false`. Patched via algolia/react-element-to-jsx-string#733
// so a `false` prop is rendered while a `true` prop keeps the shorthand syntax.
diff --git a/code/renderers/react/src/docs/jsxDecorator.tsx b/code/renderers/react/src/docs/jsxDecorator.tsx
index 1d1048c2e624..5d6c2ad66d5c 100644
--- a/code/renderers/react/src/docs/jsxDecorator.tsx
+++ b/code/renderers/react/src/docs/jsxDecorator.tsx
@@ -80,6 +80,8 @@ type JSXOptions = Options & {
enableBeautify?: boolean;
/** Override the display name used for a component */
displayName?: string | Options['displayName'];
+ /** The meta's `component`. Used to recover names for subcomponents attached as parent properties */
+ parentComponent?: any;
};
/** Apply the users parameters and render the jsx for a story */
@@ -148,6 +150,15 @@ export const renderJsx = (code: React.ReactElement, options?: JSXOptions) => {
} else if (el.type.name && el.type.name !== '_default') {
return el.type.name;
} else if (typeof el.type === 'function') {
+ const parent = options?.parentComponent;
+ if (parent) {
+ for (const key of Object.keys(parent)) {
+ if (/^[A-Z]/.test(key) && (parent as any)[key] === el.type) {
+ const parentName = (parent as any).displayName || parent.name || '';
+ return parentName ? `${parentName}.${key}` : key;
+ }
+ }
+ }
return 'No Display Name';
} else if (isForwardRef(el.type)) {
return el.type.render.name;
@@ -243,6 +254,7 @@ export const jsxDecorator = (
const options = {
...defaultOpts,
...(context?.parameters.jsx || {}),
+ parentComponent: context?.component,
} as Required;
const storyJsx = context.originalStoryFn(context.args, context);
diff --git a/code/renderers/react/src/preset.ts b/code/renderers/react/src/preset.ts
index 362edc5c1854..a16bd2865a3c 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_docgenProvider } from './docgen/preset.ts';
+
export const previewAnnotations: PresetProperty<'previewAnnotations'> = async (
input = [],
options