Skip to content

Core: Improve startup performance by deferring change detection initialization#34498

Merged
ghengeveld merged 1 commit intonextfrom
change-detection-startup-performance
Apr 9, 2026
Merged

Core: Improve startup performance by deferring change detection initialization#34498
ghengeveld merged 1 commit intonextfrom
change-detection-startup-performance

Conversation

@ghengeveld
Copy link
Copy Markdown
Member

@ghengeveld ghengeveld commented Apr 8, 2026

What I did

Deferred initialization of the ChangeDetectionService and - more importantly - Vite module graph pre-warming, until whenever the preview has rendered the first story or docs page.

Change detection requires the module graph to include all story files, which we ensure by firing a warmup request for each story file. However, this had significant impact on Storybook's startup speed, due to resource contention (warmup is async, but Node is still single-threaded).

Benchmarks

In Storybook's monorepo, changeDetection was adding significant overhead:

With changeDetection=false, average startup time (till story rendered) was 8.0s. With changeDetection=true, this increased to 14.5s on average, a 6.5s (~81%) slowdown.

With deferred initialization, the average startup time with changeDetection=true was 8.1s, only very slightly (and acceptably) slower than without change detection.

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

Caution

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook publish.yml --field pr=<PR_NUMBER>

Summary by CodeRabbit

Release Notes

  • New Features

    • Module graph listeners can now be registered after builder initialization.
    • Change detection can be configured to start lazily, improving startup performance based on feature flags.
  • Bug Fixes

    • Enhanced error handling for change detection startup failures with improved status reporting.

@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented Apr 8, 2026

View your CI Pipeline Execution ↗ for commit eaff7fe

Command Status Duration Result
nx run-many -t compile,check,knip,test,lint,fmt... ✅ Succeeded 4m 54s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-08 11:08:19 UTC

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 8, 2026

📝 Walkthrough

Walkthrough

The pull request refactors change detection startup from immediate initialization to lazy, event-driven activation. Module-graph listener registration is now allowed after builder initialization. The Vite builder's change subscription lifecycle changed from a "registration closed" gate to a "tracking started" gate with guarded initialization. Dev-server triggers change detection lazily upon story rendering or docs preparation events.

Changes

Cohort / File(s) Summary
Vite Builder Module-Graph Tracking
code/builders/builder-vite/src/index.ts, code/builders/builder-vite/src/index.test.ts
Refactored subscription lifecycle to allow late listener registration. Introduced lastBuilderOptions state and startModuleGraphTracking() guarded initialization function. Listeners now always register immediately, with tracking started on-demand with double-start protection, error handling, and startup-failure event emission. Test changed from rejecting late registration to validating listener invocation on module-graph mutations.
Dev-Server Change Detection Startup
code/core/src/core-server/dev-server.ts
Implemented lazy, event-driven change detection startup. When features?.changeDetection is enabled, initialization is deferred until STORY_RENDERED or DOCS_PREPARED events fire. Added guarded single-start protection and error handling with setChangeDetectionReadiness status updates.

Sequence Diagram(s)

sequenceDiagram
    participant DevServer as Dev Server
    participant Channel as Options Channel
    participant ChangeDetectionService as Change Detection<br/>Service
    participant ViteBuilder as Vite Builder
    participant ModuleGraph as Module Graph

    Note over DevServer: changeDetectionStarted = false

    Channel->>Channel: emit STORY_RENDERED or<br/>DOCS_PREPARED
    Channel-->>DevServer: listener triggered

    alt changeDetectionStarted = false
        DevServer->>ChangeDetectionService: start(..., features.changeDetection !== false)
        Note over DevServer: changeDetectionStarted = true
        
        ChangeDetectionService->>ViteBuilder: startModuleGraphTracking()
        
        alt startModuleGraphTracking guards
            ViteBuilder->>ViteBuilder: Validate server + lastBuilderOptions
            ViteBuilder->>ViteBuilder: Install debounced watcherChangeHandler
            ViteBuilder->>ViteBuilder: startChangeDetection(server, handler)
            Note over ViteBuilder: Listener registration now allowed
        else Error during startChangeDetection
            ViteBuilder-->>DevServer: Error logged + emit unavailable/error
            DevServer->>DevServer: setChangeDetectionReadiness({status: error})
        end
    else changeDetectionStarted = true
        DevServer->>DevServer: Skip (already started)
    end

    ModuleGraph->>ModuleGraph: fileToModulesMap changes
    ModuleGraph-->>ViteBuilder: watcher 'change' event
    ViteBuilder->>ViteBuilder: debounced watcherChangeHandler
    ViteBuilder->>ViteBuilder: buildModuleGraph + notify listeners
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
code/builders/builder-vite/src/index.ts (1)

