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