From c6fa3b3bf264e389fd490a0c76291e116b96c984 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 31 May 2026 11:15:03 +0000 Subject: [PATCH 1/3] feat(docgen): re-extract docgen on source change via module-graph service Server-side docgen invalidation: when a watched source file changes, the already-extracted docgen for affected components is re-extracted so the core/docgen service never serves stale data, with no flash to empty for current readers. - Add core/module-graph open service: a thin facade that maps affected story files to component ids, backed by the existing change-detection dependency graph. - Add core/docgen handleSourceChange command: re-extract-if-present (skip components not in state), keep-last-good on extraction error. - ChangeDetectionService: add an additive onInvalidate callback and a publishStatuses=false graph-only mode so the dependency graph can power docgen without surfacing review-changes statuses; existing status behavior is unchanged when publishStatuses is true. - Wire it in dev-server/common-preset: run the graph when changeDetection OR experimentalDocgenServer is enabled (server-side only; no manager transport). Co-authored-by: Jeppe Reinhold --- .../ChangeDetectionService.test.ts | 90 ++++++++++++ .../ChangeDetectionService.ts | 78 +++++++++-- code/core/src/core-server/dev-server.ts | 51 ++++++- .../src/core-server/presets/common-preset.ts | 9 ++ .../services/docgen/definition.ts | 11 ++ .../docgen/invalidation.integration.test.ts | 130 ++++++++++++++++++ .../services/docgen/server.test.ts | 86 ++++++++++++ .../open-service/services/docgen/server.ts | 28 ++++ .../services/module-graph/definition.ts | 58 ++++++++ .../services/module-graph/server.test.ts | 87 ++++++++++++ .../services/module-graph/server.ts | 81 +++++++++++ .../services/module-graph/types.ts | 16 +++ 12 files changed, 712 insertions(+), 13 deletions(-) create mode 100644 code/core/src/shared/open-service/services/docgen/invalidation.integration.test.ts create mode 100644 code/core/src/shared/open-service/services/module-graph/definition.ts create mode 100644 code/core/src/shared/open-service/services/module-graph/server.test.ts create mode 100644 code/core/src/shared/open-service/services/module-graph/server.ts create mode 100644 code/core/src/shared/open-service/services/module-graph/types.ts diff --git a/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts b/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts index a7d07ce751de..c992443c9f6e 100644 --- a/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts +++ b/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts @@ -1064,6 +1064,96 @@ describe('ChangeDetectionService', () => { await service.dispose(); }); + it('calls onInvalidate with the affected story files when a dependency changes', async () => { + // Button.tsx is imported by Button.stories.tsx (distance 1). + const reverseIndex = buildReverseIndex([ + ['/repo/src/Button.tsx', '/repo/src/Button.stories.tsx', 1], + ['/repo/src/Button.stories.tsx', '/repo/src/Button.stories.tsx', 0], + ]); + const { buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + + const storyIndex = createStoryIndex([ + { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, + ]); + const { getStatusStoreByTypeId } = createStatusStore({ + universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), + environment: 'server', + }); + const onInvalidate = vi.fn(); + const { adapter, emitFileChange } = createMockAdapter(); + const service = new ChangeDetectionService({ + storyIndexGeneratorPromise: Promise.resolve({ + getIndex: vi.fn().mockResolvedValue(storyIndex), + } as never), + statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), + gitDiffProvider: createMockGitDiffProvider(), + indexBaselineService: createMockStoryIndexBaselineService(), + workingDir, + onInvalidate, + }); + + service.start(adapter, true); + await vi.runAllTimersAsync(); + + emitFileChange({ kind: 'change', path: '/repo/src/Button.tsx' }); + await vi.runAllTimersAsync(); + + expect(onInvalidate).toHaveBeenCalledWith(['/repo/src/Button.stories.tsx']); + + await service.dispose(); + }); + + it('graph-only mode (publishStatuses:false) emits invalidations without publishing statuses or calling git', async () => { + const reverseIndex = buildReverseIndex([ + ['/repo/src/Button.tsx', '/repo/src/Button.stories.tsx', 1], + ]); + const { buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + + const storyIndex = createStoryIndex([ + { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, + ]); + const { getStatusStoreByTypeId } = createStatusStore({ + universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), + environment: 'server', + }); + const gitDiffProvider = createMockGitDiffProvider((provider) => { + provider.getChangedFilesMock.mockResolvedValue({ + changed: new Set(['src/Button.stories.tsx']), + new: new Set(), + }); + }); + const onInvalidate = vi.fn(); + const { adapter, emitFileChange } = createMockAdapter(); + const service = new ChangeDetectionService({ + storyIndexGeneratorPromise: Promise.resolve({ + getIndex: vi.fn().mockResolvedValue(storyIndex), + } as never), + statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), + gitDiffProvider, + indexBaselineService: createMockStoryIndexBaselineService(), + workingDir, + publishStatuses: false, + onInvalidate, + }); + + service.start(adapter, true); + await vi.runAllTimersAsync(); + + emitFileChange({ kind: 'change', path: '/repo/src/Button.tsx' }); + await vi.runAllTimersAsync(); + + // Invalidations still fire, but no statuses are published and git is never consulted. + expect(onInvalidate).toHaveBeenCalledWith(['/repo/src/Button.stories.tsx']); + expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({}); + expect(gitDiffProvider.getChangedFilesMock).not.toHaveBeenCalled(); + expect(gitDiffProvider.onGitStateChangeMock).not.toHaveBeenCalled(); + expect(await getChangeDetectionReadiness()).toEqual({ status: 'ready' }); + + await service.dispose(); + }); + it('scan waits for the current patch to settle before reading reverseIndex', async () => { // Without the patchSnapshot await in scan(), a git-state-change that fires scheduleScan // while a patch is mid-rewalk (reverseIndex transiently empty) reads the empty index diff --git a/code/core/src/core-server/change-detection/ChangeDetectionService.ts b/code/core/src/core-server/change-detection/ChangeDetectionService.ts index 1458b10080ba..345ac6b1502d 100644 --- a/code/core/src/core-server/change-detection/ChangeDetectionService.ts +++ b/code/core/src/core-server/change-detection/ChangeDetectionService.ts @@ -153,6 +153,8 @@ export class ChangeDetectionService { private indexBaselineService: IndexBaselineService | undefined; private readonly workingDir: string; private readonly debounceMs: number; + private readonly publishStatuses: boolean; + private readonly onInvalidate?: (affectedStoryFiles: string[]) => void; private adapter: ChangeDetectionAdapter | undefined; private dependencyGraphBuilder: DependencyGraphBuilder | undefined; private incrementalPatcher: IncrementalPatcher | undefined; @@ -181,12 +183,27 @@ export class ChangeDetectionService { debounceMs?: number; /** Presets instance used to resolve `experimental_importParsers` contributions from framework/renderer plugins. */ presets?: Presets; + /** + * When `false`, run the dependency graph + file watching + {@link onInvalidate} emit only, + * without computing or publishing git-diff statuses to the status store. Lets the graph power + * other consumers (e.g. the docgen service) without surfacing "review changes" sidebar + * statuses. Defaults to `true` (the existing full change-detection behavior). + */ + publishStatuses?: boolean; + /** + * Called after each file-change batch with the absolute, normalized story files affected by + * the change (the changed file's reverse-index dependents, plus the file itself when it is a + * story). Independent of {@link publishStatuses}; fires in both modes. + */ + onInvalidate?: (affectedStoryFiles: string[]) => void; } ) { this.gitDiffProvider = options.gitDiffProvider; this.indexBaselineService = options.indexBaselineService; this.workingDir = options.workingDir ?? process.cwd(); this.debounceMs = options.debounceMs ?? CHANGE_DETECTION_DEBOUNCE_MS; + this.publishStatuses = options.publishStatuses ?? true; + this.onInvalidate = options.onInvalidate; resetChangeDetectionReadiness(); } @@ -345,20 +362,25 @@ export class ChangeDetectionService { }); } - void this.getIndexBaselineService().start(); + // Baseline + git-state subscriptions are status-only concerns. In graph-only mode (docgen) + // we skip them so the graph never depends on git and never publishes statuses. + if (this.publishStatuses) { + void this.getIndexBaselineService().start(); - this.getGitDiffProvider().onGitStateChange(() => { - if (this.disposed) { - return; - } + this.getGitDiffProvider().onGitStateChange(() => { + if (this.disposed) { + return; + } - this.scheduleScan(this.debounceMs); - void this.getIndexBaselineService() - .handleGitStateChange() - .catch(() => undefined); - }); + this.scheduleScan(this.debounceMs); + void this.getIndexBaselineService() + .handleGitStateChange() + .catch(() => undefined); + }); + } - // Initial scan surfaces git-pending diffs immediately. + // Initial scan surfaces git-pending diffs immediately (and resolves readiness in graph-only + // mode). this.scheduleScan(0); } @@ -514,6 +536,22 @@ export class ChangeDetectionService { if (this.disposed || !this.incrementalPatcher) { return; } + + const path = normalize(event.path); + const affectedStoryFiles = new Set(); + const collectAffected = () => { + if (!this.reverseIndex) { + return; + } + for (const story of this.reverseIndex.lookup(path).keys()) { + affectedStoryFiles.add(story); + } + }; + // Snapshot dependents before the patch so `unlink` (which prunes the reverse index) is still + // captured. The patcher may also early-return on a content-only edit with an unchanged import + // set, but the existing edges still resolve here. + collectAffected(); + try { await this.incrementalPatcher.patch(event); } catch (error) { @@ -524,6 +562,17 @@ export class ChangeDetectionService { if (this.disposed) { return; } + + // Snapshot again to capture dependents introduced by `add`/`change`, then include the file + // itself when it is a story root. + collectAffected(); + if (this.storyFiles.has(path)) { + affectedStoryFiles.add(path); + } + if (this.onInvalidate && affectedStoryFiles.size > 0) { + this.onInvalidate([...affectedStoryFiles]); + } + this.scheduleScan(this.debounceMs); } @@ -561,6 +610,13 @@ export class ChangeDetectionService { this.scanInFlight = true; try { + // Graph-only mode: the dependency graph + invalidation emit are the only work; skip all + // git-diff status computation and publishing. + if (!this.publishStatuses) { + this.resolveReadiness({ status: 'ready' }); + return; + } + const nextStatuses = await this.buildStatuses(this.reverseIndex); if (this.disposed) { return; diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index e085a4446255..b289f655cfe1 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -7,6 +7,9 @@ import type { Options } from 'storybook/internal/types'; import compression from '@polka/compression'; import polka from 'polka'; +import { getService } from '../shared/open-service/server.ts'; +import type { docgenServiceDef } from '../shared/open-service/services/docgen/definition.ts'; +import type { moduleGraphServiceDef } from '../shared/open-service/services/module-graph/definition.ts'; import { isTelemetryModuleEnabled, telemetry } from '../telemetry/index.ts'; import type { ChangeDetectionAdapter } from './change-detection/index.ts'; import { ChangeDetectionService } from './change-detection/index.ts'; @@ -48,11 +51,51 @@ export async function storybookDevServer( const storyIndexGeneratorPromise = options.presets.apply('storyIndexGenerator'); + // The docgen service (registered in the `services` preset) needs the change-detection dependency + // graph to know which components a file change affects, even when the change-detection *status* + // feature is off — so we run the graph (without publishing statuses) whenever docgen is enabled. + const experimentalDocgenServer = !!features?.experimentalDocgenServer && !options.ignorePreview; + + /** + * Re-extracts docgen for components affected by a source change. + * + * Translates the change-detection graph's affected story files into component ids via the + * `core/module-graph` service, then asks `core/docgen` to re-extract the ones already in state + * (re-extract-if-present). Best-effort: failures are logged at debug and never break the watcher. + */ + async function invalidateDocgenForStoryFiles(affectedStoryFiles: string[]): Promise { + if (affectedStoryFiles.length === 0) { + return; + } + try { + const moduleGraph = getService('core/module-graph'); + const docgen = getService('core/docgen'); + const { componentIds } = await moduleGraph.commands.resolveAffectedComponents({ + storyFiles: affectedStoryFiles, + }); + if (componentIds.length > 0) { + await docgen.commands.handleSourceChange({ componentIds }); + } + } catch (error) { + logger.debug( + `Docgen invalidation skipped: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + const changeDetectionService = new ChangeDetectionService({ storyIndexGeneratorPromise, statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), workingDir, presets: options.presets, + // Only publish "review changes" sidebar statuses when the change-detection feature is on. + publishStatuses: !!features.changeDetection, + // When docgen is enabled, the graph drives docgen re-extraction on file changes. + onInvalidate: experimentalDocgenServer + ? (affectedStoryFiles) => { + void invalidateDocgenForStoryFiles(affectedStoryFiles); + } + : undefined, }); app.use(compression({ level: 1 })); @@ -124,7 +167,11 @@ export async function storybookDevServer( await Promise.resolve(); if (!options.ignorePreview) { - if (!features.changeDetection) { + // Run the dependency graph when either the change-detection status feature or the docgen + // service needs it. `publishStatuses` (set above) decides whether statuses are surfaced. + const needsChangeDetectionGraph = !!features.changeDetection || experimentalDocgenServer; + + if (!needsChangeDetectionGraph) { changeDetectionService.start(undefined, false); } @@ -153,7 +200,7 @@ export async function storybookDevServer( throw e; }); - if (features.changeDetection) { + if (needsChangeDetectionGraph) { let adapter: ChangeDetectionAdapter | undefined; try { adapter = previewBuilder.changeDetectionAdapter?.(); diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index ce52199d8136..efcca01c34d9 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -27,6 +27,7 @@ import type { } from 'storybook/internal/types'; import { registerDocgenService } from '../../shared/open-service/services/docgen/server.ts'; +import { registerModuleGraphService } from '../../shared/open-service/services/module-graph/server.ts'; import { isAbsolute, join } from 'pathe'; import * as pathe from 'pathe'; @@ -345,6 +346,14 @@ export const services = async (_value: void, options: Options): Promise => async () => undefined ); + // The module-graph service translates change-detection's affected story files into component + // ids so the docgen service can re-extract only the components that actually changed. It owns + // no file watching itself; it is fed by the change-detection graph (wired in dev-server). + registerModuleGraphService({ + getIndex: () => generator.getIndex(), + workingDir: process.cwd(), + }); + registerDocgenService({ getIndex: () => generator.getIndex(), provider, diff --git a/code/core/src/shared/open-service/services/docgen/definition.ts b/code/core/src/shared/open-service/services/docgen/definition.ts index 9f283ba2f65e..721788ea9c61 100644 --- a/code/core/src/shared/open-service/services/docgen/definition.ts +++ b/code/core/src/shared/open-service/services/docgen/definition.ts @@ -6,6 +6,9 @@ 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() }); +/** Input to the `handleSourceChange` command: component ids whose source may have changed. */ +export const handleSourceChangeInputSchema = v.object({ componentIds: v.array(v.string()) }); + /** * Phase-1 docgen payload schema. * @@ -73,5 +76,13 @@ export const docgenServiceDef = defineService({ // Handler is supplied at registration time so it can close over the story index and the // composed experimental_docgenProvider chain. }, + handleSourceChange: { + description: + 'Re-extracts docgen for the given componentIds that are already present in state (leaving absent ones for lazy load on next read). Used to keep docgen fresh when source files change.', + input: handleSourceChangeInputSchema, + output: v.void(), + // Handler is supplied at registration time so it can log keep-last-good extraction failures + // without pulling a node-only logger into the environment-agnostic definition. + }, }, }); diff --git a/code/core/src/shared/open-service/services/docgen/invalidation.integration.test.ts b/code/core/src/shared/open-service/services/docgen/invalidation.integration.test.ts new file mode 100644 index 000000000000..7ecaafebddc0 --- /dev/null +++ b/code/core/src/shared/open-service/services/docgen/invalidation.integration.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { join, normalize } from 'pathe'; + +import type { IndexEntry, StoryIndex } from '../../../../types/modules/indexer.ts'; +import { clearRegistry, getService } from '../../server.ts'; +import type { docgenServiceDef } from './definition.ts'; +import { registerDocgenService } from './server.ts'; +import type { DocgenProvider } from './types.ts'; +import type { moduleGraphServiceDef } from '../module-graph/definition.ts'; +import { registerModuleGraphService } from '../module-graph/server.ts'; + +afterEach(() => { + clearRegistry(); +}); + +const WORKING_DIR = '/repo'; + +function makeStoryEntry(id: string, fileBase: string): IndexEntry { + return { + id, + name: id.split('--').slice(1).join('--') || 'Default', + title: fileBase, + type: 'story', + subtype: 'story', + importPath: `./${fileBase}.stories.tsx`, + }; +} + +function makeGetIndex(entries: IndexEntry[]) { + const index: StoryIndex = { + v: 5, + entries: Object.fromEntries(entries.map((entry) => [entry.id, entry])), + }; + return () => Promise.resolve(index); +} + +function absStoryFile(fileBase: string): string { + return normalize(join(WORKING_DIR, `./${fileBase}.stories.tsx`)); +} + +/** + * Mirrors the dev-server glue (`onInvalidate` -> module-graph -> docgen) against the real + * process-global registry, proving the cross-service composition works end-to-end on the server. + */ +async function invalidateDocgenForStoryFiles(affectedStoryFiles: string[]): Promise { + const moduleGraph = getService('core/module-graph'); + const docgen = getService('core/docgen'); + const { componentIds } = await moduleGraph.commands.resolveAffectedComponents({ + storyFiles: affectedStoryFiles, + }); + if (componentIds.length > 0) { + await docgen.commands.handleSourceChange({ componentIds }); + } +} + +describe('docgen invalidation (server-side end-to-end)', () => { + it('re-extracts and notifies subscribers when an affected story file changes', async () => { + // A content-sensitive provider whose output changes when the underlying source changes. + const descriptionByImportPath: Record = { + './button.stories.tsx': 'v1', + }; + const provider: DocgenProvider = async ({ importPath }) => ({ + componentId: 'button', + name: 'Button', + description: descriptionByImportPath[importPath] ?? 'unknown', + props: [], + }); + + registerModuleGraphService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'button')]), + workingDir: WORKING_DIR, + }); + const docgen = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'button')]), + provider, + }); + + // User navigates to the component: docgen is extracted and cached. + await docgen.queries.getDocgen.loaded({ componentId: 'button' }); + expect(docgen.queries.getDocgen({ componentId: 'button' })).toMatchObject({ + description: 'v1', + }); + + const emitted: Array<{ description: string } | undefined> = []; + const unsubscribe = docgen.queries.getDocgen.subscribe({ componentId: 'button' }, (value) => { + emitted.push(value as { description: string } | undefined); + }); + await vi.waitFor(() => expect(emitted.at(-1)).toMatchObject({ description: 'v1' })); + + // The component's source changes on disk. + descriptionByImportPath['./button.stories.tsx'] = 'v2'; + await invalidateDocgenForStoryFiles([absStoryFile('button')]); + + // Future reads are fresh (no stale data) and the live subscriber was notified (no flash). + expect(docgen.queries.getDocgen({ componentId: 'button' })).toMatchObject({ + description: 'v2', + }); + await vi.waitFor(() => expect(emitted.at(-1)).toMatchObject({ description: 'v2' })); + + unsubscribe(); + }); + + it('does not extract components that were never viewed (re-extract-if-present)', async () => { + const provider = vi.fn(async () => ({ + componentId: 'button', + name: 'Button', + description: 'x', + props: [], + })); + + registerModuleGraphService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'button')]), + workingDir: WORKING_DIR, + }); + registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'button')]), + provider, + }); + + await invalidateDocgenForStoryFiles([absStoryFile('button')]); + + expect(provider).not.toHaveBeenCalled(); + expect( + getService('core/docgen').queries.getDocgen({ + componentId: 'button', + }) + ).toBeUndefined(); + }); +}); diff --git a/code/core/src/shared/open-service/services/docgen/server.test.ts b/code/core/src/shared/open-service/services/docgen/server.test.ts index ed3e56eda075..0be08ba01e45 100644 --- a/code/core/src/shared/open-service/services/docgen/server.test.ts +++ b/code/core/src/shared/open-service/services/docgen/server.test.ts @@ -139,6 +139,92 @@ describe('docgen open service', () => { }); }); + describe('handleSourceChange command', () => { + it('re-extracts components already in state and pushes the new value to subscribers', async () => { + let description = 'v1'; + const service = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), + provider: async () => ({ + componentId: 'button', + name: 'Button', + description, + props: [], + }), + }); + + // Prime state so the component is "present". + await service.commands.extractDocgen({ componentId: 'button' }); + + const emitted: Array<{ description: string } | undefined> = []; + const unsubscribe = service.queries.getDocgen.subscribe( + { componentId: 'button' }, + (value) => { + emitted.push(value as { description: string } | undefined); + } + ); + await vi.waitFor(() => expect(emitted.length).toBeGreaterThan(0)); + + // The source "changes" so the provider now returns a different payload. + description = 'v2'; + await service.commands.handleSourceChange({ componentIds: ['button'] }); + + expect(service.queries.getDocgen({ componentId: 'button' })).toMatchObject({ + description: 'v2', + }); + await vi.waitFor(() => expect(emitted.at(-1)).toMatchObject({ description: 'v2' })); + + unsubscribe(); + }); + + it('ignores components that are not already in state (re-extract-if-present)', async () => { + const provider = vi.fn(async () => ({ + componentId: 'button', + name: 'Button', + description: 'x', + props: [], + })); + const service = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), + provider, + }); + + await service.commands.handleSourceChange({ componentIds: ['button'] }); + + // Never extracted -> absent -> provider must not have been invoked. + expect(provider).not.toHaveBeenCalled(); + expect(service.queries.getDocgen({ componentId: 'button' })).toBeUndefined(); + }); + + it('keeps the last-good payload when re-extraction throws', async () => { + let shouldThrow = false; + const service = registerDocgenService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'Button')]), + provider: async () => { + if (shouldThrow) { + throw new Error('transient parse error'); + } + return { componentId: 'button', name: 'Button', description: 'good', props: [] }; + }, + }); + + // Prime state with a good payload (command call, no fire-and-forget load involved). + await service.commands.extractDocgen({ componentId: 'button' }); + + shouldThrow = true; + await expect( + service.commands.handleSourceChange({ componentIds: ['button'] }) + ).resolves.toBeUndefined(); + + // Re-enable success before reading via the query, whose `load` would otherwise re-run the + // throwing provider in the background. The assertion below confirms handleSourceChange kept + // the last-good payload despite the failed re-extraction. + shouldThrow = false; + expect(service.queries.getDocgen({ componentId: 'button' })).toMatchObject({ + description: 'good', + }); + }); + }); + describe('static build', () => { it('writes one docgen JSON per componentId whose provider produced a payload', async () => { registerDocgenService({ diff --git a/code/core/src/shared/open-service/services/docgen/server.ts b/code/core/src/shared/open-service/services/docgen/server.ts index 6989b21c2df4..7b12135809bc 100644 --- a/code/core/src/shared/open-service/services/docgen/server.ts +++ b/code/core/src/shared/open-service/services/docgen/server.ts @@ -1,3 +1,4 @@ +import { logger } from '../../../../node-logger/index.ts'; import { getComponentIdFromEntry } from '../../../../common/utils/component-id.ts'; import { OpenServiceDocgenMissingComponentError } from '../../../../server-errors.ts'; import type { StoryIndex } from '../../../../types/modules/indexer.ts'; @@ -72,6 +73,33 @@ export function registerDocgenService(options: RegisterDocgenServiceOptions) { return payload; }, }, + handleSourceChange: { + handler: async (input, ctx) => { + // Re-extract only components that are already in state ("present"). Absent components are + // left untouched so the next read's `load` extracts them lazily. We never null out an + // entry: `extractDocgen` overwrites in place (or no-ops when the provider yields nothing), + // so a current reader never flashes empty and a future reader sees fresh data. + for (const componentId of input.componentIds) { + if (ctx.self.state.components[componentId] === undefined) { + continue; + } + + try { + await ctx.self.commands.extractDocgen({ componentId }); + } catch (error) { + // Keep the last-good payload rather than blanking it — e.g. when a file is mid-edit + // and the provider throws on a transient syntax error. + logger.warn( + `core/docgen: failed to re-extract docgen for "${componentId}" after a source change; keeping last-known value. ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + return undefined; + }, + }, }, }); } diff --git a/code/core/src/shared/open-service/services/module-graph/definition.ts b/code/core/src/shared/open-service/services/module-graph/definition.ts new file mode 100644 index 000000000000..4c280b6c100b --- /dev/null +++ b/code/core/src/shared/open-service/services/module-graph/definition.ts @@ -0,0 +1,58 @@ +import * as v from 'valibot'; + +import { defineService } from '../../service-definition.ts'; +import type { ModuleGraphServiceState } from './types.ts'; + +/** Input to the `resolveAffectedComponents` command: absolute, normalized story-file paths. */ +export const resolveAffectedComponentsInputSchema = v.object({ + storyFiles: v.array(v.string()), +}); + +/** Output of `resolveAffectedComponents` and the shape stored in `lastAffected`. */ +export const moduleGraphInvalidationSchema = v.object({ + revision: v.number(), + componentIds: v.array(v.string()), +}); + +/** Input to the `getLastAffected` query — no parameters. */ +export const getLastAffectedInputSchema = v.optional(v.object({})); + +/** + * Definition for the `core/module-graph` open service. + * + * A thin facade over the change-detection dependency graph. It does not own file watching or the + * reverse index (those stay in `code/core/src/core-server/change-detection/`); instead it is fed a + * batch of affected story files and translates them into the component ids that consumers like + * `core/docgen` (and, later, a story-snippet service) care about. + * + * The `resolveAffectedComponents` handler is supplied at registration time because it needs to + * close over the server-only story index and working directory. `getLastAffected` is a thin + * subscribable read of the last recorded invalidation — unused in the first slice, but the seam a + * future snippet service can subscribe to. + */ +export const moduleGraphServiceDef = defineService({ + id: 'core/module-graph', + description: + 'Maps changed source files to the component ids they affect, backed by the change-detection dependency graph.', + initialState: { + lastAffected: { revision: 0, componentIds: [] }, + } as ModuleGraphServiceState, + queries: { + getLastAffected: { + description: 'Returns the most recent invalidation (revision + affected component ids).', + input: getLastAffectedInputSchema, + output: moduleGraphInvalidationSchema, + handler: (_input, ctx) => ctx.self.state.lastAffected, + }, + }, + commands: { + resolveAffectedComponents: { + description: + 'Translates a batch of affected story files into affected component ids, records them as the latest invalidation, and returns them.', + input: resolveAffectedComponentsInputSchema, + output: moduleGraphInvalidationSchema, + // Handler is supplied at registration time so it can close over the story index and the + // working directory used to resolve absolute story-file paths back to index entries. + }, + }, +}); diff --git a/code/core/src/shared/open-service/services/module-graph/server.test.ts b/code/core/src/shared/open-service/services/module-graph/server.test.ts new file mode 100644 index 000000000000..df041e873fb3 --- /dev/null +++ b/code/core/src/shared/open-service/services/module-graph/server.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { join, normalize } from 'pathe'; + +import type { IndexEntry, StoryIndex } from '../../../../types/modules/indexer.ts'; +import { clearRegistry } from '../../server.ts'; +import { registerModuleGraphService } from './server.ts'; + +afterEach(() => { + clearRegistry(); +}); + +const WORKING_DIR = '/repo'; + +function makeStoryEntry(id: string, fileBase: string): IndexEntry { + return { + id, + name: id.split('--').slice(1).join('--') || 'Default', + title: fileBase, + type: 'story', + subtype: 'story', + importPath: `./${fileBase}.stories.tsx`, + }; +} + +function makeGetIndex(entries: IndexEntry[]) { + const index: StoryIndex = { + v: 5, + entries: Object.fromEntries(entries.map((entry) => [entry.id, entry])), + }; + return () => Promise.resolve(index); +} + +function abs(fileBase: string): string { + return normalize(join(WORKING_DIR, `./${fileBase}.stories.tsx`)); +} + +describe('module-graph open service', () => { + describe('resolveAffectedComponents command', () => { + it('maps absolute story files to distinct component ids', async () => { + const service = registerModuleGraphService({ + getIndex: makeGetIndex([ + makeStoryEntry('button--primary', 'button'), + makeStoryEntry('button--secondary', 'button'), + makeStoryEntry('card--default', 'card'), + ]), + workingDir: WORKING_DIR, + }); + + const result = await service.commands.resolveAffectedComponents({ + storyFiles: [abs('button'), abs('card')], + }); + + expect(result.componentIds.sort()).toEqual(['button', 'card']); + expect(result.revision).toBe(1); + }); + + it('ignores story files that do not correspond to any index entry', async () => { + const service = registerModuleGraphService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'button')]), + workingDir: WORKING_DIR, + }); + + const result = await service.commands.resolveAffectedComponents({ + storyFiles: [abs('does-not-exist')], + }); + + expect(result.componentIds).toEqual([]); + }); + + it('records the latest invalidation in state and bumps the revision', async () => { + const service = registerModuleGraphService({ + getIndex: makeGetIndex([makeStoryEntry('button--primary', 'button')]), + workingDir: WORKING_DIR, + }); + + await service.commands.resolveAffectedComponents({ storyFiles: [abs('button')] }); + const second = await service.commands.resolveAffectedComponents({ storyFiles: [] }); + + expect(second.revision).toBe(2); + expect(service.queries.getLastAffected({})).toEqual({ + revision: 2, + componentIds: [], + }); + }); + }); +}); diff --git a/code/core/src/shared/open-service/services/module-graph/server.ts b/code/core/src/shared/open-service/services/module-graph/server.ts new file mode 100644 index 000000000000..f945abc983fb --- /dev/null +++ b/code/core/src/shared/open-service/services/module-graph/server.ts @@ -0,0 +1,81 @@ +import { join, normalize } from 'pathe'; + +import { getComponentIdFromEntry } from '../../../../common/utils/component-id.ts'; +import type { StoryIndex } from '../../../../types/modules/indexer.ts'; +import { registerService } from '../../service-registration.ts'; +import { moduleGraphServiceDef } from './definition.ts'; + +export type RegisterModuleGraphServiceOptions = { + /** + * Returns the current story index when the service needs it. Callers should bind this to a + * pre-resolved generator so each call does not re-await generator initialization. + */ + getIndex: () => Promise; + /** + * Working directory used to turn relative `IndexEntry.importPath` values into the absolute, + * normalized paths that the change-detection graph reports. + */ + workingDir: string; +}; + +/** + * Builds an absolute-story-file -> component-ids lookup from a story index. + * + * Story index entries carry a relative `importPath`; the change-detection graph reports absolute, + * normalized paths. This resolves entries to absolute paths so the two can be matched. + */ +function buildStoryFileToComponentIds( + index: StoryIndex, + workingDir: string +): Map> { + const byFile = new Map>(); + for (const entry of Object.values(index.entries)) { + const absPath = normalize(join(workingDir, entry.importPath)); + const componentId = getComponentIdFromEntry(entry); + const set = byFile.get(absPath) ?? new Set(); + set.add(componentId); + byFile.set(absPath, set); + } + return byFile; +} + +/** + * Registers the `core/module-graph` open service against the process-global registry. + * + * The `resolveAffectedComponents` command maps a batch of affected story files (produced by the + * change-detection graph) to the distinct component ids they back, records them as the latest + * invalidation, and returns them so the composition root can forward them to `core/docgen`. + */ +export function registerModuleGraphService(options: RegisterModuleGraphServiceOptions) { + return registerService(moduleGraphServiceDef, { + commands: { + resolveAffectedComponents: { + handler: async (input, ctx) => { + const index = await options.getIndex(); + const byFile = buildStoryFileToComponentIds(index, options.workingDir); + + const componentIds = new Set(); + for (const storyFile of input.storyFiles) { + const ids = byFile.get(normalize(storyFile)); + if (ids) { + for (const id of ids) { + componentIds.add(id); + } + } + } + + const result = { + revision: ctx.self.state.lastAffected.revision + 1, + componentIds: Array.from(componentIds), + }; + + ctx.self.setState((draft) => { + draft.lastAffected = result; + }); + + return result; + }, + }, + }, + }); +} diff --git a/code/core/src/shared/open-service/services/module-graph/types.ts b/code/core/src/shared/open-service/services/module-graph/types.ts new file mode 100644 index 000000000000..1a3e10e9e2a2 --- /dev/null +++ b/code/core/src/shared/open-service/services/module-graph/types.ts @@ -0,0 +1,16 @@ +/** + * Latest invalidation recorded by `core/module-graph`. + * + * `revision` is a monotonically increasing counter that lets subscribers detect a new invalidation + * even when the affected component set is unchanged. `componentIds` is the set of component ids + * whose source (story file or a transitively-imported module) changed in the last reported batch. + */ +export interface ModuleGraphInvalidation { + revision: number; + componentIds: string[]; +} + +export type ModuleGraphServiceState = { + /** The most recent invalidation. `revision` starts at 0 with an empty component set. */ + lastAffected: ModuleGraphInvalidation; +}; From eefc9d03b2aabd75fefc852ec4f07eac83a1bcad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 31 May 2026 12:21:54 +0000 Subject: [PATCH 2/3] open-service: use 'state' (not 'draft') in module-graph setState callback Aligns the new module-graph service with the setState arg rename from the base branch (draft -> state). Co-authored-by: Jeppe Reinhold --- .../src/shared/open-service/services/module-graph/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/core/src/shared/open-service/services/module-graph/server.ts b/code/core/src/shared/open-service/services/module-graph/server.ts index f945abc983fb..8534219efe7d 100644 --- a/code/core/src/shared/open-service/services/module-graph/server.ts +++ b/code/core/src/shared/open-service/services/module-graph/server.ts @@ -69,8 +69,8 @@ export function registerModuleGraphService(options: RegisterModuleGraphServiceOp componentIds: Array.from(componentIds), }; - ctx.self.setState((draft) => { - draft.lastAffected = result; + ctx.self.setState((state) => { + state.lastAffected = result; }); return result; From 2e0f7b243b0a92fbfcae9dac9462eb50c7c6ab9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 31 May 2026 20:18:15 +0000 Subject: [PATCH 3/3] refactor(docgen): connect docgen to module-graph via open-service subscription Replace the dev-server orchestration (which called module-graph then docgen) with an open-service-native link: docgen subscribes to core/module-graph's getLastAffected query and re-extracts affected components on change. The dev server now only feeds the module graph (the one imperative edge from the non-open-service change detector); it no longer references docgen. The module-graph revision (bumped on every change) ensures repeated changes to the same component still emit distinct values, so subscription value-dedup does not swallow a repeat change. This is an explicit subscription because re-extraction is async and the runtime fires a query's load only once (it does not re-run load on a tracked-dependency change). A future reactive-load primitive would let getDocgen depend on the revision directly and drop this wiring. Co-authored-by: Jeppe Reinhold --- code/core/src/core-server/dev-server.ts | 27 ++--- .../src/core-server/presets/common-preset.ts | 18 ++- .../docgen/invalidation.integration.test.ts | 112 +++++++++++------- .../open-service/services/docgen/server.ts | 31 +++++ 4 files changed, 123 insertions(+), 65 deletions(-) diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index b289f655cfe1..d51488f8820b 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -8,7 +8,6 @@ import compression from '@polka/compression'; import polka from 'polka'; import { getService } from '../shared/open-service/server.ts'; -import type { docgenServiceDef } from '../shared/open-service/services/docgen/definition.ts'; import type { moduleGraphServiceDef } from '../shared/open-service/services/module-graph/definition.ts'; import { isTelemetryModuleEnabled, telemetry } from '../telemetry/index.ts'; import type { ChangeDetectionAdapter } from './change-detection/index.ts'; @@ -57,28 +56,24 @@ export async function storybookDevServer( const experimentalDocgenServer = !!features?.experimentalDocgenServer && !options.ignorePreview; /** - * Re-extracts docgen for components affected by a source change. + * Feeds affected story files into the `core/module-graph` service. * - * Translates the change-detection graph's affected story files into component ids via the - * `core/module-graph` service, then asks `core/docgen` to re-extract the ones already in state - * (re-extract-if-present). Best-effort: failures are logged at debug and never break the watcher. + * This is the one imperative edge from the (non-open-service) change detector into the service + * world: it records which components changed into module-graph state. The docgen service reacts + * to that state change through an open-service subscription wired in the `services` preset — the + * dev server intentionally knows nothing about docgen here. Best-effort: failures are logged at + * debug and never break the watcher. */ - async function invalidateDocgenForStoryFiles(affectedStoryFiles: string[]): Promise { + async function recordAffectedStoryFiles(affectedStoryFiles: string[]): Promise { if (affectedStoryFiles.length === 0) { return; } try { const moduleGraph = getService('core/module-graph'); - const docgen = getService('core/docgen'); - const { componentIds } = await moduleGraph.commands.resolveAffectedComponents({ - storyFiles: affectedStoryFiles, - }); - if (componentIds.length > 0) { - await docgen.commands.handleSourceChange({ componentIds }); - } + await moduleGraph.commands.resolveAffectedComponents({ storyFiles: affectedStoryFiles }); } catch (error) { logger.debug( - `Docgen invalidation skipped: ${error instanceof Error ? error.message : String(error)}` + `Module-graph invalidation skipped: ${error instanceof Error ? error.message : String(error)}` ); } } @@ -90,10 +85,10 @@ export async function storybookDevServer( presets: options.presets, // Only publish "review changes" sidebar statuses when the change-detection feature is on. publishStatuses: !!features.changeDetection, - // When docgen is enabled, the graph drives docgen re-extraction on file changes. + // When docgen is enabled, feed file changes into the module-graph service; docgen reacts to it. onInvalidate: experimentalDocgenServer ? (affectedStoryFiles) => { - void invalidateDocgenForStoryFiles(affectedStoryFiles); + void recordAffectedStoryFiles(affectedStoryFiles); } : undefined, }); diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index efcca01c34d9..a7898288b237 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -26,7 +26,10 @@ import type { StorybookConfigRaw, } from 'storybook/internal/types'; -import { registerDocgenService } from '../../shared/open-service/services/docgen/server.ts'; +import { + connectDocgenToModuleGraph, + registerDocgenService, +} from '../../shared/open-service/services/docgen/server.ts'; import { registerModuleGraphService } from '../../shared/open-service/services/module-graph/server.ts'; import { isAbsolute, join } from 'pathe'; @@ -347,17 +350,22 @@ export const services = async (_value: void, options: Options): Promise => ); // The module-graph service translates change-detection's affected story files into component - // ids so the docgen service can re-extract only the components that actually changed. It owns - // no file watching itself; it is fed by the change-detection graph (wired in dev-server). - registerModuleGraphService({ + // ids. It owns no file watching itself; it is fed by the change-detection graph (the dev server + // calls its `resolveAffectedComponents` command on file changes). + const moduleGraph = registerModuleGraphService({ getIndex: () => generator.getIndex(), workingDir: process.cwd(), }); - registerDocgenService({ + const docgen = registerDocgenService({ getIndex: () => generator.getIndex(), provider, }); + + // Connect the two services with an open-service subscription: docgen re-extracts whenever the + // module graph reports an invalidation. The dev server only feeds the module graph; it never + // talks to docgen directly. + connectDocgenToModuleGraph(docgen, moduleGraph); } }; diff --git a/code/core/src/shared/open-service/services/docgen/invalidation.integration.test.ts b/code/core/src/shared/open-service/services/docgen/invalidation.integration.test.ts index 7ecaafebddc0..e220d5cd8fad 100644 --- a/code/core/src/shared/open-service/services/docgen/invalidation.integration.test.ts +++ b/code/core/src/shared/open-service/services/docgen/invalidation.integration.test.ts @@ -3,11 +3,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { join, normalize } from 'pathe'; import type { IndexEntry, StoryIndex } from '../../../../types/modules/indexer.ts'; -import { clearRegistry, getService } from '../../server.ts'; -import type { docgenServiceDef } from './definition.ts'; -import { registerDocgenService } from './server.ts'; +import { clearRegistry } from '../../server.ts'; +import { connectDocgenToModuleGraph, registerDocgenService } from './server.ts'; import type { DocgenProvider } from './types.ts'; -import type { moduleGraphServiceDef } from '../module-graph/definition.ts'; import { registerModuleGraphService } from '../module-graph/server.ts'; afterEach(() => { @@ -40,22 +38,24 @@ function absStoryFile(fileBase: string): string { } /** - * Mirrors the dev-server glue (`onInvalidate` -> module-graph -> docgen) against the real - * process-global registry, proving the cross-service composition works end-to-end on the server. + * Sets up the same wiring the `services` preset does: register both services and connect docgen to + * the module graph via the open-service subscription. The change detector is simulated by calling + * the module graph's `resolveAffectedComponents` command directly (in production the dev server + * does this on file-change events). */ -async function invalidateDocgenForStoryFiles(affectedStoryFiles: string[]): Promise { - const moduleGraph = getService('core/module-graph'); - const docgen = getService('core/docgen'); - const { componentIds } = await moduleGraph.commands.resolveAffectedComponents({ - storyFiles: affectedStoryFiles, +function setup(provider: DocgenProvider) { + const entries = [makeStoryEntry('button--primary', 'button')]; + const moduleGraph = registerModuleGraphService({ + getIndex: makeGetIndex(entries), + workingDir: WORKING_DIR, }); - if (componentIds.length > 0) { - await docgen.commands.handleSourceChange({ componentIds }); - } + const docgen = registerDocgenService({ getIndex: makeGetIndex(entries), provider }); + const unsubscribe = connectDocgenToModuleGraph(docgen, moduleGraph); + return { moduleGraph, docgen, unsubscribe }; } -describe('docgen invalidation (server-side end-to-end)', () => { - it('re-extracts and notifies subscribers when an affected story file changes', async () => { +describe('docgen invalidation (server-side, open-service native wiring)', () => { + it('re-extracts and notifies subscribers when the module graph reports a change', async () => { // A content-sensitive provider whose output changes when the underlying source changes. const descriptionByImportPath: Record = { './button.stories.tsx': 'v1', @@ -67,37 +67,34 @@ describe('docgen invalidation (server-side end-to-end)', () => { props: [], }); - registerModuleGraphService({ - getIndex: makeGetIndex([makeStoryEntry('button--primary', 'button')]), - workingDir: WORKING_DIR, - }); - const docgen = registerDocgenService({ - getIndex: makeGetIndex([makeStoryEntry('button--primary', 'button')]), - provider, - }); + const { moduleGraph, docgen, unsubscribe } = setup(provider); - // User navigates to the component: docgen is extracted and cached. + // User navigates to the component: docgen is extracted and cached ("present"). await docgen.queries.getDocgen.loaded({ componentId: 'button' }); expect(docgen.queries.getDocgen({ componentId: 'button' })).toMatchObject({ description: 'v1', }); const emitted: Array<{ description: string } | undefined> = []; - const unsubscribe = docgen.queries.getDocgen.subscribe({ componentId: 'button' }, (value) => { + const unsubDocgen = docgen.queries.getDocgen.subscribe({ componentId: 'button' }, (value) => { emitted.push(value as { description: string } | undefined); }); await vi.waitFor(() => expect(emitted.at(-1)).toMatchObject({ description: 'v1' })); - // The component's source changes on disk. + // The component's source changes; the change detector feeds the module graph. No explicit + // docgen call here — docgen reacts through its subscription to the module graph. descriptionByImportPath['./button.stories.tsx'] = 'v2'; - await invalidateDocgenForStoryFiles([absStoryFile('button')]); + await moduleGraph.commands.resolveAffectedComponents({ storyFiles: [absStoryFile('button')] }); // Future reads are fresh (no stale data) and the live subscriber was notified (no flash). - expect(docgen.queries.getDocgen({ componentId: 'button' })).toMatchObject({ - description: 'v2', - }); + await vi.waitFor(() => + expect(docgen.queries.getDocgen({ componentId: 'button' })).toMatchObject({ + description: 'v2', + }) + ); await vi.waitFor(() => expect(emitted.at(-1)).toMatchObject({ description: 'v2' })); + unsubDocgen(); unsubscribe(); }); @@ -109,22 +106,49 @@ describe('docgen invalidation (server-side end-to-end)', () => { props: [], })); - registerModuleGraphService({ - getIndex: makeGetIndex([makeStoryEntry('button--primary', 'button')]), - workingDir: WORKING_DIR, + const { moduleGraph, unsubscribe } = setup(provider); + + await moduleGraph.commands.resolveAffectedComponents({ storyFiles: [absStoryFile('button')] }); + // Let the docgen subscription run handleSourceChange. + await vi.waitFor(() => + expect(moduleGraph.queries.getLastAffected({}).componentIds).toEqual(['button']) + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Never extracted -> absent -> provider must not have been invoked by the invalidation. + expect(provider).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('re-emits on repeated changes to the same component (revision defeats value-dedup)', async () => { + let version = 1; + const provider: DocgenProvider = async () => ({ + componentId: 'button', + name: 'Button', + description: `v${version}`, + props: [], }); - registerDocgenService({ - getIndex: makeGetIndex([makeStoryEntry('button--primary', 'button')]), - provider, + + const { moduleGraph, docgen, unsubscribe } = setup(provider); + + await docgen.queries.getDocgen.loaded({ componentId: 'button' }); + + const emitted: string[] = []; + const unsubDocgen = docgen.queries.getDocgen.subscribe({ componentId: 'button' }, (value) => { + emitted.push((value as { description: string }).description); }); + await vi.waitFor(() => expect(emitted.at(-1)).toBe('v1')); - await invalidateDocgenForStoryFiles([absStoryFile('button')]); + version = 2; + await moduleGraph.commands.resolveAffectedComponents({ storyFiles: [absStoryFile('button')] }); + await vi.waitFor(() => expect(emitted.at(-1)).toBe('v2')); - expect(provider).not.toHaveBeenCalled(); - expect( - getService('core/docgen').queries.getDocgen({ - componentId: 'button', - }) - ).toBeUndefined(); + version = 3; + await moduleGraph.commands.resolveAffectedComponents({ storyFiles: [absStoryFile('button')] }); + await vi.waitFor(() => expect(emitted.at(-1)).toBe('v3')); + + unsubDocgen(); + unsubscribe(); }); }); diff --git a/code/core/src/shared/open-service/services/docgen/server.ts b/code/core/src/shared/open-service/services/docgen/server.ts index a37b0a7a3aac..b85f859c2054 100644 --- a/code/core/src/shared/open-service/services/docgen/server.ts +++ b/code/core/src/shared/open-service/services/docgen/server.ts @@ -3,6 +3,8 @@ import { getComponentIdFromEntry } from '../../../../common/utils/component-id.t import { OpenServiceDocgenMissingComponentError } from '../../../../server-errors.ts'; import type { StoryIndex } from '../../../../types/modules/indexer.ts'; import { registerService } from '../../service-registration.ts'; +import type { ServiceInstanceOf } from '../../types.ts'; +import type { moduleGraphServiceDef } from '../module-graph/definition.ts'; import { docgenServiceDef } from './definition.ts'; import type { DocgenProvider } from './types.ts'; @@ -103,3 +105,32 @@ export function registerDocgenService(options: RegisterDocgenServiceOptions) { }, }); } + +/** + * Connects `core/docgen` to `core/module-graph` so docgen re-extracts when source files change. + * + * This is the open-service-native link between the two services: docgen subscribes to the module + * graph's latest invalidation and re-extracts the affected components (re-extract-if-present, via + * `handleSourceChange`). Because the module graph bumps a monotonic `revision` on every change, two + * consecutive changes that affect the same components still emit distinct values, so value-dedup on + * the subscription never swallows a repeat change. + * + * Note: this is an explicit subscription rather than a pure reactive read because re-extraction is + * async/side-effecting and the open-service runtime fires a query's `load` only once (it does not + * re-run `load` when a tracked dependency changes). A future "reactive load" runtime primitive + * would let `getDocgen` depend on the revision directly and drop this wiring entirely. + * + * Returns an unsubscribe function; the caller (the `services` preset) keeps the subscription alive + * for the lifetime of the process. + */ +export function connectDocgenToModuleGraph( + docgen: ServiceInstanceOf, + moduleGraph: ServiceInstanceOf +): () => void { + return moduleGraph.queries.getLastAffected.subscribe({}, (affected) => { + if (affected.componentIds.length === 0) { + return; + } + void docgen.commands.handleSourceChange({ componentIds: affected.componentIds }); + }); +}