73-90: ⚠️ Potential issue | 🟠 Major

Cancel stale startup work across bail() / restart.

startChangeDetection() keeps reading the module-level server and watcherChangeHandler after async boundaries. If bail() runs and a new start() reinitializes those globals before the old startup settles, the stale continuation can attach the 'all' watcher to the new Vite server and double-deliver updates. Please snapshot the current run and ignore post-await work from older runs.

Also applies to: 92-115, 183-195

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/builders/builder-vite/src/index.ts` around lines 73 - 90, The startup
flow can race: bail() may replace globals (server, watcherChangeHandler) while
async work in startChangeDetection() or similar functions (the areas around
lines 92-115 and 183-195) continues and attaches watchers to a new server; fix
by snapshotting a run identifier at the start of startChangeDetection()/other
async startup paths (e.g., capture a local const runId = currentRunId++ or a
capturedServer reference) and checking that snapshot immediately after each
await before doing any side-effect (attaching watcher, modifying
watcherChangeHandler, or using server). Ensure bail() increments/invalidates
that runId (or clears the capturedServer) so older continuations return early
and do not reattach 'all' handlers to a newer Vite server.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@code/core/src/core-server/dev-server.ts`:
- Line 2: Replace uses of the STORY_RENDERED event with DOCS_PREPARED in this
dev-server code: update any event listeners/subscriptions that wait for
STORY_RENDERED to instead wait for DOCS_PREPARED (the imported symbols
DOCS_PREPARED and STORY_RENDERED are present) so the Vite warmup triggers after
docs are prepared rather than after the first story paint; also update the other
occurrence(s) in the same file where STORY_RENDERED is used (the later listener
around the Vite warmup logic) to use DOCS_PREPARED as well.

---

Outside diff comments:
In `@code/builders/builder-vite/src/index.ts`:
- Around line 73-90: The startup flow can race: bail() may replace globals
(server, watcherChangeHandler) while async work in startChangeDetection() or
similar functions (the areas around lines 92-115 and 183-195) continues and
attaches watchers to a new server; fix by snapshotting a run identifier at the
start of startChangeDetection()/other async startup paths (e.g., capture a local
const runId = currentRunId++ or a capturedServer reference) and checking that
snapshot immediately after each await before doing any side-effect (attaching
watcher, modifying watcherChangeHandler, or using server). Ensure bail()
increments/invalidates that runId (or clears the capturedServer) so older
continuations return early and do not reattach 'all' handlers to a newer Vite
server.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a61ab052-133a-458a-b59d-4826c9bf0b2d

📥 Commits

Reviewing files that changed from the base of the PR and between ad5c2a9 and eaff7fe.

📒 Files selected for processing (3)
  • code/builders/builder-vite/src/index.test.ts
  • code/builders/builder-vite/src/index.ts
  • code/core/src/core-server/dev-server.ts

Comment thread code/core/src/core-server/dev-server.ts
@ghengeveld ghengeveld added the maintenance User-facing maintenance tasks label Apr 8, 2026
Comment thread code/core/src/core-server/dev-server.ts
@ghengeveld ghengeveld merged commit aae6c95 into next Apr 9, 2026
131 of 136 checks passed
@ghengeveld ghengeveld deleted the change-detection-startup-performance branch April 9, 2026 08:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants