Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b5f192e
Initial plan
Copilot May 28, 2026
5da9cf9
fix(tanstack-react): normalize pathless file-route paths
Copilot May 28, 2026
0739dc7
core: add DocgenService (phase 1 with mocked data)
JReinhold May 28, 2026
e389ca7
core,addon-docs: rename docgen extractor → provider, add addon-docs p…
JReinhold May 28, 2026
5288d7a
core,addon-docs,react: rename preset key to experimental_docgenProvid…
JReinhold May 28, 2026
8bcd35c
fix: imporve implementation
huang-julien May 28, 2026
f21f499
Merge branch 'claude/practical-jones-e3d0c1' into claude/wizardly-bar…
JReinhold May 28, 2026
b048376
core: adapt docgen service to filePath + staticInputs split
JReinhold May 28, 2026
597fd9d
Enhance jsxDecorator: Add support for resolving subcomponents attache…
yatishgoel May 28, 2026
39f7a9b
core-server: log "Building open services.." during static build
JReinhold May 29, 2026
9c30cef
Merge branch 'claude/practical-jones-e3d0c1' into claude/wizardly-bar…
JReinhold May 29, 2026
a9ada24
core,react,addon-docs: simplify docgen provider shape; gate behind ex…
JReinhold May 29, 2026
1dc6ba2
core: extractDocgen returns the extracted payload
JReinhold May 29, 2026
cb5e62f
open-service: document the load-stays-thin, work-lives-in-commands pa…
JReinhold May 29, 2026
e4f5560
core,react,addon-docs: tighten docgen provider DX (review feedback)
JReinhold May 29, 2026
70f750f
.storybook: enable experimentalDocgenServer in internal Storybook
JReinhold May 29, 2026
88740ec
simplify docgen provider preset api
JReinhold May 29, 2026
2acfcbc
Enhance StoryContext and Canvas interfaces to include userEvent and B…
yatishgoel May 30, 2026
099c701
Merge branch 'next' into fix/30763-canvas-types-pnp
yatishgoel May 30, 2026
c8b1deb
Merge branch 'next' into copilot/bugfix-tanstack-react-route-group
huang-julien Jun 1, 2026
bb81c92
Merge branch 'next' into claude/wizardly-bartik-cc2705
JReinhold Jun 1, 2026
9400098
UI: Prevent docs page scroll reset on HMR re-render
LongTangGithub Jun 2, 2026
070efea
Merge upstream/next into fix/20486-subcomponent-display-names
yatishgoel Jun 2, 2026
4a56438
Merge branch 'next' into fix/30763-canvas-types-pnp
yatishgoel Jun 2, 2026
2ef6312
Merge branch 'next' into copilot/bugfix-tanstack-react-route-group
valentinpalkovic Jun 2, 2026
49df694
Merge remote-tracking branch 'origin/next' into copilot/bugfix-tansta…
Copilot Jun 2, 2026
0f59e38
Fix TanStack route mock assertions after merge
Copilot Jun 2, 2026
d518b9f
Merge branch 'next' into copilot/bugfix-tanstack-react-route-group
huang-julien Jun 2, 2026
23f3fb2
Merge pull request #34986 from yatishgoel/fix/30763-canvas-types-pnp
valentinpalkovic Jun 3, 2026
fda93be
Update code/addons/docs/template/stories/docs2/UtfSymbolScroll.mdx
valentinpalkovic Jun 3, 2026
de87198
Update code/addons/docs/template/stories/docs2/UtfSymbolScroll.mdx
valentinpalkovic Jun 3, 2026
eb1f1e9
Merge pull request #34954 from storybookjs/claude/wizardly-bartik-cc2705
JReinhold Jun 3, 2026
f0f3863
Merge pull request #35021 from LongTangGithub/fix/docs-hmr-scroll-to-top
valentinpalkovic Jun 3, 2026
ba8b3e1
Merge pull request #34967 from yatishgoel/fix/20486-subcomponent-disp…
valentinpalkovic Jun 3, 2026
1c695c9
Merge branch 'next' into copilot/bugfix-tanstack-react-route-group
huang-julien Jun 3, 2026
9040e16
Merge pull request #34948 from storybookjs/copilot/bugfix-tanstack-re…
huang-julien Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions code/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ const config = defineMain({
features: {
developmentModeForBuild: true,
experimentalTestSyntax: true,
experimentalDocgenServer: true,
changeDetection: true,
},
staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }],
Expand Down
24 changes: 24 additions & 0 deletions code/addons/docs/src/docgen.ts
Original file line number Diff line number Diff line change
@@ -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' }],
};
};
};
1 change: 1 addition & 0 deletions code/addons/docs/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
10 changes: 6 additions & 4 deletions code/addons/docs/template/stories/docs2/UtfSymbolScroll.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<div style={{ height: "1500px", background: "green", color: "white" }}>Space for scroll test</div>
<div style={{ height: "1500px", background: "green", color: "white" }}>
Space for scroll test
</div>

