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..26fdc5e4eb91 100644 --- a/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts +++ b/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts @@ -9,25 +9,28 @@ import { UNIVERSAL_STATUS_STORE_OPTIONS, } from '../../shared/status-store/index.ts'; import { MockUniversalStore } from '../../shared/universal-store/mock.ts'; -import type { ChangeDetectionAdapter, FileChangeEvent } from './adapters/index.ts'; -import { getChangeDetectionReadiness, internal_resetChangeDetectionReadiness } from './index.ts'; -import { ChangeDetectionFailureError, ChangeDetectionUnavailableError } from './errors.ts'; +import * as oxcParser from 'storybook/internal/oxc-parser'; + +import { + buildReverseIndex, + createDeferred, + createMockAdapter, + createStoryIndex, + createWiredChangeDetection, + installDependencyGraphMocks, +} from './change-detection.test-helpers.ts'; import { buildIndexBaselineStatuses, ChangeDetectionService, mergeChangeDetectionStatuses, mergeStatusValues, } from './ChangeDetectionService.ts'; -import * as oxcParser from 'storybook/internal/oxc-parser'; -import { - ChangeDetectionResolverFactory, - DependencyGraphBuilder, - IncrementalPatcher, - ReverseIndexImpl, -} from './dependency-graph/index.ts'; +import { ChangeDetectionFailureError, ChangeDetectionUnavailableError } from './errors.ts'; +import { getChangeDetectionReadiness, internal_resetChangeDetectionReadiness } from './index.ts'; import type { GitDiffResult } from './GitDiffProvider.ts'; import { GitDiffProvider } from './GitDiffProvider.ts'; import type { IndexBaselineService } from './IndexBaselineService.ts'; +import type { StoryDependencyGraphService } from './StoryDependencyGraphService.ts'; vi.mock('storybook/internal/node-logger', { spy: true }); vi.mock('./dependency-graph/index.ts', async (importOriginal) => { @@ -44,85 +47,6 @@ vi.mock('./dependency-graph/index.ts', async (importOriginal) => { }; }); -function createDeferred() { - let resolve!: (value: T) => void; - - return { - promise: new Promise((fulfill) => { - resolve = fulfill; - }), - resolve, - }; -} - -function createStoryIndex( - entries: Array<{ storyId: string; importPath: string; title?: string; name?: string }> -): StoryIndex { - return { - v: 5, - entries: Object.fromEntries( - entries.map(({ storyId, importPath, title = 'Story', name = 'Default' }) => [ - storyId, - { - id: storyId, - type: 'story', - subtype: 'story', - title, - name, - importPath, - }, - ]) - ), - }; -} - -interface MockAdapterHandle { - adapter: ChangeDetectionAdapter; - emitFileChange: (event: FileChangeEvent) => void; - emitStartupFailure: (event: { reason: string; error?: Error }) => void; - hasFileChangeSubscriber: () => boolean; - hasStartupFailureSubscriber: () => boolean; -} - -function createMockAdapter(opts?: { - resolveConfig?: { projectRoot?: string }; - withoutStartupFailure?: boolean; -}): MockAdapterHandle { - const fileHandlers = new Set<(e: FileChangeEvent) => void>(); - const startupHandlers = new Set<(e: { reason: string; error?: Error }) => void>(); - - const adapter: ChangeDetectionAdapter = { - async getResolveConfig() { - return { - projectRoot: opts?.resolveConfig?.projectRoot ?? '/repo', - }; - }, - onFileChange(handler) { - fileHandlers.add(handler); - return () => fileHandlers.delete(handler); - }, - }; - - if (!opts?.withoutStartupFailure) { - adapter.onStartupFailure = (handler) => { - startupHandlers.add(handler); - return () => startupHandlers.delete(handler); - }; - } - - return { - adapter, - emitFileChange: (event) => { - fileHandlers.forEach((h) => h(event)); - }, - emitStartupFailure: (event) => { - startupHandlers.forEach((h) => h(event)); - }, - hasFileChangeSubscriber: () => fileHandlers.size > 0, - hasStartupFailureSubscriber: () => startupHandlers.size > 0, - }; -} - class MockGitDiffProvider extends GitDiffProvider { readonly getChangedFilesMock = vi.fn( async (): Promise => ({ @@ -197,50 +121,6 @@ function createStatus(value: Status['value'], data?: Status['data']): Status { }; } -/** - * Build a ReverseIndexImpl populated with the given (dep -> story -> depth) entries. - * Used by tests to control what `reverseIndex.lookup(changedFile)` returns. - */ -function buildReverseIndex(edges: Iterable): ReverseIndexImpl { - const reverseIndex = new ReverseIndexImpl(); - for (const [dep, story, depth] of edges) { - reverseIndex.record(dep, story, depth); - } - return reverseIndex; -} - -/** - * Stub the dependency-graph constructors so the service uses an in-test - * ReverseIndexImpl + an inert IncrementalPatcher. - * - * Note: `vi.mock` replaces these exports with plain `vi.fn()` constructors. When the - * service calls `new Ctor(...)` we must return objects via `mockImplementation` — - * but vitest invokes the impl with `Reflect.construct` on `new`, so arrow-function - * impls throw "is not a constructor". `function () { return obj; }` works because - * regular functions support `[[Construct]]`. - */ -function installDependencyGraphMocks(reverseIndex: ReverseIndexImpl): { - patchSpy: ReturnType; - buildSpy: ReturnType; -} { - const patchSpy = vi.fn(async () => undefined); - const buildSpy = vi.fn(async () => ({ reverseIndex, graph: new Map() })); - - vi.mocked(ChangeDetectionResolverFactory).mockImplementation(function () { - return { - resolve: vi.fn(async () => null), - } as unknown as ChangeDetectionResolverFactory; - } as unknown as new () => ChangeDetectionResolverFactory); - vi.mocked(DependencyGraphBuilder).mockImplementation(function () { - return { build: buildSpy } as unknown as DependencyGraphBuilder; - } as unknown as new () => DependencyGraphBuilder); - vi.mocked(IncrementalPatcher).mockImplementation(function () { - return { patch: patchSpy } as unknown as IncrementalPatcher; - } as unknown as new () => IncrementalPatcher); - - return { patchSpy, buildSpy }; -} - describe('ChangeDetectionService', () => { const workingDir = '/repo'; @@ -284,7 +164,7 @@ describe('ChangeDetectionService', () => { }); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -294,6 +174,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -320,6 +201,7 @@ describe('ChangeDetectionService', () => { }, }); await service.dispose(); + await graph.dispose(); }); it('edits a non-story dep at distance 1 from one story and distance 2 from another -> nearest is modified, farther is affected', async () => { @@ -354,7 +236,7 @@ describe('ChangeDetectionService', () => { }); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -364,6 +246,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -375,6 +258,7 @@ describe('ChangeDetectionService', () => { 'status-value:affected' ); await service.dispose(); + await graph.dispose(); }); it('edits a non-story dep at equal distance from two stories -> both stories tie and are both modified', async () => { @@ -400,7 +284,7 @@ describe('ChangeDetectionService', () => { }); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -410,6 +294,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -421,6 +306,7 @@ describe('ChangeDetectionService', () => { 'status-value:modified' ); await service.dispose(); + await graph.dispose(); }); it('edits a non-story file with no story importers -> reverse-index lookup is empty -> no status emitted', async () => { @@ -444,7 +330,7 @@ describe('ChangeDetectionService', () => { }); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -454,12 +340,14 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({}); expect(await getChangeDetectionReadiness()).toEqual({ status: 'ready' }); await service.dispose(); + await graph.dispose(); }); // ------------------------------------------------------------------ @@ -496,7 +384,7 @@ describe('ChangeDetectionService', () => { onGitStateChange = callback; }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -507,6 +395,7 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -530,6 +419,7 @@ describe('ChangeDetectionService', () => { 'new-button--primary': {}, }); await service.dispose(); + await graph.dispose(); }); it('replaces prior scan status data instead of cumulatively merging with store state', async () => { @@ -562,7 +452,7 @@ describe('ChangeDetectionService', () => { onGitStateChange = callback; }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -573,6 +463,7 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -605,6 +496,7 @@ describe('ChangeDetectionService', () => { }, }); await service.dispose(); + await graph.dispose(); }); it('rescans on git state changes using the normal debounce', async () => { @@ -631,7 +523,7 @@ describe('ChangeDetectionService', () => { }); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -642,6 +534,7 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -658,6 +551,7 @@ describe('ChangeDetectionService', () => { expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(2); await service.dispose(); + await graph.dispose(); }); it('debounces consecutive file-change events into a single scan', async () => { @@ -672,7 +566,7 @@ describe('ChangeDetectionService', () => { }); const gitDiffProvider = createMockGitDiffProvider(); const { adapter, emitFileChange } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -683,6 +577,7 @@ describe('ChangeDetectionService', () => { debounceMs: 50, }); + graph.start(adapter); service.start(adapter, true); // First scan from initial start — debounce 0 runs synchronously. await vi.runAllTimersAsync(); @@ -703,6 +598,7 @@ describe('ChangeDetectionService', () => { expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(2); await service.dispose(); + await graph.dispose(); }); it('does not subscribe to git state when change detection is disabled', async () => { @@ -712,7 +608,7 @@ describe('ChangeDetectionService', () => { }); const gitDiffProvider = createMockGitDiffProvider(); const { adapter, hasFileChangeSubscriber } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn(), } as never), @@ -731,14 +627,45 @@ describe('ChangeDetectionService', () => { reason: 'disabled', }); await service.dispose(); + await graph.dispose(); }); - it('logs unavailability when the builder does not provide an adapter', async () => { + it('acts as a consumer when an external graph is injected', async () => { + const graph = { + start: vi.fn(), + dispose: vi.fn(async () => undefined), + whenSettled: vi.fn(async () => undefined), + hasGraph: vi.fn(() => false), + lookup: vi.fn(() => new Map()), + } as unknown as StoryDependencyGraphService; const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', }); const service = new ChangeDetectionService({ + graph, + storyIndexGeneratorPromise: Promise.resolve({ + getIndex: vi.fn(), + } as never), + statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), + gitDiffProvider: createMockGitDiffProvider(), + indexBaselineService: createMockStoryIndexBaselineService(), + workingDir, + }); + + service.start(undefined, false); + await service.dispose(); + + expect(graph.start).not.toHaveBeenCalled(); + expect(graph.dispose).not.toHaveBeenCalled(); + }); + + it('logs unavailability when the builder does not provide an adapter', async () => { + const { getStatusStoreByTypeId } = createStatusStore({ + universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), + environment: 'server', + }); + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn(), } as never), @@ -758,6 +685,7 @@ describe('ChangeDetectionService', () => { reason: 'builder does not support change detection', }); await service.dispose(); + await graph.dispose(); }); it('resolves readiness as unavailable when the adapter reports a startup failure', async () => { @@ -773,7 +701,7 @@ describe('ChangeDetectionService', () => { provider.getChangedFilesMock.mockImplementation(() => gitDeferred.promise); }); const { adapter, emitStartupFailure } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), } as never), @@ -783,6 +711,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); // Let startInternal subscribe before emitting the failure (initial scan parked on git). await vi.runAllTimersAsync(); @@ -799,6 +728,7 @@ describe('ChangeDetectionService', () => { // Unblock the parked git call so dispose can drain. gitDeferred.resolve({ changed: new Set(), new: new Set() }); await service.dispose(); + await graph.dispose(); }); it('resolves readiness as error when the eager build throws', async () => { @@ -812,7 +742,7 @@ describe('ChangeDetectionService', () => { environment: 'server', }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), } as never), @@ -822,6 +752,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -833,6 +764,7 @@ describe('ChangeDetectionService', () => { error: expect.objectContaining({ message: 'graph build blew up' }), }); await service.dispose(); + await graph.dispose(); }); it('disposes the pool when startInternal throws', async () => { @@ -850,7 +782,7 @@ describe('ChangeDetectionService', () => { environment: 'server', }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), } as never), @@ -860,6 +792,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -867,6 +800,7 @@ describe('ChangeDetectionService', () => { expect(disposePoolSpy).toHaveBeenCalledTimes(1); await service.dispose(); + await graph.dispose(); }); it('keeps the previous statuses when a live rescan fails', async () => { @@ -895,7 +829,7 @@ describe('ChangeDetectionService', () => { onGitStateChange = callback; }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -906,6 +840,7 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -926,6 +861,7 @@ describe('ChangeDetectionService', () => { }); expect(logger.error).toHaveBeenCalledWith('Change detection failed: scan blew up'); await service.dispose(); + await graph.dispose(); }); it('does not apply scan results or rerun after disposal', async () => { @@ -949,7 +885,7 @@ describe('ChangeDetectionService', () => { provider.getChangedFilesMock.mockImplementation(() => changedFilesDeferred.promise); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -960,9 +896,11 @@ describe('ChangeDetectionService', () => { debounceMs: 0, }); + graph.start(adapter); service.start(adapter, true); await vi.advanceTimersByTimeAsync(0); await service.dispose(); + await graph.dispose(); changedFilesDeferred.resolve({ changed: new Set(['src/Button.stories.tsx']), @@ -993,7 +931,7 @@ describe('ChangeDetectionService', () => { ); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), } as never), @@ -1004,6 +942,7 @@ describe('ChangeDetectionService', () => { debounceMs: 0, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -1015,53 +954,7 @@ describe('ChangeDetectionService', () => { error: expect.any(ChangeDetectionUnavailableError), }); await service.dispose(); - }); - - it('serialises concurrent file-change events through the patch chain', async () => { - const reverseIndex = buildReverseIndex([]); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); - - let activePatches = 0; - let maxConcurrent = 0; - patchSpy.mockImplementation(async () => { - activePatches += 1; - maxConcurrent = Math.max(maxConcurrent, activePatches); - // Force an actual await so two concurrent calls would visibly interleave. - await new Promise((resolve) => setImmediate(resolve)); - activePatches -= 1; - }); - - 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 { 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, - }); - - service.start(adapter, true); - await vi.runAllTimersAsync(); - - emitFileChange({ kind: 'change', path: '/repo/src/Button.tsx' }); - emitFileChange({ kind: 'change', path: '/repo/src/Other.tsx' }); - emitFileChange({ kind: 'unlink', path: '/repo/src/Stale.tsx' }); - await vi.runAllTimersAsync(); - - expect(patchSpy).toHaveBeenCalledTimes(3); - expect(maxConcurrent).toBe(1); - - await service.dispose(); + await graph.dispose(); }); it('scan waits for the current patch to settle before reading reverseIndex', async () => { @@ -1098,7 +991,7 @@ describe('ChangeDetectionService', () => { triggerGitStateChange = callback; }); const { adapter, emitFileChange } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -1109,6 +1002,7 @@ describe('ChangeDetectionService', () => { debounceMs: 0, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -1127,54 +1021,7 @@ describe('ChangeDetectionService', () => { ); await service.dispose(); - }); - - it('does not patch file-change events emitted before the adapter subscription is installed (pre-build events are dropped)', async () => { - const reverseIndex = buildReverseIndex([]); - const buildDeferred = createDeferred(); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockImplementation(async () => { - await buildDeferred.promise; - return { 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 { 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, - }); - - service.start(adapter, true); - // Allow startInternal to reach the build step and start awaiting it. - await Promise.resolve(); - await Promise.resolve(); - - // The service subscribes to file-change events strictly after the eager build resolves, - // so anything emitted by the adapter before then has nowhere to land. Assert no patch - // calls have happened yet. - expect(patchSpy).not.toHaveBeenCalled(); - - buildDeferred.resolve(); - await vi.runAllTimersAsync(); - - // Now the adapter has subscribers — file events go through the patcher. - emitFileChange({ kind: 'change', path: '/repo/src/Button.tsx' }); - await vi.runAllTimersAsync(); - expect(patchSpy).toHaveBeenCalledTimes(1); - - await service.dispose(); + await graph.dispose(); }); it('calls gitDiffProvider.dispose() on service dispose when a git watcher was installed', async () => { @@ -1187,7 +1034,7 @@ describe('ChangeDetectionService', () => { provider.onGitStateChangeMock.mockImplementation(() => undefined); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), } as never), @@ -1200,10 +1047,12 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); await service.dispose(); + await graph.dispose(); expect(gitDiffProvider.disposeMock).toHaveBeenCalledTimes(1); }); @@ -1215,7 +1064,7 @@ describe('ChangeDetectionService', () => { universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', }); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn(), } as never), @@ -1229,210 +1078,47 @@ describe('ChangeDetectionService', () => { service.start(undefined, false); // Should not throw and should not attempt to call dispose on an unconstructed provider. await expect(service.dispose()).resolves.toBeUndefined(); + await graph.dispose(); }); - it('replays add/unlink through the patcher when onStoryIndexInvalidated reveals new/removed stories', async () => { + it('rescans the working tree when the story index is invalidated', async () => { + // Graph-side reconciliation (replaying add/unlink, the refreshInFlight guard) is covered by + // StoryDependencyGraphService.test.ts; here we assert the status side of the seam: an index + // invalidation re-runs the git-diff scan. const reverseIndex = buildReverseIndex([]); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + installDependencyGraphMocks(reverseIndex); - const initialIndex = createStoryIndex([ - { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, - ]); - const updatedIndex = createStoryIndex([ + const storyIndex = createStoryIndex([ { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, - { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, ]); - const getIndex = vi - .fn() - .mockResolvedValueOnce(initialIndex) - .mockResolvedValueOnce(initialIndex) - .mockResolvedValue(updatedIndex); - const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', }); + const gitDiffProvider = createMockGitDiffProvider(); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ - storyIndexGeneratorPromise: Promise.resolve({ getIndex } as never), - statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider: createMockGitDiffProvider(), - indexBaselineService: createMockStoryIndexBaselineService(), - workingDir, - }); - - service.start(adapter, true); - await vi.runAllTimersAsync(); - expect(patchSpy).not.toHaveBeenCalled(); - - service.onStoryIndexInvalidated(); - await vi.runAllTimersAsync(); - - expect(patchSpy).toHaveBeenCalledWith({ - kind: 'add', - path: '/repo/src/B.stories.tsx', - }); - - await service.dispose(); - }); - - it('file events emitted during the eager build are buffered and applied after build resolves', async () => { - // The service subscribes to file-change events BEFORE awaiting the build. Events arriving - // during the build window should be buffered and drained into patchQueue once the build - // completes — not silently dropped. - const reverseIndex = buildReverseIndex([]); - const buildDeferred = createDeferred(); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockImplementation(async () => { - await buildDeferred.promise; - return { 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 { adapter, emitFileChange } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider: createMockGitDiffProvider(), - indexBaselineService: createMockStoryIndexBaselineService(), - workingDir, - }); - - service.start(adapter, true); - // Allow startInternal to advance past: getResolveConfig, storyIndexGeneratorPromise, - // getIndex, and the DependencyGraphBuilder constructor — reaching the build await. - // Each 'await' in the async function consumes one microtask tick. - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - // Emit a file-change event while the build is still in flight (buffering handler active). - emitFileChange({ kind: 'change', path: '/repo/src/Button.tsx' }); - - // Build has not resolved yet — no patch should have run. - expect(patchSpy).not.toHaveBeenCalled(); - - // Now resolve the build — the buffered event should be drained into patchQueue. - buildDeferred.resolve(); - await vi.runAllTimersAsync(); - - // The buffered event must have been patched exactly once. - expect(patchSpy).toHaveBeenCalledTimes(1); - expect(patchSpy).toHaveBeenCalledWith({ kind: 'change', path: '/repo/src/Button.tsx' }); - - await service.dispose(); - }); - - it('multiple file events buffered during build are all applied in order after build resolves', async () => { - const reverseIndex = buildReverseIndex([]); - const buildDeferred = createDeferred(); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockImplementation(async () => { - await buildDeferred.promise; - return { reverseIndex, graph: new Map() }; - }); - - const storyIndex = createStoryIndex([]); - const { getStatusStoreByTypeId } = createStatusStore({ - universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), - environment: 'server', - }); - 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, - }); - - service.start(adapter, true); - // Advance past all awaits in startInternal before the build step. - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - emitFileChange({ kind: 'change', path: '/repo/src/A.tsx' }); - emitFileChange({ kind: 'change', path: '/repo/src/B.tsx' }); - emitFileChange({ kind: 'unlink', path: '/repo/src/C.tsx' }); - - expect(patchSpy).not.toHaveBeenCalled(); - - buildDeferred.resolve(); - await vi.runAllTimersAsync(); - - expect(patchSpy).toHaveBeenCalledTimes(3); - expect(patchSpy).toHaveBeenNthCalledWith(1, { kind: 'change', path: '/repo/src/A.tsx' }); - expect(patchSpy).toHaveBeenNthCalledWith(2, { kind: 'change', path: '/repo/src/B.tsx' }); - expect(patchSpy).toHaveBeenNthCalledWith(3, { kind: 'unlink', path: '/repo/src/C.tsx' }); - - await service.dispose(); - }); - - it('calling onStoryIndexInvalidated twice rapidly does not enqueue duplicate patches', async () => { - // Two rapid onStoryIndexInvalidated() calls before the first refresh completes both - // compute the same diff from the same storyFiles baseline. The refreshInFlight guard - // ensures only one refresh runs; the second call is a no-op, preventing duplicate patches. - const reverseIndex = buildReverseIndex([]); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); - - const initialIndex = createStoryIndex([ - { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, - ]); - // After invalidation: B is added. - const updatedIndex = createStoryIndex([ - { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, - { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, - ]); - - // getIndex is called: (1) during startInternal (initial), (2+) during refreshStoryFiles. - // Both refresh calls will receive updatedIndex so they compute the same diff. - const getIndex = vi - .fn() - .mockResolvedValueOnce(initialIndex) // startInternal initial read - .mockResolvedValueOnce(initialIndex) // scan's storyIndexGenerator.getIndex() - .mockResolvedValue(updatedIndex); // both refresh calls - - const { getStatusStoreByTypeId } = createStatusStore({ - universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), - environment: 'server', - }); - const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ - storyIndexGeneratorPromise: Promise.resolve({ getIndex } as never), - statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider: createMockGitDiffProvider(), + gitDiffProvider, indexBaselineService: createMockStoryIndexBaselineService(), workingDir, + debounceMs: 10, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); + expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(1); - // Two rapid calls before the first refresh completes. - service.onStoryIndexInvalidated(); - service.onStoryIndexInvalidated(); + service.onGraphChange(); await vi.runAllTimersAsync(); - - // B.stories.tsx should be patched exactly once — not twice. - const addPatches = patchSpy.mock.calls.filter( - ([event]) => event.kind === 'add' && event.path === '/repo/src/B.stories.tsx' - ); - expect(addPatches).toHaveLength(1); + expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(2); await service.dispose(); + await graph.dispose(); }); }); diff --git a/code/core/src/core-server/change-detection/ChangeDetectionService.ts b/code/core/src/core-server/change-detection/ChangeDetectionService.ts index 1458b10080ba..6cbadf9c3c80 100644 --- a/code/core/src/core-server/change-detection/ChangeDetectionService.ts +++ b/code/core/src/core-server/change-detection/ChangeDetectionService.ts @@ -1,35 +1,23 @@ -import { writeFile } from 'node:fs/promises'; - import { join, normalize } from 'pathe'; import { dequal } from 'dequal'; import { logger } from 'storybook/internal/node-logger'; -import { disposeOxcParsePool } from 'storybook/internal/oxc-parser'; -import { getProjectRoot } from 'storybook/internal/common'; import type { - Presets, - StatusValue, - StoryIndex, Status, StatusStoreByTypeId, + StatusValue, + StoryIndex, } from 'storybook/internal/types'; import { CHANGE_DETECTION_STATUS_TYPE_ID } from 'storybook/internal/types'; import type { StoryIndexGenerator } from '../utils/StoryIndexGenerator.ts'; -import type { ChangeDetectionAdapter, FileChangeEvent } from './adapters/index.ts'; -import { - ChangeDetectionResolverFactory, - DependencyGraphBuilder, - IncrementalPatcher, - ParseResolveCache, -} from './dependency-graph/index.ts'; -import type { DependencyGraph, ReverseIndexImpl } from './dependency-graph/index.ts'; +import type { ChangeDetectionAdapter } from './adapters/index.ts'; import { ChangeDetectionFailureError, ChangeDetectionUnavailableError } from './errors.ts'; import { GitDiffProvider } from './GitDiffProvider.ts'; import { extractBaselineEntryIds, IndexBaselineService } from './IndexBaselineService.ts'; -import type { ImportParser } from './parser-registry/index.ts'; -import { ParserRegistry, builtinImportParsers } from './parser-registry/index.ts'; import { resetChangeDetectionReadiness, setChangeDetectionReadiness } from './readiness.ts'; +import { getStoryIdsByAbsolutePath } from './story-files.ts'; +import type { StoryDependencyGraphService } from './StoryDependencyGraphService.ts'; const CHANGE_DETECTION_DEBOUNCE_MS = 200; @@ -49,33 +37,6 @@ function isSameStatus(a: Status | undefined, b: Status): boolean { ); } -type StoryIdsByFileCacheKey = Awaited>; - -function getStoryIdsByAbsolutePath( - cache: WeakMap< - StoryIdsByFileCacheKey, - { workingDir: string; storyIdsByFile: Map> } - >, - storyIndex: StoryIdsByFileCacheKey, - workingDir: string -): Map> { - const cached = cache.get(storyIndex); - if (cached && cached.workingDir === workingDir) { - return cached.storyIdsByFile; - } - const storyIdsByFile = new Map>(); - Object.values(storyIndex.entries).forEach((entry) => { - if (entry.type === 'story' && !entry.importPath.startsWith('virtual:')) { - const filePath = normalize(join(workingDir, entry.importPath)); - const storyIds = storyIdsByFile.get(filePath) ?? new Set(); - storyIds.add(entry.id); - storyIdsByFile.set(filePath, storyIds); - } - }); - cache.set(storyIndex, { workingDir, storyIdsByFile }); - return storyIdsByFile; -} - export function mergeStatusValues( previousValue: StatusValue | undefined, nextValue: StatusValue @@ -136,10 +97,14 @@ export function buildIndexBaselineStatuses( } /** - * Coordinates change detection by owning a builder-supplied {@link ChangeDetectionAdapter}, - * eagerly building a reverse-dependency index from story files at startup, applying - * file-system events incrementally to that index, resolving git-changed files, and publishing - * the resulting story statuses to the status store. + * Publishes change-detection story statuses to the status store. It resolves git-changed files, + * maps them to affected stories through a shared {@link StoryDependencyGraphService}, and emits + * `modified`/`affected`/`new` statuses (plus index-baseline `new` entries). + * + * The module dependency graph itself — building the reverse index, watching builder file events, + * and incrementally patching — lives in {@link StoryDependencyGraphService}, which this class + * composes. The two responsibilities are independent: the graph never depends on git, the status + * store, or the readiness signal, and could be driven by other consumers in the future. */ export class ChangeDetectionService { private disposed = false; @@ -147,49 +112,74 @@ export class ChangeDetectionService { private scanInFlight = false; private rerunAfterCurrentScan = false; private readinessResolved = false; - private refreshInFlight = false; + private statusPipelineStarted = false; + private changeDetectionEnabled = false; + private readonly graph: StoryDependencyGraphService; private previousStatuses = new Map(); private gitDiffProvider: GitDiffProvider | undefined; private indexBaselineService: IndexBaselineService | undefined; private readonly workingDir: string; private readonly debounceMs: number; - private adapter: ChangeDetectionAdapter | undefined; - private dependencyGraphBuilder: DependencyGraphBuilder | undefined; - private incrementalPatcher: IncrementalPatcher | undefined; - private reverseIndex: ReverseIndexImpl | undefined; - private storyFiles: Set = new Set(); - private readonly storyIdsByFileCache = new WeakMap< - StoryIdsByFileCacheKey, - { workingDir: string; storyIdsByFile: Map> } - >(); - /** - * Serialises file-change patches so two events touching the same dep set never interleave - * across `await` points inside `IncrementalPatcher.patch`. The chain ignores rejections - * (each call's failure is logged in {@link handleFileChange}). - */ - private patchQueue: Promise = Promise.resolve(); - private unsubscribeFileChange: (() => void) | undefined; - private unsubscribeStartupFailure: (() => void) | undefined; constructor( private readonly options: { + graph: StoryDependencyGraphService; storyIndexGeneratorPromise: Promise; statusStore: StatusStoreByTypeId; gitDiffProvider?: GitDiffProvider; indexBaselineService?: IndexBaselineService; workingDir?: string; debounceMs?: number; - /** Presets instance used to resolve `experimental_importParsers` contributions from framework/renderer plugins. */ - presets?: Presets; } ) { this.gitDiffProvider = options.gitDiffProvider; this.indexBaselineService = options.indexBaselineService; this.workingDir = options.workingDir ?? process.cwd(); this.debounceMs = options.debounceMs ?? CHANGE_DETECTION_DEBOUNCE_MS; + this.graph = options.graph; resetChangeDetectionReadiness(); } + /** True while the service is live and change-detection status publishing is enabled. */ + private isActive(): boolean { + return !this.disposed && this.changeDetectionEnabled; + } + + onGraphReady(): void { + if (!this.isActive()) { + return; + } + + this.startStatusPipeline(); + } + + onGraphChange(): void { + if (!this.isActive()) { + return; + } + + this.scheduleScan(this.debounceMs); + } + + onGraphError(error: Error): void { + if (!this.isActive()) { + return; + } + + this.resolveReadiness({ status: 'error', error }); + void this.dispose().catch(() => undefined); + } + + onGraphUnavailable(reason: string, error?: Error): void { + if (!this.isActive()) { + return; + } + + logger.warn(`Change detection unavailable: ${reason}`); + this.resolveReadiness({ status: 'unavailable', reason, error }); + void this.dispose(); + } + start(adapter: ChangeDetectionAdapter | undefined, enabled: boolean | undefined): void { if (enabled === false) { logger.debug('Change detection disabled.'); @@ -210,140 +200,19 @@ export class ChangeDetectionService { } logger.debug('Change detection enabled.'); - this.adapter = adapter; - - void this.startInternal().catch((error) => { - if (this.disposed) { - return; - } - const failure = - error instanceof Error ? error : new ChangeDetectionFailureError(String(error)); - logger.error(`Change detection failed to start: ${failure.message}`); - this.resolveReadiness({ status: 'error', error: failure }); - void this.dispose().catch(() => undefined); - }); + this.changeDetectionEnabled = true; + this.onGraphReady(); } /** - * Builds parser registry, resolver, dependency graph, and patcher; subscribes to - * file-change events queued behind {@link patchQueue}; kicks off the baseline service - * and initial scan. + * Wires the git-diff-driven status pipeline. Runs once the dependency graph is ready (so the + * initial scan and every git-state-change scan read a populated reverse index). */ - private async startInternal(): Promise { - const adapter = this.adapter; - if (!adapter) { - return; - } - - if (this.disposed) { - return; - } - - const resolveConfig = await adapter.getResolveConfig(); - const projectRoot = normalize(resolveConfig.projectRoot ?? this.workingDir); - - const pluginParsers = this.options.presets - ? await this.options.presets.apply('experimental_importParsers', []) - : []; - const registry = new ParserRegistry({ - defaultParsers: builtinImportParsers, - pluginParsers, - }); - const resolver = new ChangeDetectionResolverFactory(resolveConfig); - const workspaceRoots = new Set([normalize(getProjectRoot())]); - - const storyIndexGenerator = await this.options.storyIndexGeneratorPromise; - const storyIndex = await storyIndexGenerator.getIndex(); - const storyIdsByFile = getStoryIdsByAbsolutePath( - this.storyIdsByFileCache, - storyIndex, - this.workingDir - ); - this.storyFiles = new Set(storyIdsByFile.keys()); - - if (this.disposed) { + private startStatusPipeline(): void { + if (this.disposed || this.statusPipelineStarted) { return; } - - // Shared parse/resolve cache so the patcher reuses cold-start results instead of - // re-doing every file's parse + resolution on the first event after boot. The patcher - // invalidates per-file entries on every change/unlink before reading. - const debugEnv = process.env.STORYBOOK_CHANGE_DETECTION_DEBUG; - const cache = new ParseResolveCache({ - registry, - resolver, - workspaceRoots, - projectRoot, - logger, - debug: !!debugEnv, - }); - - this.dependencyGraphBuilder = new DependencyGraphBuilder({ - registry, - resolver, - workspaceRoots, - projectRoot, - cache, - }); - - // Subscribe BEFORE build — buffer events until patcher is ready - const eventBuffer: FileChangeEvent[] = []; - this.unsubscribeFileChange = adapter.onFileChange((event) => { - if (this.disposed) { - return; - } - eventBuffer.push(event); - }); - - const { reverseIndex, graph } = await this.dependencyGraphBuilder.build(this.storyFiles); - if (this.disposed) { - return; - } - this.reverseIndex = reverseIndex; - void this.dumpDebugSnapshot(reverseIndex, graph, projectRoot, workspaceRoots, cache); - - this.incrementalPatcher = new IncrementalPatcher({ - reverseIndex, - graph, - registry, - resolver, - workspaceRoots, - projectRoot, - cache, - isStoryFile: (path: string) => this.storyFiles.has(normalize(path)), - }); - - // Drain buffered events into patchQueue, then switch to live handler - this.unsubscribeFileChange?.(); - for (const event of eventBuffer) { - this.patchQueue = this.patchQueue - .then(() => this.handleFileChange(event)) - .catch(() => undefined); - } - - this.unsubscribeFileChange = adapter.onFileChange((event) => { - if (this.disposed) { - return; - } - this.patchQueue = this.patchQueue - .then(() => this.handleFileChange(event)) - .catch(() => undefined); - }); - - if (adapter.onStartupFailure) { - this.unsubscribeStartupFailure = adapter.onStartupFailure((event) => { - if (this.disposed) { - return; - } - logger.warn(`Change detection unavailable: ${event.reason}`); - this.resolveReadiness({ - status: 'unavailable', - reason: event.reason, - error: event.error, - }); - void this.dispose(); - }); - } + this.statusPipelineStarted = true; void this.getIndexBaselineService().start(); @@ -362,134 +231,10 @@ export class ChangeDetectionService { this.scheduleScan(0); } - onStoryIndexInvalidated(): void { + async dispose(): Promise { if (this.disposed) { return; } - void this.refreshStoryFiles().catch(() => undefined); - this.scheduleScan(this.debounceMs); - } - - /** - * Re-reads the story index and reconciles {@link storyFiles} with stories that have - * appeared or disappeared since startup. For each story that newly entered the index, the - * patcher is asked to walk it (so its forward edges are recorded). For each story that - * left the index, the patcher is asked to unlink it (so its reverse-index entries are - * pruned). Replays are queued behind {@link patchQueue} to keep the serialised-patch - * invariant intact. - */ - private async refreshStoryFiles(): Promise { - if (this.refreshInFlight || !this.incrementalPatcher) { - return; - } - this.refreshInFlight = true; - try { - const storyIndexGenerator = await this.options.storyIndexGeneratorPromise; - const storyIndex = await storyIndexGenerator.getIndex(); - if (this.disposed) { - return; - } - const storyIdsByFile = getStoryIdsByAbsolutePath( - this.storyIdsByFileCache, - storyIndex, - this.workingDir - ); - const next = new Set(storyIdsByFile.keys()); - const previous = this.storyFiles; - - const added: string[] = []; - for (const path of next) { - if (!previous.has(path)) { - added.push(path); - } - } - const removed: string[] = []; - for (const path of previous) { - if (!next.has(path)) { - removed.push(path); - } - } - - if (added.length === 0 && removed.length === 0) { - return; - } - - this.storyFiles = next; - - for (const path of added) { - this.patchQueue = this.patchQueue - .then(() => this.handleFileChange({ kind: 'add', path })) - .catch(() => undefined); - } - for (const path of removed) { - this.patchQueue = this.patchQueue - .then(() => this.handleFileChange({ kind: 'unlink', path })) - .catch(() => undefined); - } - } finally { - this.refreshInFlight = false; - } - } - - private async dumpDebugSnapshot( - reverseIndex: ReverseIndexImpl, - graph: DependencyGraph, - projectRoot: string, - workspaceRoots: Set, - cache: ParseResolveCache - ): Promise { - const debugEnv = process.env.STORYBOOK_CHANGE_DETECTION_DEBUG; - if (!debugEnv) { - return; - } - const outPath = - debugEnv === '1' || debugEnv === 'true' - ? join(projectRoot, 'storybook-graph-debug.json') - : debugEnv; - - const graphObj: Record = {}; - for (const [story, deps] of graph) { - graphObj[story] = Array.from(deps).sort(); - } - - const reverseObj: Record> = {}; - for (const [dep, stories] of reverseIndex.asMap()) { - reverseObj[dep] = Array.from(stories.entries()) - .map(([story, depth]) => ({ story, depth })) - .sort((a, b) => a.depth - b.depth || a.story.localeCompare(b.story)); - } - - const snapshot = { - timestamp: new Date().toISOString(), - projectRoot, - workspaceRoots: Array.from(workspaceRoots).sort(), - // `graph` is keyed by every walked node (story roots + their transitive deps), - // and `reverseIndex` records each story root at depth 0 alongside real deps — - // so `graph.size` / `reverseIndex.asMap().size` over-report story and dep totals. - // Report `storyFiles` from the authoritative source-of-truth set, plus the raw - // node/entry counts under unambiguous names for diagnostics. - storyFiles: this.storyFiles.size, - graphNodes: graph.size, - reverseIndexEntries: reverseIndex.asMap().size, - graph: graphObj, - reverseIndex: reverseObj, - // Each entry records one named-import barrel lookup: which names were requested, - // which source files they resolved to, and whether the barrel itself was also - // included (needBarrel: true means at least one name fell back to the barrel). - barrelResolutions: cache.getBarrelTrace() ?? [], - }; - - try { - await writeFile(outPath, JSON.stringify(snapshot, null, 2), 'utf8'); - logger.debug(`Change detection: graph debug snapshot written to ${outPath}`); - } catch (error) { - logger.warn( - `Change detection: failed to write debug snapshot to ${outPath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - async dispose(): Promise { this.disposed = true; this.rerunAfterCurrentScan = false; @@ -498,33 +243,7 @@ export class ChangeDetectionService { this.debounceTimer = undefined; } - this.unsubscribeFileChange?.(); - this.unsubscribeFileChange = undefined; - this.unsubscribeStartupFailure?.(); - this.unsubscribeStartupFailure = undefined; - this.gitDiffProvider?.dispose(); - // Drain in-flight patches before tearing down the OXC parse pool so no - // patch reads the pool after it has been disposed. - await this.patchQueue.catch(() => undefined); - await disposeOxcParsePool().catch(() => undefined); - } - - private async handleFileChange(event: FileChangeEvent): Promise { - if (this.disposed || !this.incrementalPatcher) { - return; - } - try { - await this.incrementalPatcher.patch(event); - } catch (error) { - logger.warn( - `Change detection: failed to apply ${event.kind} for ${event.path}: ${error instanceof Error ? error.message : String(error)}` - ); - } - if (this.disposed) { - return; - } - this.scheduleScan(this.debounceMs); } private scheduleScan(delayMs: number): void { @@ -539,17 +258,16 @@ export class ChangeDetectionService { } private async scan(): Promise { - if (this.disposed || !this.reverseIndex) { + if (this.disposed) { return; } - // Snapshot and drain the current patch chain before reading reverseIndex. Without this - // await, a scan triggered mid-patch (between removeStory and the re-walk's recordEdges) - // reads a transiently empty reverseIndex and publishes incorrect statuses. - const patchSnapshot = this.patchQueue; - await patchSnapshot.catch(() => undefined); + // Drain the graph's patch chain before reading it. Without this, a scan triggered mid-patch + // (between a story's removeStory and the re-walk's recordEdges) reads a transiently empty + // reverse index and publishes incorrect statuses. + await this.graph.whenSettled(); - if (this.disposed || !this.reverseIndex) { + if (this.disposed || !this.graph.hasGraph()) { return; } @@ -561,7 +279,7 @@ export class ChangeDetectionService { this.scanInFlight = true; try { - const nextStatuses = await this.buildStatuses(this.reverseIndex); + const nextStatuses = await this.buildStatuses(); if (this.disposed) { return; } @@ -608,7 +326,7 @@ export class ChangeDetectionService { } } - private async buildStatuses(reverseIndex: ReverseIndexImpl): Promise> { + private async buildStatuses(): Promise> { const gitDiffProvider = this.getGitDiffProvider(); const [changes, repoRoot, storyIndexGenerator, baselineEntryIds] = await Promise.all([ gitDiffProvider.getChangedFiles(), @@ -627,17 +345,12 @@ export class ChangeDetectionService { const storyIndex = await storyIndexGenerator.getIndex(); const baselineStatuses = buildIndexBaselineStatuses(storyIndex, baselineEntryIds); - const storyIdsByFile = getStoryIdsByAbsolutePath( - this.storyIdsByFileCache, - storyIndex, - this.workingDir - ); + const storyIdsByFile = getStoryIdsByAbsolutePath(storyIndex, this.workingDir); const statuses = new Map(); for (const changedFile of scannedFiles) { - const affectedStoryFiles = reverseIndex.lookup(changedFile); - // Include the changed file as a story-at-distance-0 if it IS a story (parity with - // legacy trace-changed.ts:10-12). + const affectedStoryFiles = this.graph.lookup(changedFile); + // Include the changed file as a story-at-distance-0 if it IS a story. const allEntries = new Map(affectedStoryFiles); if (storyIdsByFile.has(changedFile)) { allEntries.set(changedFile, 0); diff --git a/code/core/src/core-server/change-detection/StoryDependencyGraphService.test.ts b/code/core/src/core-server/change-detection/StoryDependencyGraphService.test.ts new file mode 100644 index 000000000000..dd85410dfce4 --- /dev/null +++ b/code/core/src/core-server/change-detection/StoryDependencyGraphService.test.ts @@ -0,0 +1,449 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { logger } from 'storybook/internal/node-logger'; +import * as oxcParser from 'storybook/internal/oxc-parser'; +import type { StoryIndex } from 'storybook/internal/types'; + +import { + buildReverseIndex, + createDeferred, + createMockAdapter, + createStoryIndex, + installDependencyGraphMocks, +} from './change-detection.test-helpers.ts'; +import { ChangeDetectionFailureError } from './errors.ts'; +import { StoryDependencyGraphService } from './StoryDependencyGraphService.ts'; + +vi.mock('storybook/internal/node-logger', { spy: true }); +vi.mock('./dependency-graph/index.ts', async (importOriginal) => { + // Keep ReverseIndexImpl + types real so tests can build synthetic indexes; replace the + // graph-building constructors with `vi.fn()`s so tests can override their behaviour per-case. + const actual = await importOriginal(); + return { + ...actual, + ChangeDetectionResolverFactory: vi.fn(), + DependencyGraphBuilder: vi.fn(), + IncrementalPatcher: vi.fn(), + }; +}); + +const workingDir = '/repo'; + +function setup(options?: { + storyIndex?: StoryIndex; + getIndex?: ReturnType; + withoutStartupFailure?: boolean; +}) { + const callbacks = { + onReady: vi.fn(), + onChange: vi.fn(), + onError: vi.fn(), + onUnavailable: vi.fn(), + }; + const adapterHandle = createMockAdapter({ + withoutStartupFailure: options?.withoutStartupFailure, + }); + const getIndex = + options?.getIndex ?? vi.fn().mockResolvedValue(options?.storyIndex ?? createStoryIndex([])); + + const service = new StoryDependencyGraphService({ + storyIndexGeneratorPromise: Promise.resolve({ getIndex } as never), + workingDir, + ...callbacks, + }); + + return { service, getIndex, callbacks, ...adapterHandle }; +} + +describe('StoryDependencyGraphService', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(logger.info).mockImplementation(() => undefined); + vi.mocked(logger.warn).mockImplementation(() => undefined); + vi.mocked(logger.error).mockImplementation(() => undefined); + vi.mocked(logger.debug).mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + it('builds the graph, fires onReady, and exposes the reverse index via lookup', async () => { + const reverseIndex = buildReverseIndex([ + ['/repo/src/Button.tsx', '/repo/src/Button.stories.tsx', 1], + ]); + installDependencyGraphMocks(reverseIndex); + const storyIndex = createStoryIndex([ + { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, + ]); + const { service, callbacks, adapter } = setup({ storyIndex }); + + expect(service.hasGraph()).toBe(false); + + service.start(adapter); + await vi.runAllTimersAsync(); + + expect(callbacks.onReady).toHaveBeenCalledTimes(1); + expect(callbacks.onError).not.toHaveBeenCalled(); + expect(service.hasGraph()).toBe(true); + expect(service.lookup('/repo/src/Button.tsx')).toEqual( + new Map([['/repo/src/Button.stories.tsx', 1]]) + ); + expect(service.lookup('/repo/src/Unknown.tsx')).toEqual(new Map()); + + await service.dispose(); + }); + + it('serialises concurrent file-change events through the patch chain', async () => { + const reverseIndex = buildReverseIndex([]); + const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + + let activePatches = 0; + let maxConcurrent = 0; + patchSpy.mockImplementation(async () => { + activePatches += 1; + maxConcurrent = Math.max(maxConcurrent, activePatches); + await new Promise((resolve) => setImmediate(resolve)); + activePatches -= 1; + }); + + const { service, adapter, emitFileChange } = setup({ + storyIndex: createStoryIndex([ + { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, + ]), + }); + + service.start(adapter); + await vi.runAllTimersAsync(); + + emitFileChange({ kind: 'change', path: '/repo/src/Button.tsx' }); + emitFileChange({ kind: 'change', path: '/repo/src/Other.tsx' }); + emitFileChange({ kind: 'unlink', path: '/repo/src/Stale.tsx' }); + await vi.runAllTimersAsync(); + + expect(patchSpy).toHaveBeenCalledTimes(3); + expect(maxConcurrent).toBe(1); + + await service.dispose(); + }); + + it('whenSettled resolves only after the in-flight patch settles, so a later lookup observes it', async () => { + const reverseIndex = buildReverseIndex([]); + const patchDeferred = createDeferred(); + const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + + patchSpy.mockImplementationOnce(async () => { + await patchDeferred.promise; + reverseIndex.record('/repo/src/Button.stories.tsx', '/repo/src/Button.stories.tsx', 0); + }); + + const { service, adapter, emitFileChange } = setup({ + storyIndex: createStoryIndex([ + { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, + ]), + }); + + service.start(adapter); + await vi.runAllTimersAsync(); + + emitFileChange({ kind: 'change', path: '/repo/src/Button.stories.tsx' }); + + let settled = false; + void service.whenSettled().then(() => { + settled = true; + }); + // The patch is parked, so the settle barrier must not resolve yet. + await Promise.resolve(); + expect(settled).toBe(false); + + patchDeferred.resolve(); + await vi.runAllTimersAsync(); + + expect(settled).toBe(true); + expect(service.lookup('/repo/src/Button.stories.tsx')).toEqual( + new Map([['/repo/src/Button.stories.tsx', 0]]) + ); + + await service.dispose(); + }); + + it('fires onChange after each file-change patch settles, but not for the initial build', async () => { + installDependencyGraphMocks(buildReverseIndex([])); + const { service, adapter, emitFileChange, callbacks } = setup({ + storyIndex: createStoryIndex([ + { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, + ]), + }); + + service.start(adapter); + await vi.runAllTimersAsync(); + expect(callbacks.onChange).not.toHaveBeenCalled(); + + emitFileChange({ kind: 'change', path: '/repo/src/B.tsx' }); + emitFileChange({ kind: 'change', path: '/repo/src/C.tsx' }); + await vi.runAllTimersAsync(); + + expect(callbacks.onChange).toHaveBeenCalledTimes(2); + + await service.dispose(); + }); + + it('buffers file events emitted during the build and applies them in order after build resolves', async () => { + const reverseIndex = buildReverseIndex([]); + const buildDeferred = createDeferred(); + const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockImplementation(async () => { + await buildDeferred.promise; + return { reverseIndex, graph: new Map() }; + }); + + const { service, adapter, emitFileChange } = setup({ storyIndex: createStoryIndex([]) }); + + service.start(adapter); + // Advance past all awaits in startInternal up to the build await. + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + + emitFileChange({ kind: 'change', path: '/repo/src/A.tsx' }); + emitFileChange({ kind: 'change', path: '/repo/src/B.tsx' }); + emitFileChange({ kind: 'unlink', path: '/repo/src/C.tsx' }); + expect(patchSpy).not.toHaveBeenCalled(); + + buildDeferred.resolve(); + await vi.runAllTimersAsync(); + + expect(patchSpy).toHaveBeenCalledTimes(3); + expect(patchSpy).toHaveBeenNthCalledWith(1, { kind: 'change', path: '/repo/src/A.tsx' }); + expect(patchSpy).toHaveBeenNthCalledWith(2, { kind: 'change', path: '/repo/src/B.tsx' }); + expect(patchSpy).toHaveBeenNthCalledWith(3, { kind: 'unlink', path: '/repo/src/C.tsx' }); + + await service.dispose(); + }); + + it('replays add through the patcher when onStoryIndexInvalidated reveals a new story', async () => { + const reverseIndex = buildReverseIndex([]); + const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + + const initialIndex = createStoryIndex([ + { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, + ]); + const updatedIndex = createStoryIndex([ + { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, + { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, + ]); + const getIndex = vi.fn().mockResolvedValueOnce(initialIndex).mockResolvedValue(updatedIndex); + + const { service, adapter, callbacks } = setup({ getIndex }); + + service.start(adapter); + await vi.runAllTimersAsync(); + expect(patchSpy).not.toHaveBeenCalled(); + + service.onStoryIndexInvalidated(); + await vi.runAllTimersAsync(); + + expect(patchSpy).toHaveBeenCalledWith({ kind: 'add', path: '/repo/src/B.stories.tsx' }); + // The index changed, so consumers are notified to recompute derived state. + expect(callbacks.onChange).toHaveBeenCalled(); + + await service.dispose(); + }); + + it('guards duplicate onStoryIndexInvalidated so a newly-added story is replayed only once', async () => { + const reverseIndex = buildReverseIndex([]); + const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + + const initialIndex = createStoryIndex([ + { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, + ]); + const updatedIndex = createStoryIndex([ + { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, + { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, + ]); + const getIndex = vi.fn().mockResolvedValueOnce(initialIndex).mockResolvedValue(updatedIndex); + + const { service, adapter } = setup({ getIndex }); + + service.start(adapter); + await vi.runAllTimersAsync(); + + service.onStoryIndexInvalidated(); + service.onStoryIndexInvalidated(); + await vi.runAllTimersAsync(); + + const addPatches = patchSpy.mock.calls.filter( + ([event]) => event.kind === 'add' && event.path === '/repo/src/B.stories.tsx' + ); + expect(addPatches).toHaveLength(1); + + await service.dispose(); + }); + + it('whenSettled waits for an in-flight story-index reconciliation, so a later lookup is post-reconciliation', async () => { + // Regression guard for the onChange-before-reconciliation gap: onStoryIndexInvalidated starts + // an async refresh (getIndex + add/unlink) and fires onChange synchronously, before the + // reconciliation patches exist. whenSettled() must await that in-flight reconciliation, not + // just the current patch tail, or a consumer reacting to onChange would read a pre-reconciliation + // graph. + const reverseIndex = buildReverseIndex([]); + const getIndexDeferred = createDeferred(); + const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + + // The reconciliation's add patch records B into the reverse index, so a post-reconciliation + // lookup can observe the freshly-walked story. + patchSpy.mockImplementation(async (event) => { + if (event.kind === 'add' && event.path === '/repo/src/B.stories.tsx') { + reverseIndex.record('/repo/src/B.stories.tsx', '/repo/src/B.stories.tsx', 0); + } + }); + + const initialIndex = createStoryIndex([ + { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, + ]); + const updatedIndex = createStoryIndex([ + { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, + { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, + ]); + // Build reads the initial index; the reconciliation's getIndex parks until released. + const getIndex = vi + .fn() + .mockResolvedValueOnce(initialIndex) + .mockImplementationOnce(async () => { + await getIndexDeferred.promise; + return updatedIndex; + }); + + const { service, adapter } = setup({ getIndex }); + service.start(adapter); + await vi.runAllTimersAsync(); + + service.onStoryIndexInvalidated(); + + let settled = false; + void service.whenSettled().then(() => { + settled = true; + }); + + // The reconciliation is parked in getIndex, so the barrier must not resolve and the add patch + // must not have run yet. + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + expect(patchSpy).not.toHaveBeenCalled(); + + getIndexDeferred.resolve(); + await vi.runAllTimersAsync(); + + expect(settled).toBe(true); + expect(patchSpy).toHaveBeenCalledWith({ kind: 'add', path: '/repo/src/B.stories.tsx' }); + expect(service.lookup('/repo/src/B.stories.tsx')).toEqual( + new Map([['/repo/src/B.stories.tsx', 0]]) + ); + + await service.dispose(); + }); + + it('fires onError, logs, and disposes the oxc pool when the eager build throws', async () => { + const { buildSpy } = installDependencyGraphMocks(buildReverseIndex([])); + buildSpy.mockImplementation(async () => { + throw new ChangeDetectionFailureError('graph build blew up'); + }); + const disposePoolSpy = vi.spyOn(oxcParser, 'disposeOxcParsePool').mockResolvedValue(undefined); + + const { service, adapter, callbacks } = setup({ storyIndex: createStoryIndex([]) }); + + service.start(adapter); + await vi.runAllTimersAsync(); + + expect(logger.error).toHaveBeenCalledWith( + 'Change detection failed to start: graph build blew up' + ); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'graph build blew up' }) + ); + expect(callbacks.onReady).not.toHaveBeenCalled(); + expect(disposePoolSpy).toHaveBeenCalledTimes(1); + expect(service.hasGraph()).toBe(false); + + await service.dispose(); + }); + + it('fires onUnavailable and tears down when the adapter reports a startup failure', async () => { + installDependencyGraphMocks(buildReverseIndex([])); + const disposePoolSpy = vi.spyOn(oxcParser, 'disposeOxcParsePool').mockResolvedValue(undefined); + + const { service, adapter, emitStartupFailure, callbacks, hasFileChangeSubscriber } = setup({ + storyIndex: createStoryIndex([]), + }); + + service.start(adapter); + await vi.runAllTimersAsync(); + expect(callbacks.onReady).toHaveBeenCalledTimes(1); + expect(hasFileChangeSubscriber()).toBe(true); + + emitStartupFailure({ reason: 'vite warmup failed', error: new Error('warmup failed') }); + await vi.runAllTimersAsync(); + + expect(callbacks.onUnavailable).toHaveBeenCalledWith( + 'vite warmup failed', + expect.objectContaining({ message: 'warmup failed' }) + ); + expect(disposePoolSpy).toHaveBeenCalledTimes(1); + // Disposal tears down the file-change subscription. + expect(hasFileChangeSubscriber()).toBe(false); + + await service.dispose(); + }); + + it('drains the in-flight patch before disposing the oxc pool, and dispose is idempotent', async () => { + const reverseIndex = buildReverseIndex([]); + const patchDeferred = createDeferred(); + const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + patchSpy.mockImplementationOnce(async () => { + await patchDeferred.promise; + }); + const disposePoolSpy = vi.spyOn(oxcParser, 'disposeOxcParsePool').mockResolvedValue(undefined); + + const { service, adapter, emitFileChange } = setup({ + storyIndex: createStoryIndex([ + { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, + ]), + }); + + service.start(adapter); + await vi.runAllTimersAsync(); + + emitFileChange({ kind: 'change', path: '/repo/src/B.tsx' }); + // Let the patch start and park inside patchSpy (now in-flight, past the disposed guard). + await Promise.resolve(); + await Promise.resolve(); + expect(patchSpy).toHaveBeenCalledTimes(1); + + let disposed = false; + const disposePromise = service.dispose().then(() => { + disposed = true; + }); + await Promise.resolve(); + // Dispose must wait for the in-flight patch to settle before disposing the pool. + expect(disposed).toBe(false); + expect(disposePoolSpy).not.toHaveBeenCalled(); + + patchDeferred.resolve(); + await disposePromise; + + expect(disposed).toBe(true); + expect(disposePoolSpy).toHaveBeenCalledTimes(1); + + // Idempotent: a second dispose is a no-op. + await service.dispose(); + expect(disposePoolSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/code/core/src/core-server/change-detection/StoryDependencyGraphService.ts b/code/core/src/core-server/change-detection/StoryDependencyGraphService.ts new file mode 100644 index 000000000000..6461c0b2e9e3 --- /dev/null +++ b/code/core/src/core-server/change-detection/StoryDependencyGraphService.ts @@ -0,0 +1,416 @@ +import { writeFile } from 'node:fs/promises'; + +import { join, normalize } from 'pathe'; + +import { getProjectRoot } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; +import { disposeOxcParsePool } from 'storybook/internal/oxc-parser'; +import type { Presets } from 'storybook/internal/types'; + +import type { StoryIndexGenerator } from '../utils/StoryIndexGenerator.ts'; +import type { ChangeDetectionAdapter, FileChangeEvent } from './adapters/index.ts'; +import { + ChangeDetectionResolverFactory, + DependencyGraphBuilder, + IncrementalPatcher, + ParseResolveCache, +} from './dependency-graph/index.ts'; +import type { DependencyGraph, ReverseIndexImpl } from './dependency-graph/index.ts'; +import { ChangeDetectionFailureError } from './errors.ts'; +import type { ImportParser } from './parser-registry/index.ts'; +import { ParserRegistry, builtinImportParsers } from './parser-registry/index.ts'; +import { getStoryIdsByAbsolutePath } from './story-files.ts'; + +export interface StoryDependencyGraphServiceOptions { + storyIndexGeneratorPromise: Promise; + workingDir?: string; + /** Presets instance used to resolve `experimental_importParsers` contributions from plugins. */ + presets?: Presets; + /** Fired once the initial graph build succeeds and the reverse index is ready to be queried. */ + onReady?: () => void; + /** + * Edge-triggered "the dependency graph may have changed; recompute derived state" signal. Fires + * after each settled file-change patch, and synchronously on a story-index invalidation (before + * its reconciliation patches are enqueued). It is a coalesce signal, NOT a settled read: do not + * call {@link StoryDependencyGraphService.lookup} synchronously inside the callback — schedule a + * (debounced) recompute whose first step is `await whenSettled()`. May fire more than once per + * logical change, so consumers must be idempotent. + */ + onChange?: () => void; + /** Fired when the eager build (or start pipeline) fails irrecoverably. */ + onError?: (error: Error) => void; + /** Fired when the builder adapter reports a startup failure. */ + onUnavailable?: (reason: string, error?: Error) => void; +} + +/** + * Owns the module dependency graph: it consumes a builder-supplied {@link ChangeDetectionAdapter}, + * eagerly builds a reverse-dependency index from story files at startup, applies file-system events + * incrementally to that index, and reconciles the story-root set when the story index changes. + * + * It is deliberately independent of git diffing, the status store, and the change-detection + * readiness signal — those belong to the {@link ChangeDetectionService} status publisher (and, in + * the future, other consumers such as server-side docgen). Consumers observe the graph through a + * narrow surface: {@link lookup} for `changedFile -> affected stories`, {@link whenSettled} as a + * patch-settle barrier, and the lifecycle callbacks supplied at construction. + */ +export class StoryDependencyGraphService { + private disposed = false; + private readonly workingDir: string; + private adapter: ChangeDetectionAdapter | undefined; + private dependencyGraphBuilder: DependencyGraphBuilder | undefined; + private incrementalPatcher: IncrementalPatcher | undefined; + private reverseIndex: ReverseIndexImpl | undefined; + private storyFiles: Set = new Set(); + private refreshInFlight = false; + /** + * Resolves once the in-flight story-index reconciliation has enqueued its add/unlink patches. + * {@link whenSettled} awaits this before snapshotting {@link patchQueue}, so a barrier taken + * while a reconciliation is still in `getIndex()` does not miss its patches (which would let a + * later {@link lookup} observe a pre-reconciliation graph). + */ + private refreshSettled: Promise = Promise.resolve(); + /** + * Serialises file-change patches so two events touching the same dep set never interleave + * across `await` points inside `IncrementalPatcher.patch`. The chain ignores rejections + * (each call's failure is logged in {@link handleFileChange}). + */ + private patchQueue: Promise = Promise.resolve(); + private unsubscribeFileChange: (() => void) | undefined; + private unsubscribeStartupFailure: (() => void) | undefined; + + constructor(private readonly options: StoryDependencyGraphServiceOptions) { + this.workingDir = options.workingDir ?? process.cwd(); + } + + start(adapter: ChangeDetectionAdapter): void { + this.adapter = adapter; + + void this.startInternal().catch((error) => { + if (this.disposed) { + return; + } + const failure = + error instanceof Error ? error : new ChangeDetectionFailureError(String(error)); + logger.error(`Change detection failed to start: ${failure.message}`); + this.options.onError?.(failure); + void this.dispose().catch(() => undefined); + }); + } + + /** Returns the per-story BFS-depth map for `dep`. EMPTY map if `dep` is unknown or unbuilt. */ + lookup(dep: string): Map { + return this.reverseIndex?.lookup(dep) ?? new Map(); + } + + /** True once the initial build has produced a reverse index. */ + hasGraph(): boolean { + return this.reverseIndex !== undefined; + } + + /** + * Read barrier. First awaits any in-flight story-index reconciliation so its add/unlink patches + * are enqueued, then snapshots the current tail of {@link patchQueue} (rather than re-reading the + * live field, so a continuous stream of file events cannot livelock the awaiter) and awaits it. + * When it resolves, every patch enqueued as of this call — including that reconciliation — has + * fully settled. + * + * This is a point-in-time barrier, not a freeze: file events arriving after the snapshot enqueue + * patches this call does not await, so a {@link lookup} taken after any further `await` may + * observe a newer (still non-mid-patch) graph. For a read pinned to this barrier, call + * {@link lookup} immediately after this resolves with no intervening `await`. Each new event + * re-fires {@link onChange}, so coalescing consumers converge without holding this barrier open. + */ + async whenSettled(): Promise { + // Phase 1: let any in-flight story-index reconciliation enqueue its add/unlink patches, so the + // tail snapshot below includes them. + await this.refreshSettled.catch(() => undefined); + // Phase 2: drain the patch chain, including any patches phase 1 just enqueued. + const tail = this.patchQueue; + await tail.catch(() => undefined); + } + + /** + * Builds parser registry, resolver, dependency graph, and patcher; subscribes to file-change + * events queued behind {@link patchQueue}; then signals readiness via {@link onReady}. + */ + private async startInternal(): Promise { + const adapter = this.adapter; + if (!adapter) { + return; + } + + if (this.disposed) { + return; + } + + const resolveConfig = await adapter.getResolveConfig(); + const projectRoot = normalize(resolveConfig.projectRoot ?? this.workingDir); + + const pluginParsers = this.options.presets + ? await this.options.presets.apply('experimental_importParsers', []) + : []; + const registry = new ParserRegistry({ + defaultParsers: builtinImportParsers, + pluginParsers, + }); + const resolver = new ChangeDetectionResolverFactory(resolveConfig); + const workspaceRoots = new Set([normalize(getProjectRoot())]); + + const storyIndexGenerator = await this.options.storyIndexGeneratorPromise; + const storyIndex = await storyIndexGenerator.getIndex(); + const storyIdsByFile = getStoryIdsByAbsolutePath(storyIndex, this.workingDir); + this.storyFiles = new Set(storyIdsByFile.keys()); + + if (this.disposed) { + return; + } + + // Shared parse/resolve cache so the patcher reuses cold-start results instead of + // re-doing every file's parse + resolution on the first event after boot. The patcher + // invalidates per-file entries on every change/unlink before reading. + const debugEnv = process.env.STORYBOOK_CHANGE_DETECTION_DEBUG; + const cache = new ParseResolveCache({ + registry, + resolver, + workspaceRoots, + projectRoot, + logger, + debug: !!debugEnv, + }); + + this.dependencyGraphBuilder = new DependencyGraphBuilder({ + registry, + resolver, + workspaceRoots, + projectRoot, + cache, + }); + + // Subscribe BEFORE build — buffer events until patcher is ready + const eventBuffer: FileChangeEvent[] = []; + this.unsubscribeFileChange = adapter.onFileChange((event) => { + if (this.disposed) { + return; + } + eventBuffer.push(event); + }); + + const { reverseIndex, graph } = await this.dependencyGraphBuilder.build(this.storyFiles); + if (this.disposed) { + return; + } + this.reverseIndex = reverseIndex; + void this.dumpDebugSnapshot(reverseIndex, graph, projectRoot, workspaceRoots, cache); + + this.incrementalPatcher = new IncrementalPatcher({ + reverseIndex, + graph, + registry, + resolver, + workspaceRoots, + projectRoot, + cache, + isStoryFile: (path: string) => this.storyFiles.has(normalize(path)), + }); + + // Drain buffered events into patchQueue, then switch to live handler + this.unsubscribeFileChange?.(); + for (const event of eventBuffer) { + this.patchQueue = this.patchQueue + .then(() => this.handleFileChange(event)) + .catch(() => undefined); + } + + this.unsubscribeFileChange = adapter.onFileChange((event) => { + if (this.disposed) { + return; + } + this.patchQueue = this.patchQueue + .then(() => this.handleFileChange(event)) + .catch(() => undefined); + }); + + if (adapter.onStartupFailure) { + this.unsubscribeStartupFailure = adapter.onStartupFailure((event) => { + if (this.disposed) { + return; + } + this.options.onUnavailable?.(event.reason, event.error); + void this.dispose(); + }); + } + + if (this.disposed) { + return; + } + this.options.onReady?.(); + } + + onStoryIndexInvalidated(): void { + if (this.disposed) { + return; + } + // Single-flight: a reconciliation already running will pick up this invalidation's changes when + // its getIndex() reads the (already-nulled) index cache, so we don't start a second. Track the + // running reconciliation in refreshSettled so whenSettled() can wait for it to enqueue its + // add/unlink patches before snapshotting the patch tail. The guard lives here (not inside + // refreshStoryFiles) so a dropped second invalidation can't overwrite refreshSettled with a + // resolved no-op and let the barrier skip the real in-flight reconciliation. + if (!this.refreshInFlight && this.incrementalPatcher) { + this.refreshInFlight = true; + this.refreshSettled = this.refreshStoryFiles() + .catch(() => undefined) + .finally(() => { + this.refreshInFlight = false; + }); + } + // The story index changed even when no story files were added/removed (e.g. a story renamed + // within a file); signal consumers so derived state is recomputed. + this.options.onChange?.(); + } + + /** + * Re-reads the story index and reconciles {@link storyFiles} with stories that have appeared or + * disappeared since startup. For each story that newly entered the index, the patcher is asked + * to walk it (so its forward edges are recorded). For each story that left the index, the + * patcher is asked to unlink it (so its reverse-index entries are pruned). Replays are queued + * behind {@link patchQueue} to keep the serialised-patch invariant intact. + * + * Single-flight is enforced by the sole caller, {@link onStoryIndexInvalidated}, which also + * exposes this run via {@link refreshSettled} so {@link whenSettled} can wait for the add/unlink + * patches to be enqueued. + */ + private async refreshStoryFiles(): Promise { + const storyIndexGenerator = await this.options.storyIndexGeneratorPromise; + const storyIndex = await storyIndexGenerator.getIndex(); + if (this.disposed) { + return; + } + const storyIdsByFile = getStoryIdsByAbsolutePath(storyIndex, this.workingDir); + const next = new Set(storyIdsByFile.keys()); + const previous = this.storyFiles; + + const added: string[] = []; + for (const path of next) { + if (!previous.has(path)) { + added.push(path); + } + } + const removed: string[] = []; + for (const path of previous) { + if (!next.has(path)) { + removed.push(path); + } + } + + if (added.length === 0 && removed.length === 0) { + return; + } + + this.storyFiles = next; + + for (const path of added) { + this.patchQueue = this.patchQueue + .then(() => this.handleFileChange({ kind: 'add', path })) + .catch(() => undefined); + } + for (const path of removed) { + this.patchQueue = this.patchQueue + .then(() => this.handleFileChange({ kind: 'unlink', path })) + .catch(() => undefined); + } + } + + private async dumpDebugSnapshot( + reverseIndex: ReverseIndexImpl, + graph: DependencyGraph, + projectRoot: string, + workspaceRoots: Set, + cache: ParseResolveCache + ): Promise { + const debugEnv = process.env.STORYBOOK_CHANGE_DETECTION_DEBUG; + if (!debugEnv) { + return; + } + const outPath = + debugEnv === '1' || debugEnv === 'true' + ? join(projectRoot, 'storybook-graph-debug.json') + : debugEnv; + + const graphObj: Record = {}; + for (const [story, deps] of graph) { + graphObj[story] = Array.from(deps).sort(); + } + + const reverseObj: Record> = {}; + for (const [dep, stories] of reverseIndex.asMap()) { + reverseObj[dep] = Array.from(stories.entries()) + .map(([story, depth]) => ({ story, depth })) + .sort((a, b) => a.depth - b.depth || a.story.localeCompare(b.story)); + } + + const snapshot = { + timestamp: new Date().toISOString(), + projectRoot, + workspaceRoots: Array.from(workspaceRoots).sort(), + // `graph` is keyed by every walked node (story roots + their transitive deps), + // and `reverseIndex` records each story root at depth 0 alongside real deps — + // so `graph.size` / `reverseIndex.asMap().size` over-report story and dep totals. + // Report `storyFiles` from the authoritative source-of-truth set, plus the raw + // node/entry counts under unambiguous names for diagnostics. + storyFiles: this.storyFiles.size, + graphNodes: graph.size, + reverseIndexEntries: reverseIndex.asMap().size, + graph: graphObj, + reverseIndex: reverseObj, + // Each entry records one named-import barrel lookup: which names were requested, + // which source files they resolved to, and whether the barrel itself was also + // included (needBarrel: true means at least one name fell back to the barrel). + barrelResolutions: cache.getBarrelTrace() ?? [], + }; + + try { + await writeFile(outPath, JSON.stringify(snapshot, null, 2), 'utf8'); + logger.debug(`Change detection: graph debug snapshot written to ${outPath}`); + } catch (error) { + logger.warn( + `Change detection: failed to write debug snapshot to ${outPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + private async handleFileChange(event: FileChangeEvent): Promise { + if (this.disposed || !this.incrementalPatcher) { + return; + } + try { + await this.incrementalPatcher.patch(event); + } catch (error) { + logger.warn( + `Change detection: failed to apply ${event.kind} for ${event.path}: ${error instanceof Error ? error.message : String(error)}` + ); + } + if (this.disposed) { + return; + } + this.options.onChange?.(); + } + + async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; + + this.unsubscribeFileChange?.(); + this.unsubscribeFileChange = undefined; + this.unsubscribeStartupFailure?.(); + this.unsubscribeStartupFailure = undefined; + + // Drain in-flight patches before tearing down the OXC parse pool so no + // patch reads the pool after it has been disposed. + await this.patchQueue.catch(() => undefined); + await disposeOxcParsePool().catch(() => undefined); + } +} diff --git a/code/core/src/core-server/change-detection/active-service-registry.ts b/code/core/src/core-server/change-detection/active-service-registry.ts new file mode 100644 index 000000000000..368ac277f9d8 --- /dev/null +++ b/code/core/src/core-server/change-detection/active-service-registry.ts @@ -0,0 +1,20 @@ +import type { StoryDependencyGraphService } from './StoryDependencyGraphService.ts'; + +let activeStoryDependencyGraphService: StoryDependencyGraphService | undefined; + +/** @internal */ +export function setDependencyGraphService(service: StoryDependencyGraphService | undefined): void { + activeStoryDependencyGraphService = service; +} + +/** + * Returns the active graph service registered by the dev-server lifecycle, or `undefined` when + * the dev-server has not finished booting yet or has already torn down. The service may exist even + * when change-detection statuses are disabled. Use {@link StoryDependencyGraphService.hasGraph} to + * check whether the initial build has completed. + * + * @experimental + */ +export function getDependencyGraphService(): StoryDependencyGraphService | undefined { + return activeStoryDependencyGraphService; +} diff --git a/code/core/src/core-server/change-detection/change-detection.test-helpers.ts b/code/core/src/core-server/change-detection/change-detection.test-helpers.ts new file mode 100644 index 000000000000..f0f5694ba5f8 --- /dev/null +++ b/code/core/src/core-server/change-detection/change-detection.test-helpers.ts @@ -0,0 +1,166 @@ +import { vi } from 'vitest'; + +import type { StoryIndex } from 'storybook/internal/types'; + +import type { ChangeDetectionAdapter, FileChangeEvent } from './adapters/index.ts'; +import { ChangeDetectionService } from './ChangeDetectionService.ts'; +import { + ChangeDetectionResolverFactory, + DependencyGraphBuilder, + IncrementalPatcher, + ReverseIndexImpl, +} from './dependency-graph/index.ts'; +import { StoryDependencyGraphService } from './StoryDependencyGraphService.ts'; + +type ChangeDetectionServiceOptions = ConstructorParameters[0]; + +/** + * Shared scaffolding for the change-detection unit tests. The dependency-graph constructors are + * mocked per test file (each file declares its own `vi.mock('./dependency-graph/index.ts', ...)`); + * these helpers drive those mocks and build synthetic adapters / indexes / reverse indexes. + */ + +export function createDeferred() { + let resolve!: (value: T) => void; + + return { + promise: new Promise((fulfill) => { + resolve = fulfill; + }), + resolve, + }; +} + +export function createStoryIndex( + entries: Array<{ storyId: string; importPath: string; title?: string; name?: string }> +): StoryIndex { + return { + v: 5, + entries: Object.fromEntries( + entries.map(({ storyId, importPath, title = 'Story', name = 'Default' }) => [ + storyId, + { + id: storyId, + type: 'story', + subtype: 'story', + title, + name, + importPath, + }, + ]) + ), + }; +} + +export interface MockAdapterHandle { + adapter: ChangeDetectionAdapter; + emitFileChange: (event: FileChangeEvent) => void; + emitStartupFailure: (event: { reason: string; error?: Error }) => void; + hasFileChangeSubscriber: () => boolean; + hasStartupFailureSubscriber: () => boolean; +} + +/** + * Constructs a {@link StoryDependencyGraphService} wired to a {@link ChangeDetectionService} the + * same way the dev-server does: the graph is always injected, and its lifecycle callbacks are + * routed to the service's `onGraph*` handlers. Tests drive `graph.start(adapter)` and + * `service.start(adapter, enabled)` themselves (to keep timing control) and dispose both. + */ +export function createWiredChangeDetection(options: Omit): { + service: ChangeDetectionService; + graph: StoryDependencyGraphService; +} { + const ref: { current?: ChangeDetectionService } = {}; + const graph = new StoryDependencyGraphService({ + storyIndexGeneratorPromise: options.storyIndexGeneratorPromise, + workingDir: options.workingDir, + onReady: () => ref.current?.onGraphReady(), + onChange: () => ref.current?.onGraphChange(), + onError: (error) => ref.current?.onGraphError(error), + onUnavailable: (reason, error) => ref.current?.onGraphUnavailable(reason, error), + }); + const service = new ChangeDetectionService({ ...options, graph }); + ref.current = service; + return { service, graph }; +} + +export function createMockAdapter(opts?: { + resolveConfig?: { projectRoot?: string }; + withoutStartupFailure?: boolean; +}): MockAdapterHandle { + const fileHandlers = new Set<(e: FileChangeEvent) => void>(); + const startupHandlers = new Set<(e: { reason: string; error?: Error }) => void>(); + + const adapter: ChangeDetectionAdapter = { + async getResolveConfig() { + return { + projectRoot: opts?.resolveConfig?.projectRoot ?? '/repo', + }; + }, + onFileChange(handler) { + fileHandlers.add(handler); + return () => fileHandlers.delete(handler); + }, + }; + + if (!opts?.withoutStartupFailure) { + adapter.onStartupFailure = (handler) => { + startupHandlers.add(handler); + return () => startupHandlers.delete(handler); + }; + } + + return { + adapter, + emitFileChange: (event) => { + fileHandlers.forEach((h) => h(event)); + }, + emitStartupFailure: (event) => { + startupHandlers.forEach((h) => h(event)); + }, + hasFileChangeSubscriber: () => fileHandlers.size > 0, + hasStartupFailureSubscriber: () => startupHandlers.size > 0, + }; +} + +/** + * Build a ReverseIndexImpl populated with the given (dep -> story -> depth) entries. + * Used by tests to control what `reverseIndex.lookup(changedFile)` returns. + */ +export function buildReverseIndex( + edges: Iterable +): ReverseIndexImpl { + const reverseIndex = new ReverseIndexImpl(); + for (const [dep, story, depth] of edges) { + reverseIndex.record(dep, story, depth); + } + return reverseIndex; +} + +/** + * Stub the dependency-graph constructors so the service under test uses an in-test + * ReverseIndexImpl + an inert IncrementalPatcher. The mock implementations must be regular + * `function`s, not arrow functions: the service calls them with `new`, which arrow functions do + * not support. + */ +export function installDependencyGraphMocks(reverseIndex: ReverseIndexImpl): { + patchSpy: ReturnType; + buildSpy: ReturnType; +} { + const patchSpy = vi.fn(async () => undefined); + const buildSpy = vi.fn(async () => ({ reverseIndex, graph: new Map() })); + + vi.mocked(ChangeDetectionResolverFactory).mockImplementation(function () { + return { + resolve: vi.fn(async () => null), + } as unknown as ChangeDetectionResolverFactory; + } as unknown as new () => ChangeDetectionResolverFactory); + vi.mocked(DependencyGraphBuilder).mockImplementation(function () { + return { build: buildSpy } as unknown as DependencyGraphBuilder; + } as unknown as new () => DependencyGraphBuilder); + vi.mocked(IncrementalPatcher).mockImplementation(function () { + return { patch: patchSpy } as unknown as IncrementalPatcher; + } as unknown as new () => IncrementalPatcher); + + return { patchSpy, buildSpy }; +} diff --git a/code/core/src/core-server/change-detection/index.ts b/code/core/src/core-server/change-detection/index.ts index 1bd5583d15aa..09337fa8d704 100644 --- a/code/core/src/core-server/change-detection/index.ts +++ b/code/core/src/core-server/change-detection/index.ts @@ -6,6 +6,9 @@ export { type ChangeDetectionReadiness, } from './readiness.ts'; export { ChangeDetectionService } from './ChangeDetectionService.ts'; +export { StoryDependencyGraphService } from './StoryDependencyGraphService.ts'; +export type { StoryDependencyGraphServiceOptions } from './StoryDependencyGraphService.ts'; +export { getDependencyGraphService, setDependencyGraphService } from './active-service-registry.ts'; export type { ChangeDetectionAdapter, FileChangeEvent, diff --git a/code/core/src/core-server/change-detection/story-files.ts b/code/core/src/core-server/change-detection/story-files.ts new file mode 100644 index 000000000000..fa9c828028b3 --- /dev/null +++ b/code/core/src/core-server/change-detection/story-files.ts @@ -0,0 +1,44 @@ +import { join, normalize } from 'pathe'; + +import type { StoryIndexGenerator } from '../utils/StoryIndexGenerator.ts'; + +type StoryIndex = Awaited>; + +/** + * Maps each story index to its absolute-story-file -> story-id sets, keyed by the index object + * so repeat calls within a scan/build reuse the result. The story index is referentially stable + * for a given generation, so identity-keying is safe; the `workingDir` field guards against the + * (test-only) case of the same index resolved against a different working directory. + */ +const cache = new WeakMap< + StoryIndex, + { workingDir: string; storyIdsByFile: Map> } +>(); + +/** + * Builds (or returns a cached) map from absolute story-file path to the set of story ids declared + * in that file. Virtual entries are skipped. Shared by the dependency-graph tracker (to derive its + * story-root set) and the status publisher (to map affected files back to story ids). + */ +export function getStoryIdsByAbsolutePath( + storyIndex: StoryIndex, + workingDir: string +): Map> { + const cached = cache.get(storyIndex); + if (cached && cached.workingDir === workingDir) { + return cached.storyIdsByFile; + } + + const storyIdsByFile = new Map>(); + Object.values(storyIndex.entries).forEach((entry) => { + if (entry.type === 'story' && !entry.importPath.startsWith('virtual:')) { + const filePath = normalize(join(workingDir, entry.importPath)); + const storyIds = storyIdsByFile.get(filePath) ?? new Set(); + storyIds.add(entry.id); + storyIdsByFile.set(filePath, storyIds); + } + }); + + cache.set(storyIndex, { workingDir, storyIdsByFile }); + return storyIdsByFile; +} diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index e085a4446255..9dee7f62fc62 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -9,7 +9,11 @@ import polka from 'polka'; import { isTelemetryModuleEnabled, telemetry } from '../telemetry/index.ts'; import type { ChangeDetectionAdapter } from './change-detection/index.ts'; -import { ChangeDetectionService } from './change-detection/index.ts'; +import { + ChangeDetectionService, + setDependencyGraphService, + StoryDependencyGraphService, +} from './change-detection/index.ts'; import { getStatusStoreByTypeId } from './stores/status.ts'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator.ts'; import { doTelemetry } from './utils/doTelemetry.ts'; @@ -48,12 +52,33 @@ export async function storybookDevServer( const storyIndexGeneratorPromise = options.presets.apply('storyIndexGenerator'); + // Graph callbacks are wired before service construction; callbacks no-op until assigned below. + const changeDetectionServiceRef: { current?: ChangeDetectionService } = {}; + const storyDependencyGraphService = new StoryDependencyGraphService({ + storyIndexGeneratorPromise, + workingDir, + presets: options.presets, + onReady: () => changeDetectionServiceRef.current?.onGraphReady(), + onChange: () => changeDetectionServiceRef.current?.onGraphChange(), + onError: (failure) => changeDetectionServiceRef.current?.onGraphError(failure), + onUnavailable: (reason, error) => + changeDetectionServiceRef.current?.onGraphUnavailable(reason, error), + }); + setDependencyGraphService(storyDependencyGraphService); + const changeDetectionService = new ChangeDetectionService({ + graph: storyDependencyGraphService, storyIndexGeneratorPromise, statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), workingDir, - presets: options.presets, }); + changeDetectionServiceRef.current = changeDetectionService; + + const disposeChangeDetectionRuntime = async () => { + await changeDetectionService.dispose().catch(() => undefined); + setDependencyGraphService(undefined); + await storyDependencyGraphService.dispose().catch(() => undefined); + }; app.use(compression({ level: 1 })); @@ -79,7 +104,7 @@ export async function storybookDevServer( channel: options.channel, workingDir, configDir, - onStoryIndexInvalidated: () => changeDetectionService.onStoryIndexInvalidated(), + onStoryIndexInvalidated: () => storyDependencyGraphService.onStoryIndexInvalidated(), }); (await getMiddleware(options.configDir))(app); @@ -124,10 +149,6 @@ export async function storybookDevServer( await Promise.resolve(); if (!options.ignorePreview) { - if (!features.changeDetection) { - changeDetectionService.start(undefined, false); - } - logger.debug('Starting preview..'); previewResult = await previewBuilder .start({ @@ -141,7 +162,7 @@ export async function storybookDevServer( logger.error('Failed to build the preview'); process.exitCode = 1; - await changeDetectionService.dispose().catch(() => undefined); + await disposeChangeDetectionRuntime(); await managerBuilder?.bail().catch(() => undefined); // For some reason, even when Webpack fails e.g. wrong main.js config, // the preview may continue to print to stdout, which can affect output @@ -153,15 +174,24 @@ export async function storybookDevServer( throw e; }); - if (features.changeDetection) { - let adapter: ChangeDetectionAdapter | undefined; - try { - adapter = previewBuilder.changeDetectionAdapter?.(); - } catch (err) { - logger.warn('Change detection: adapter initialisation failed'); - logger.debug(err instanceof Error ? (err.stack ?? err.message) : String(err)); - } + let adapter: ChangeDetectionAdapter | undefined; + try { + adapter = previewBuilder.changeDetectionAdapter?.(); + } catch (err) { + logger.warn('Change detection: adapter initialisation failed'); + logger.debug(err instanceof Error ? (err.stack ?? err.message) : String(err)); + } + + if (adapter) { + storyDependencyGraphService.start(adapter); + } + + const isChangeDetectionStatusEnabled = features.changeDetection !== false; + if (isChangeDetectionStatusEnabled) { changeDetectionService.start(adapter, true); + } else { + // Status publication is explicitly feature-gated; graph service may still be consumed elsewhere. + changeDetectionService.start(undefined, false); } } @@ -180,6 +210,7 @@ export async function storybookDevServer( }); } } catch (e) { + await disposeChangeDetectionRuntime(); await managerBuilder?.bail().catch(() => undefined); await previewBuilder?.bail().catch(() => undefined); throw e; @@ -211,6 +242,7 @@ export async function storybookDevServer( } catch {} await telemetry('canceled', payload, { immediate: true }); } finally { + await disposeChangeDetectionRuntime(); // Always terminate on signal, even when telemetry is disabled. process.exit(0); } diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index 783c928b5baa..9781654d6671 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -67,6 +67,8 @@ export type { ParseFileArgs, } from './change-detection/index.ts'; export { ChangeDetectionService } from './change-detection/ChangeDetectionService.ts'; +export { StoryDependencyGraphService } from './change-detection/StoryDependencyGraphService.ts'; +export { getDependencyGraphService as experimental_getDependencyGraphService } from './change-detection/active-service-registry.ts'; export { getTestProviderStoreById as experimental_getTestProviderStore, fullTestProviderStore as internal_fullTestProviderStore,