Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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 { setActiveChangeDetectionService } from './active-service-registry.ts';
import {
ChangeDetectionResolverFactory,
DependencyGraphBuilder,
Expand Down Expand Up @@ -489,10 +490,48 @@ export class ChangeDetectionService {
}
}

/**
* Returns the live reverse-dependency index, or `undefined` if the graph
* hasn't finished its cold-start build yet (or change detection is unavailable).
*
* The returned instance is the same reference patched by `IncrementalPatcher`
* on file changes — callers read it lazily at query time, not snapshotted.
*
* @experimental
*/
getReverseIndex(): ReverseIndexImpl | undefined {
return this.reverseIndex;
}

/**
* Returns a map from absolute story file path → set of story IDs declared in
* that file, derived from the current story index.
*
* Useful for callers (e.g. addons) that consume {@link getReverseIndex} output:
* the reverse-index entries are keyed by absolute file path, but consumers
* typically need story IDs.
*
* @experimental
*/
async getStoryIdsByFile(): Promise<Map<string, Set<string>>> {
const generator = await this.options.storyIndexGeneratorPromise;
const storyIndex = await generator.getIndex();
return getStoryIdsByAbsolutePath(this.storyIdsByFileCache, storyIndex, this.workingDir);
}
Comment on lines +516 to +520

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid returning the mutable internal cache map directly.

This method exposes cached Map<string, Set<string>> references to callers. External mutation can corrupt internal state used later by change-detection scans.

💡 Proposed fix
  async getStoryIdsByFile(): Promise<Map<string, Set<string>>> {
    const generator = await this.options.storyIndexGeneratorPromise;
    const storyIndex = await generator.getIndex();
-    return getStoryIdsByAbsolutePath(this.storyIdsByFileCache, storyIndex, this.workingDir);
+    const storyIdsByFile = getStoryIdsByAbsolutePath(
+      this.storyIdsByFileCache,
+      storyIndex,
+      this.workingDir
+    );
+    return new Map(
+      Array.from(storyIdsByFile.entries(), ([filePath, storyIds]) => [filePath, new Set(storyIds)])
+    );
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/core-server/change-detection/ChangeDetectionService.ts` around
lines 516 - 520, getStoryIdsByFile currently returns references into the
internal cache (storyIdsByFileCache) allowing callers to mutate internal state;
change getStoryIdsByFile (in ChangeDetectionService) to return a defensive copy:
after obtaining the Map from getStoryIdsByAbsolutePath, create and return a new
Map with cloned Sets for each key (i.e., for each [k,v] produce [k, new
Set(v)]), so callers receive independent structures and cannot mutate
storyIdsByFileCache or its Sets.


/** @experimental Working directory used to resolve relative paths. */
getWorkingDir(): string {
return this.workingDir;
}

async dispose(): Promise<void> {
this.disposed = true;
this.rerunAfterCurrentScan = false;

// Clear the active-service registry so in-process consumers stop seeing
// a disposed instance after dev-server shutdown.
setActiveChangeDetectionService(undefined);

if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ChangeDetectionService } from './ChangeDetectionService.ts';

let activeService: ChangeDetectionService | undefined;

/**
* Records the change-detection service started by the current Storybook dev server
* so that in-process consumers (addon presets) can reach it without going through a
* preset hook. Dev server is single-instance, so only one service is ever active.
*
* @internal
*/
export function setActiveChangeDetectionService(service: ChangeDetectionService | undefined): void {
activeService = service;
}

/**
* Returns the change-detection service for the current dev-server process, or
* `undefined` when change detection is disabled / not yet started / disposed.
*
* Read at request time, not at preset load time — the service is constructed
* after presets register.
*
* @experimental
*/
export function getActiveChangeDetectionService(): ChangeDetectionService | undefined {
return activeService;
}
4 changes: 4 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,10 @@ export {
type ChangeDetectionReadiness,
} from './readiness.ts';
export { ChangeDetectionService } from './ChangeDetectionService.ts';
export {
getActiveChangeDetectionService,
setActiveChangeDetectionService,
} from './active-service-registry.ts';
export type {
ChangeDetectionAdapter,
FileChangeEvent,
Expand Down
8 changes: 7 additions & 1 deletion code/core/src/core-server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ 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,
setActiveChangeDetectionService,
} 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 @@ -54,6 +57,9 @@ export async function storybookDevServer(
workingDir,
presets: options.presets,
});
// Expose to in-process consumers (addon presets) via the active-service registry.
// Dev server is single-instance, so only one service is ever active.
setActiveChangeDetectionService(changeDetectionService);

Comment on lines +60 to 63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Register the active service only when change detection is actually active.

The registry is set before any start() path runs. In cases like ignorePreview, disabled feature, or unavailable adapter, consumers can still retrieve a non-started service instance, which breaks the API contract.

💡 Proposed fix (gate registration by startability)
-  // Expose to in-process consumers (addon presets) via the active-service registry.
-  // Dev server is single-instance, so only one service is ever active.
-  setActiveChangeDetectionService(changeDetectionService);
   if (!options.ignorePreview) {
     if (!features.changeDetection) {
+      setActiveChangeDetectionService(undefined);
       changeDetectionService.start(undefined, false);
     }
@@
     if (features.changeDetection) {
       let adapter: ChangeDetectionAdapter | undefined;
@@
-      changeDetectionService.start(adapter, true);
+      if (adapter) {
+        setActiveChangeDetectionService(changeDetectionService);
+      } else {
+        setActiveChangeDetectionService(undefined);
+      }
+      changeDetectionService.start(adapter, true);
     }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/core-server/dev-server.ts` around lines 60 - 63, The active
service is being registered unconditionally via
setActiveChangeDetectionService(changeDetectionService) before any start() path
runs, exposing a non-started instance; change this to only register the service
when change detection is actually startable/active (e.g. after verifying the
feature flags like ignorePreview and adapter availability or after a successful
start() / startability check on changeDetectionService) so consumers cannot
retrieve a non-started service; update the logic around
setActiveChangeDetectionService, gating it by the changeDetectionService's
startability (or post-start success) and remove the unconditional early
registration.

app.use(compression({ level: 1 }));

Expand Down
1 change: 1 addition & 0 deletions code/core/src/core-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type {
ParseFileArgs,
} from './change-detection/index.ts';
export { ChangeDetectionService } from './change-detection/ChangeDetectionService.ts';
export { getActiveChangeDetectionService as experimental_getActiveChangeDetectionService } from './change-detection/active-service-registry.ts';
export {
getTestProviderStoreById as experimental_getTestProviderStore,
fullTestProviderStore as internal_fullTestProviderStore,
Expand Down
Loading