## Anchor with utf symbols (абвг)
## Anchor with utf symbols (абвг)
1 change: 1 addition & 0 deletions code/core/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 13 additions & 0 deletions code/core/src/common/utils/component-id.ts
Original file line number Diff line number Diff line change
@@ -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 `<componentId>--<storyName>`; 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<IndexEntry, 'id'>): string {
return entry.id.split('--')[0];
}
11 changes: 9 additions & 2 deletions code/core/src/core-server/build-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<StoryIndexGenerator | undefined> =
Promise.resolve(undefined);
Expand Down
34 changes: 33 additions & 1 deletion code/core/src/core-server/presets/common-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ import { logger } from 'storybook/internal/node-logger';
import { telemetry } from 'storybook/internal/telemetry';
import type {
CoreConfig,
DocgenProvider,
Indexer,
Options,
PresetProperty,
PresetPropertyFn,
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';
Expand Down Expand Up @@ -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<void> => {
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<Promise<StoryIndexGenerator>>('storyIndexGenerator');

const provider = await options.presets.apply<DocgenProvider>(
'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.
Expand Down
6 changes: 5 additions & 1 deletion code/core/src/csf/story.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -264,7 +267,7 @@ export type AfterEach<TRenderer extends Renderer = Renderer, TArgs = Args> = (
context: StoryContext<TRenderer, TArgs>
) => Awaitable<void>;

export interface Canvas {}
export interface Canvas extends BoundFunctions<typeof queries> {}

export interface StoryContext<TRenderer extends Renderer = Renderer, TArgs = Args>
extends StoryContextForEnhancers<TRenderer, TArgs>, Required<StoryContextUpdate<TArgs>> {
Expand All @@ -277,6 +280,7 @@ export interface StoryContext<TRenderer extends Renderer = Renderer, TArgs = Arg
step: StepFunction<TRenderer, TArgs>;
context: this;
canvas: Canvas;
userEvent: ReturnType<typeof userEvent.setup>;
mount: TRenderer['mount'];
reporting: ReportingAPI;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ export class PreviewWithSelection<TRenderer extends Renderer> extends Preview<TR
);
} else {
this.currentRender.renderToElement(
this.view.prepareForDocs(),
this.view.prepareForDocs({ scrollReset: storyIdChanged || viewModeChanged }),
// This argument is used for docs, which is currently only compatible with HTMLElements
this.renderStoryToElement.bind(this) as any
);
Expand Down
2 changes: 1 addition & 1 deletion code/core/src/preview-api/modules/preview-web/View.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export interface View<TStorybookRoot> {
// Get ready to render a story, returning the element to render to
prepareForStory(story: PreparedStory<any>): TStorybookRoot;

prepareForDocs(): TStorybookRoot;
prepareForDocs(options?: { scrollReset?: boolean }): TStorybookRoot;

showErrorDisplay(err: { message?: string; stack?: string }): void;

Expand Down
12 changes: 9 additions & 3 deletions code/core/src/preview-api/modules/preview-web/WebView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,19 @@ export class WebView implements View<HTMLElement> {
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();
}
Expand Down
11 changes: 11 additions & 0 deletions code/core/src/server-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
9 changes: 9 additions & 0 deletions code/core/src/shared/open-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions code/core/src/shared/open-service/services/docgen/definition.ts
Original file line number Diff line number Diff line change
@@ -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<typeof docgenPayloadSchema>
? v.InferOutput<typeof docgenPayloadSchema> 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<string, DocgenPayload | undefined>;
};

/**
* 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.
},
},
});
Loading
Loading