Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
538 changes: 112 additions & 426 deletions code/core/src/core-server/change-detection/ChangeDetectionService.test.ts

Large diffs are not rendered by default.

441 changes: 77 additions & 364 deletions code/core/src/core-server/change-detection/ChangeDetectionService.ts

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<typeof ChangeDetectionService>[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<T>() {
let resolve!: (value: T) => void;

return {
promise: new Promise<T>((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<ChangeDetectionServiceOptions, 'graph'>): {
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<readonly [string, string, number]>
): 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<typeof vi.fn>;
buildSpy: ReturnType<typeof vi.fn>;
} {
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 };
}
3 changes: 3 additions & 0 deletions code/core/src/core-server/change-detection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions code/core/src/core-server/change-detection/story-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { join, normalize } from 'pathe';

import type { StoryIndexGenerator } from '../utils/StoryIndexGenerator.ts';

type StoryIndex = Awaited<ReturnType<StoryIndexGenerator['getIndex']>>;

/**
* 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<string, Set<string>> }
>();

/**
* 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<string, Set<string>> {
const cached = cache.get(storyIndex);
if (cached && cached.workingDir === workingDir) {
return cached.storyIdsByFile;
}

const storyIdsByFile = new Map<string, Set<string>>();
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<string>();
storyIds.add(entry.id);
storyIdsByFile.set(filePath, storyIds);
}
});

cache.set(storyIndex, { workingDir, storyIdsByFile });
return storyIdsByFile;
}
64 changes: 48 additions & 16 deletions code/core/src/core-server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -48,12 +52,33 @@ export async function storybookDevServer(
const storyIndexGeneratorPromise =
options.presets.apply<StoryIndexGenerator>('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 }));

Expand All @@ -79,7 +104,7 @@ export async function storybookDevServer(
channel: options.channel,
workingDir,
configDir,
onStoryIndexInvalidated: () => changeDetectionService.onStoryIndexInvalidated(),
onStoryIndexInvalidated: () => storyDependencyGraphService.onStoryIndexInvalidated(),
});

(await getMiddleware(options.configDir))(app);
Expand Down Expand Up @@ -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({
Expand All @@ -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
Expand All @@ -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);
}
}

Expand All @@ -180,6 +210,7 @@ export async function storybookDevServer(
});
}
} catch (e) {
await disposeChangeDetectionRuntime();
await managerBuilder?.bail().catch(() => undefined);
await previewBuilder?.bail().catch(() => undefined);
throw e;
Expand Down Expand Up @@ -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);
}
Expand Down
2 changes: 2 additions & 0 deletions code/core/src/core-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down