Skip to content

HMR: Fix race conditions causing stale play functions to fire on re-rendered stories#33930

Merged
valentinpalkovic merged 3 commits intonextfrom
copilot/fix-storybook-hmr-events
Feb 27, 2026
Merged

HMR: Fix race conditions causing stale play functions to fire on re-rendered stories#33930
valentinpalkovic merged 3 commits intonextfrom
copilot/fix-storybook-hmr-events

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 25, 2026

On every file save, up to three independent re-render triggers fired simultaneously — the HMR accept callback, the leading-edge STORY_INDEX_INVALIDATED, and the trailing-edge STORY_INDEX_INVALIDATED. Concurrently, STORY_HOT_UPDATED was emitted after onStoriesChanged had already started a new render, meaning it cancelled the new play function instead of the old one, causing user-event interactions to bleed into freshly-mounted components.

Changes

  • index-json.ts — Change STORY_INDEX_INVALIDATED debounce from edges: ['leading', 'trailing'] to edges: ['trailing'] only. Eliminates the redundant immediate-fire on every save; the trailing emit (100ms after last change) aligns with when the index is fully regenerated, and the STORY_UNCHANGED check in renderSelection skips the re-render when nothing structurally changed.

  • codegen-modern-iframe-script.ts (Vite) — Remove the vite:afterUpdate listener for STORY_HOT_UPDATED (fired for all updates, after accept callbacks) and emit it at the top of the VIRTUAL_STORIES_FILE accept callback, before onStoriesChanged:

    import.meta.hot.accept(VIRTUAL_STORIES_FILE, (newModule) => {
      // Cancel any running play function before patching in the new importFn
      window.__STORYBOOK_PREVIEW__.channel.emit('storyHotUpdated');
      window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn });
    });
  • codegen-project-annotations.ts (Vite) — Same fix applied to the project annotations accept callbacks.

  • virtualModuleModernEntry.js (Webpack) — Remove the addStatusHandler-based STORY_HOT_UPDATED emit (fired after all HMR is idle) and move it before onStoriesChanged / onGetProjectAnnotationsChanged in each accept callback.

Together these changes ensure: (1) at most one STORY_INDEX_INVALIDATED fires per save; (2) the old play function is always cancelled before the new render starts, not after.

Original prompt

This section details on the original issue you should resolve

<issue_title>[Bug]: Storybook events are incorrect on HMR and cause inconsistencies</issue_title>
<issue_description>### Describe the bug

When a story has a play function (especially ones that relate to typing on inputs), and the user triggers HMR by saving the story file, things get into a broken state.

Image

A bit more context

Storybook has many different events which get emitted over the channel, and different story render phases which occur based on such events.
Normally, a story goes through these render phases in order:
preparing,loading,rendering,playing,played,completing,completed,afterEach,finished

But upon HMR, there are special events which get emmitted such as storyIndexInvalidated, storyHotUpdated and globalsUpdated, all of which trigger a chain of other events, which cause stories to get prepared, loaded and rendered. However, given that multiple of these happen, some of these phases get aborted in the process, and it's quite possible that there are race conditions and one thing affects the other.

Seeing this in practice

Here's a video with more details and explanation:
https://streamable.com/hwh72u

Reproduction link

https://stackblitz.com/edit/github-grxyqq9r?file=src%2Fstories%2Fform.css,src%2Fstories%2FForm.stories.ts&preset=node

Reproduction steps

The reproduction shows the issue quite easily, although somehow the issue doesn't occur in Stackblitz IDE mode. It does occur if you eject the story and trigger HMR. Regardless, I'd recommend running the reproduction locally instead, which consistently gives the issue.
</issue_description>

Comments on the Issue (you are @copilot in this section)

@ndelangen I'll investigate, but there's likely no easy for we can do prior to sb10 @yannbf Context after some investigation:
  • The preview gets invalidated both by builder HMR and by channel events (STORY_INDEX_INVALIDATED or similar). Both trigger re-renders, often back to back, which seems to cause race conditions.

  • The preview’s state management is spread across a store, an index fetched from the server, and multiple special render flows (force remount, pending, etc), making it harder to reason about what causes what.

  • Cancelling/teardown logic isn’t always airtight. Sometimes, a previous play function continues to run into a new render, so you get duplicate/interleaved effects or test failures.

  • File-watching + builder detection layers both emit change events (even after debouncing, a single save triggers several events). When saving rapidly, things can break unpredictably across preview, indexer and builder HMR logic.

  • Multiple layers try to debounce or guard against double-renders, but this adds complexity and can leave gaps.

  • These have been papered over by guard code for a while, but the underlying behavior is fragile and not consistently tested—especially for fast succession updates.</comment_new>


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Summary by CodeRabbit

  • Bug Fixes

    • Ensure story index invalidation emits only once per file change.
  • Improvements

    • Emit hot-update signals immediately when modules are accepted and cancel running play functions before applying updates for more reliable hot reloads.
  • Tests

    • Added a test to verify single invalidation emission per file change.
  • Chores

    • Added a lint-fix pre-commit instruction for touched files.

