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..d51488f8820b 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -7,6 +7,8 @@ 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 { 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 +50,47 @@ 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; + + /** + * Feeds affected story files into the `core/module-graph` service. + * + * 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 recordAffectedStoryFiles(affectedStoryFiles: string[]): Promise { + if (affectedStoryFiles.length === 0) { + return; + } + try { + const moduleGraph = getService('core/module-graph'); + await moduleGraph.commands.resolveAffectedComponents({ storyFiles: affectedStoryFiles }); + } catch (error) { + logger.debug( + `Module-graph 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, feed file changes into the module-graph service; docgen reacts to it. + onInvalidate: experimentalDocgenServer + ? (affectedStoryFiles) => { + void recordAffectedStoryFiles(affectedStoryFiles); + } + : undefined, }); app.use(compression({ level: 1 })); @@ -124,7 +162,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 +195,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..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,11 @@ 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'; import * as pathe from 'pathe'; @@ -345,10 +349,23 @@ export const services = async (_value: void, options: Options): Promise => async () => undefined ); - registerDocgenService({ + // The module-graph service translates change-detection's affected story files into component + // 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(), + }); + + 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/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..e220d5cd8fad --- /dev/null +++ b/code/core/src/shared/open-service/services/docgen/invalidation.integration.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { join, normalize } from 'pathe'; + +import type { IndexEntry, StoryIndex } from '../../../../types/modules/indexer.ts'; +import { clearRegistry } from '../../server.ts'; +import { connectDocgenToModuleGraph, registerDocgenService } from './server.ts'; +import type { DocgenProvider } from './types.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`)); +} + +/** + * 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). + */ +function setup(provider: DocgenProvider) { + const entries = [makeStoryEntry('button--primary', 'button')]; + const moduleGraph = registerModuleGraphService({ + getIndex: makeGetIndex(entries), + workingDir: WORKING_DIR, + }); + const docgen = registerDocgenService({ getIndex: makeGetIndex(entries), provider }); + const unsubscribe = connectDocgenToModuleGraph(docgen, moduleGraph); + return { moduleGraph, docgen, unsubscribe }; +} + +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', + }; + const provider: DocgenProvider = async ({ importPath }) => ({ + componentId: 'button', + name: 'Button', + description: descriptionByImportPath[importPath] ?? 'unknown', + props: [], + }); + + const { moduleGraph, docgen, unsubscribe } = setup(provider); + + // 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 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; 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 moduleGraph.commands.resolveAffectedComponents({ storyFiles: [absStoryFile('button')] }); + + // Future reads are fresh (no stale data) and the live subscriber was notified (no flash). + await vi.waitFor(() => + expect(docgen.queries.getDocgen({ componentId: 'button' })).toMatchObject({ + description: 'v2', + }) + ); + await vi.waitFor(() => expect(emitted.at(-1)).toMatchObject({ description: 'v2' })); + + unsubDocgen(); + 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: [], + })); + + 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: [], + }); + + 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')); + + version = 2; + await moduleGraph.commands.resolveAffectedComponents({ storyFiles: [absStoryFile('button')] }); + await vi.waitFor(() => expect(emitted.at(-1)).toBe('v2')); + + 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.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 04a7b25e8931..b85f859c2054 100644 --- a/code/core/src/shared/open-service/services/docgen/server.ts +++ b/code/core/src/shared/open-service/services/docgen/server.ts @@ -1,7 +1,10 @@ +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'; 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'; @@ -72,6 +75,62 @@ 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; + }, + }, }, }); } + +/** + * 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 }); + }); +} 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..8534219efe7d --- /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((state) => { + state.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; +};