…STORY_HOT_UPDATED timing

Co-authored-by: valentinpalkovic <5889929+valentinpalkovic@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix incorrect Storybook events on HMR triggering Fix HMR race conditions causing stale play functions to fire on re-rendered stories Feb 25, 2026
@valentinpalkovic valentinpalkovic marked this pull request as ready for review February 27, 2026 10:18
@valentinpalkovic valentinpalkovic changed the title Fix HMR race conditions causing stale play functions to fire on re-rendered stories HMR: Fix race conditions causing stale play functions to fire on re-rendered stories Feb 27, 2026
@nx-cloud
Copy link
Copy Markdown

nx-cloud bot commented Feb 27, 2026

View your CI Pipeline Execution ↗ for commit a49d9e1

Command Status Duration Result
nx run-many -t compile,check,knip,test,pretty-d... ❌ Failed 3m 44s View ↗

☁️ Nx Cloud last updated this comment at 2026-02-27 11:58:22 UTC

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 27, 2026

Fails
🚫 PR description is missing the mandatory "#### Manual testing" section. Please add it so that reviewers know how to manually test your changes.

Generated by 🚫 dangerJS against a49d9e1

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 27, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c047ebe and a49d9e1.

📒 Files selected for processing (2)
  • .github/copilot-instructions.md
  • code/builders/builder-vite/src/codegen-project-annotations.ts
✅ Files skipped from review due to trivial changes (1)
  • .github/copilot-instructions.md

📝 Walkthrough

Walkthrough

Modifies hot module replacement (HMR) flows to emit STORY_HOT_UPDATED inside module accept handlers for Vite and Webpack (pre-patch), and changes STORY_INDEX_INVALIDATED debounce to trailing-edge-only with an added test ensuring single emission per file change.

Changes

Cohort / File(s) Summary
Vite Builder HMR Runtime
code/builders/builder-vite/src/codegen-modern-iframe-script.ts, code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts
Removes separate vite:afterUpdate subscription; emits STORY_HOT_UPDATED immediately inside import.meta.hot.accept before updating importFn. Test adjusted to reflect pre-patch emission and removed afterUpdate listener.
Vite Builder Project Annotations
code/builders/builder-vite/src/codegen-project-annotations.ts
Adds STORY_HOT_UPDATED emission via window?.__STORYBOOK_PREVIEW__?.channel inside HMR accept handlers (CSF4 and general) before patching getProjectAnnotations, cancelling running plays prior to update.
Webpack Builder HMR Runtime
code/builders/builder-webpack5/templates/virtualModuleModernEntry.js
Removes webpackHot idle-status gating; emits STORY_HOT_UPDATED directly inside hot accept handlers for stories and preview annotations before applying new modules; adds comments clarifying pre-patch cancellation.
Core Server Index Invalidation
code/core/src/core-server/utils/index-json.ts, code/core/src/core-server/utils/index-json.test.ts
Changes debounce for STORY_INDEX_INVALIDATED to trailing-edge only (removing leading emission). Adds test "only emits once per file change (no double-fire from leading+trailing edges)" to verify single invalidation emission per change.
Repository tooling
.github/copilot-instructions.md
Adds a lint-fix instruction to run yarn --cwd code lint:js:cmd <file> --fix on touched files as part of lint guidance.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant HMR as HMR Runtime
participant Module as Story Module
participant Preview as Preview (window.STORYBOOK_PREVIEW)
participant Runner as Play Function / Runtime

Note over HMR,Module: Accept hot update for a module
HMR->>Module: accept(newModule)
Note right of HMR: emit STORY_HOT_UPDATED before patch
HMR->>Preview: emit STORY_HOT_UPDATED (channel)
Preview->>Runner: cancel running play(s)
HMR->>Module: patch importFn / patch getProjectAnnotations
Module->>Preview: new exports loaded

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (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

🤖 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/utils/index-json.test.ts`:
- Around line 663-691: Move the debounce mock setup out of the test body into
the test suite's beforeEach so mocking rules run in setup (replace the inline
vi.mocked(debounce).mockImplementation(...) currently in the test "only emits
once per file change..." with the same mock in beforeEach); also replace the
hardcoded 200ms sleep after triggering onChange(`${workingDir}/...`) with a wait
derived from the DEBOUNCE constant (e.g., use DEBOUNCE * 2 or similar) so the
assertion on mockServerChannel.emit (used by registerIndexJsonRoute and the
Watchpack onChange handler) waits an appropriate, self-documented duration.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 22eff0e and c047ebe.

📒 Files selected for processing (6)
  • code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts
  • code/builders/builder-vite/src/codegen-modern-iframe-script.ts
  • code/builders/builder-vite/src/codegen-project-annotations.ts
  • code/builders/builder-webpack5/templates/virtualModuleModernEntry.js
  • code/core/src/core-server/utils/index-json.test.ts
  • code/core/src/core-server/utils/index-json.ts

Comment on lines +663 to +691
it('only emits once per file change (no double-fire from leading+trailing edges)', async () => {
vi.mocked(debounce).mockImplementation(
(await vi.importActual<typeof import('es-toolkit/function')>('es-toolkit/function'))
.debounce
);

const mockServerChannel = { emit: vi.fn() } as any as ServerChannel;
registerIndexJsonRoute({
app,
channel: mockServerChannel,
workingDir,
normalizedStories,
storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(),
});

const watcher = Watchpack.mock.instances[0];
const onChange = watcher.on.mock.calls[0][1];

// Fire a single change event
onChange(`${workingDir}/src/nested/Button.stories.ts`);

// Wait for the trailing debounce to fire
await vi.waitFor(() => {
expect(mockServerChannel.emit).toHaveBeenCalledTimes(1);
});

// Ensure it was only called once (no double-fire from both leading and trailing edges)
await new Promise((resolve) => setTimeout(resolve, 200));
expect(mockServerChannel.emit).toHaveBeenCalledTimes(1);
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1. Check the exact code at lines 663-691
echo "=== Code at lines 663-691 ==="
sed -n '663,691p' code/core/src/core-server/utils/index-json.test.ts

echo -e "\n=== DEBOUNCE constant definition ==="
rg -n "const DEBOUNCE" code/core/src/core-server/utils/index-json.test.ts

Repository: storybookjs/storybook

Length of output: 1263


🏁 Script executed:

#!/bin/bash
# 2. Check for other mockImplementation calls in the test file
echo "=== All mockImplementation calls ==="
rg -n "mockImplementation\(" code/core/src/core-server/utils/index-json.test.ts

Repository: storybookjs/storybook

Length of output: 267


🏁 Script executed:

#!/bin/bash
# 3. Check the structure of beforeEach blocks in the file
echo "=== beforeEach blocks structure ==="
rg -n "beforeEach" code/core/src/core-server/utils/index-json.test.ts -A 5

Repository: storybookjs/storybook

Length of output: 722


🏁 Script executed:

#!/bin/bash
# Search for DEBOUNCE more broadly (could be imported)
echo "=== Search for DEBOUNCE usage ==="
rg -n "DEBOUNCE" code/core/src/core-server/utils/index-json.test.ts

echo -e "\n=== Also check the main implementation file for debounce timing ==="
rg -n "DEBOUNCE|debounce.*\d+" code/core/src/core-server/utils/index-json.ts | head -20

Repository: storybookjs/storybook

Length of output: 362


🏁 Script executed:

#!/bin/bash
# Check context at line 605 for mockImplementation
echo "=== Context around line 605 ==="
sed -n '600,620p' code/core/src/core-server/utils/index-json.test.ts

Repository: storybookjs/storybook

Length of output: 790


Move mock setup to beforeEach and derive debounce wait time from DEBOUNCE constant.

Lines 664–667 set mock behavior inside the test body, violating Vitest guidelines to implement mock behaviors in beforeEach blocks. Additionally, the hardcoded 200ms sleep at line 690 is arbitrary; it should derive from the DEBOUNCE constant (100ms) to keep the test self-documenting and maintainable.

Suggested adjustment
-    it('only emits once per file change (no double-fire from leading+trailing edges)', async () => {
-      vi.mocked(debounce).mockImplementation(
-        (await vi.importActual<typeof import('es-toolkit/function')>('es-toolkit/function'))
-          .debounce
-      );
+    describe('single-emission behavior', () => {
+      beforeEach(async () => {
+        vi.mocked(debounce).mockImplementation(
+          (await vi.importActual<typeof import('es-toolkit/function')>('es-toolkit/function'))
+            .debounce
+        );
+      });
+
+      it('only emits once per file change (no double-fire from leading+trailing edges)', async () => {
         // ...
-      await new Promise((resolve) => setTimeout(resolve, 200));
+      await new Promise((resolve) => setTimeout(resolve, DEBOUNCE * 2));
       expect(mockServerChannel.emit).toHaveBeenCalledTimes(1);
+      });
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/core/src/core-server/utils/index-json.test.ts` around lines 663 - 691,
Move the debounce mock setup out of the test body into the test suite's
beforeEach so mocking rules run in setup (replace the inline
vi.mocked(debounce).mockImplementation(...) currently in the test "only emits
once per file change..." with the same mock in beforeEach); also replace the
hardcoded 200ms sleep after triggering onChange(`${workingDir}/...`) with a wait
derived from the DEBOUNCE constant (e.g., use DEBOUNCE * 2 or similar) so the
assertion on mockServerChannel.emit (used by registerIndexJsonRoute and the
Watchpack onChange handler) waits an appropriate, self-documented duration.

@storybook-app-bot
Copy link
Copy Markdown

storybook-app-bot bot commented Feb 27, 2026

Package Benchmarks

Commit: a49d9e1, ran on 27 February 2026 at 11:04:53 UTC

The following packages have significant changes to their size or dependencies:

storybook

Before After Difference
Dependency count 49 49 0
Self size 20.19 MB 20.42 MB 🚨 +228 KB 🚨
Dependency size 16.52 MB 16.52 MB 0 B
Bundle Size Analyzer Link Link

@storybook/cli

Before After Difference
Dependency count 183 183 0
Self size 779 KB 779 KB 🎉 -84 B 🎉
Dependency size 67.37 MB 67.60 MB 🚨 +228 KB 🚨
Bundle Size Analyzer Link Link

@storybook/codemod

Before After Difference
Dependency count 176 176 0
Self size 32 KB 32 KB 0 B
Dependency size 65.89 MB 66.12 MB 🚨 +228 KB 🚨
Bundle Size Analyzer Link Link

create-storybook

Before After Difference
Dependency count 50 50 0
Self size 1.04 MB 1.04 MB 0 B
Dependency size 36.72 MB 36.94 MB 🚨 +228 KB 🚨
Bundle Size Analyzer node node

@valentinpalkovic valentinpalkovic added the patch:yes Bugfix & documentation PR that need to be picked to main branch label Feb 27, 2026
@valentinpalkovic valentinpalkovic merged commit 2b6c4da into next Feb 27, 2026
125 of 131 checks passed
@valentinpalkovic valentinpalkovic deleted the copilot/fix-storybook-hmr-events branch February 27, 2026 11:53
@valentinpalkovic valentinpalkovic removed the patch:yes Bugfix & documentation PR that need to be picked to main branch label Feb 27, 2026
@yannbf
Copy link
Copy Markdown
Member

yannbf commented Feb 27, 2026

@tmeasday @ghengeveld I think you might want to take a look at this fix. Let us know if there's something to keep in mind with the change!


import.meta.hot.accept('${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}', (newModule) => {
// Cancel any running play function before patching in the new importFn
window.__STORYBOOK_PREVIEW__.channel.emit('${STORY_HOT_UPDATED}');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This looks broken, since it's not a template string but a regular string.

Copy link
Copy Markdown
Member

@tmeasday tmeasday left a comment

Choose a reason for hiding this comment

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

I'm not aware of the STORY_HOT_UPDATED event - it sort of seems redundant to me (after this change especially) -- shouldn't whatever code handles it and cancels the running play function just trigger whenever the index or project annotations change? I thought we already did that @ghengeveld?

I would say a few things about both the existing code and the change:

  1. Why emit an event and not just call a method on the preview like the other things we do in these HMR handlers?

  2. Where are the integration tests (PreviewWeb.test.ts) for the scenarios we are trying to test for here? We had attempted to document all the various race conditions that we deal with in the preview in that file -- it sounds like there are more that weren't covered that way, given this PR changed the behaviour but didn't need to touch the tests. For something as difficult to QA for as race conditions that makes me concerned.

  3. I'm not opposed to the simplification that this PR does (assuming we are OK with losing the leading invalidation, slowing down all HMR by 100ms potentially?). But in general trying to avoid races by tweaking the order things are emitted doesn't seem entirely reliable. For instance what happens if the second action (onStoriesChanged) in the block triggers before the first storyHotUpdated is done passing through the channel? Can we guarantee it won't? Again putting them in the one method and simply await-ing the first effect before doing the second seems a better approach.

@yannbf yannbf added the needs qa Indicates that this needs manual QA during the upcoming minor/major release label Mar 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Storybook events are incorrect on HMR and cause inconsistencies

6 participants