Skip to content

Preview: Stop mixed CSF3+4 stories getting core annotations injected twice#35094

Merged
JReinhold merged 2 commits into
nextfrom
jeppe-cursor/a236965c
Jun 9, 2026
Merged

Preview: Stop mixed CSF3+4 stories getting core annotations injected twice#35094
JReinhold merged 2 commits into
nextfrom
jeppe-cursor/a236965c

Conversation

@JReinhold

@JReinhold JReinhold commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Closes #

What I did

Core preview annotations (decorators, and by the same mechanism beforeEach/beforeAll/loaders) were applied twice for a single render of a non-factory (CSF1/2/3) story when the project's .storybook/preview is a CSF4 (definePreview) factory preview.

Root cause — core annotations were injected at multiple sites that stacked for a CSF4 preview:

  1. definePreview(...).composed composes the core annotations in (csf-factories.ts).
  2. Both the Vite and Webpack codegens return that composed object for CSF4 previews — so it already contains core.
  3. StoryStore's constructor (and the portable setProjectAnnotations used by addon-vitest) then prepended core again.

So store.projectAnnotations held core twice under a CSF4 preview. CSF4 stories bypass it (they use csfFile.projectAnnotations = preview.composed, core ×1), but non-factory stories fall back to store.projectAnnotations → doubled decorators/loaders/etc. defaultDecorateStory does not dedupe, so the duplicated entries execute twice.

Fix (mark-and-skip)

  • core-annotations.ts: add markAsComposedWithCoreAnnotations() / hasCoreAnnotations(). The marker is a non-enumerable Symbol.for key, so it resolves across the duplicate ESM/CJS core module instances that exist in dev (a plain Symbol() would differ per instance), is collision-proof against user annotation fields, and is hidden from enumeration/JSON/string-spread.
  • csf-factories.ts: flag definePreview().composed with the marker.
  • StoryStore.ts: skip prepending core when the incoming project annotations are already flagged.
  • portable-stories.ts: setProjectAnnotations skips prepending core when any incoming annotation is flagged (fixes the addon-vitest setup path).

This makes core inject exactly once for every (preview × story) combination, with no builder changes — both Vite and Webpack hand the store the same flagged .composed object.

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

The doubling is not visible by default: the affected core annotations are either feature-gated or pass-through, so a duplicated decorator produces no extra DOM on its own. To observe it you need a temporary, always-on, framework-agnostic probe in core. Apply the patch below, which adds a pass-through core decorator and a core beforeEach that each console.log when they run.

Save as core-probe.patch at the repo root and apply with git apply core-probe.patch:

diff --git a/code/core/src/measure/preview.ts b/code/core/src/measure/preview.ts
index caf0f3bf541..db52cca8127 100644
--- a/code/core/src/measure/preview.ts
+++ b/code/core/src/measure/preview.ts
@@ -4,7 +4,15 @@ import { PARAM_KEY } from './constants.ts';
 import type { MeasureTypes } from './types.ts';
 import { withMeasure } from './withMeasure.ts';
 
-export const decorators = globalThis.FEATURES?.measure ? [withMeasure] : [];
+// TEMP QA PROBE — framework-agnostic, pass-through. DELETE before merging.
+const coreProbe = (storyFn: any, context: any) => {
+  const g = globalThis as any;
+  g.__CORE_PROBE__ = (g.__CORE_PROBE__ ?? 0) + 1;
+  console.log(`[core-probe] decorator #${g.__CORE_PROBE__} for "${context?.id}"`);
+  return storyFn();
+};
+
+export const decorators = [coreProbe, ...(globalThis.FEATURES?.measure ? [withMeasure] : [])];
 
 export const initialGlobals = {
   [PARAM_KEY]: false,
@@ -16,4 +24,10 @@ export default () =>
   definePreviewAddon<MeasureTypes>({
     decorators,
     initialGlobals,
+    // TEMP QA PROBE — DELETE before merging.
+    beforeEach: (context: any) => {
+      const g = globalThis as any;
+      g.__CORE_PROBE_BE__ = (g.__CORE_PROBE_BE__ ?? 0) + 1;
+      console.log(`[core-probe] beforeEach #${g.__CORE_PROBE_BE__} for "${context?.id}"`);
+    },
   });

Then:

  1. Generate a sandbox — its .storybook/preview.ts already ships a CSF4 definePreview preview, so no preview edits are needed:
    yarn task sandbox --template react-vite/default-ts --start-from auto
  2. Apply the probe patch and rebuild core: yarn nx compile core.
  3. In the sandbox's .storybook/main.ts, point the stories glob at the renderer fixtures so you have a non-factory and a factory story side by side:
    • non-factory (CSF1): code/renderers/react/template/stories/csf1.stories.tsx (e.g. the Hello1 story)
    • factory (CSF4): code/renderers/react/template/stories/csf4.stories.tsx
  4. Start the sandbox Storybook and open the browser devtools console. (If you rebuild core again, stop the dev server and clear its node_modules/.cache before restarting, otherwise Vite serves the stale pre-bundled core.)
  5. Open the non-factory story and watch the console — this is the key check:
    • Before this PR (on next): the probe fires twice for a single render — two [core-probe] decorator #… lines and two [core-probe] beforeEach #… lines per navigation.
    • With this PR: exactly one decorator line and one beforeEach line per render.
  6. Open the factory (CSF4) story: the probe fires once in both cases — confirming factory stories were never affected (they read csfFile.projectAnnotations), and that the fix didn't regress them.
  7. Optional no-rebuild cross-check, in the console: __STORYBOOK_STORY_STORE__.projectAnnotations.decorators.filter(d => d.name === 'coreProbe').length returns 2 on next and 1 with this PR. (Reference-based dedupe would not catch this — the two copies are distinct function instances from two getCoreAnnotations() calls — which is why the count, not identity, is the signal.)
  8. Revert the probe before merging: git apply -R core-probe.patch and rebuild core.

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

  • Declare whether manual QA will be needed for this PR during the next release, through qa:needed or qa:skip

  • 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

  • Bug Fixes

    • Prevented core Storybook annotations from being applied multiple times for composed previews and portable stories, eliminating duplicate loaders and related duplication regressions.
  • Tests

    • Added tests to verify core annotations are injected exactly once across plain and composed preview scenarios, covering preview factories, story store behavior, HMR path, and CSF4 processing.

@JReinhold JReinhold added bug preview-api ci:normal Run our default set of CI jobs (choose this for most PRs). qa:needed Pull Requests that will need manual QA prior to release. labels Jun 8, 2026
@JReinhold JReinhold changed the title fix: inject core annotations exactly once under a CSF4 preview Preview: Stop mixed CSF3+4 stories getting core annotations injected twice Jun 8, 2026
@JReinhold JReinhold marked this pull request as ready for review June 8, 2026 14:53
@JReinhold JReinhold requested a review from yannbf June 8, 2026 14:53
@JReinhold JReinhold added the patch:yes Bugfix & documentation PR that need to be picked to main branch label Jun 8, 2026
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a marker-based deduplication system for core annotations across Storybook's CSF preview initialization. It introduces a non-enumerable Symbol to flag composed preview objects, enabling StoryStore, portable-stories, and CSF factories to conditionally apply core annotations only once.

Changes

Core Annotation Deduplication

Layer / File(s) Summary
Core annotation marker and predicate helpers
code/core/src/csf/core-annotations.ts, code/core/src/csf/core-annotations.test.ts
Defines a Symbol.for-backed marker constant and exports markAsComposedWithCoreAnnotations() to flag objects and hasCoreAnnotations() to detect the flag, with unit tests validating non-enumerable behavior and type checks.
CSF factory composed preview marking
code/core/src/csf/csf-factories.ts, code/core/src/csf/csf-factories.test.ts
Applies the marker function to composed preview results in definePreview, with test coverage verifying the composed result is flagged and contains the expected loader count.
StoryStore conditional core composition
code/core/src/preview-api/modules/store/StoryStore.ts, code/core/src/preview-api/modules/store/StoryStore.test.ts
Adds composeProjectAnnotations() helper that checks for existing core annotations and conditionally prepends them only when absent, with comprehensive test coverage for CSF3 previews, CSF4 composed previews, and HMR paths.
Portable stories conditional core composition
code/core/src/preview-api/modules/store/csf/portable-stories.ts, code/core/src/preview-api/modules/store/csf/portable-stories.test.ts
Detects and skips prepending core annotations when already present in setProjectAnnotations, with tests validating single injection for both plain and composed preview paths.
ProcessCSFFile integration and validation
code/core/src/preview-api/modules/store/csf/processCSFFile.test.ts
Validates that processCSFFile correctly identifies and preserves composed preview annotations with core annotations applied exactly once.

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs:

  • storybookjs/storybook#33354: Related work on deriving csfFile.projectAnnotations from CSF4 factory preview meta.preview.composed and ensuring composed annotations include core annotations once.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

JReinhold and others added 2 commits June 8, 2026 20:59
…a CSF4 preview

CSF4 (definePreview) previews already compose the core annotations into their
`composed` result, but the StoryStore constructor and the portable
`setProjectAnnotations` both unconditionally prepended core again. This doubled
core decorators/loaders/beforeEach/beforeAll for non-factory (CSF1/2/3) stories
that fall back to `store.projectAnnotations`, and for the addon-vitest setup path.

Flag the composed result with a `Symbol.for` marker (stable across the dual
ESM/CJS module instances in dev) and skip prepending core when it is present.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fix oxfmt formatting in StoryStore.test.ts and align definePreview test
fixtures with renderToCanvas typing used elsewhere in the codebase.

Co-authored-by: Cursor <cursoragent@cursor.com>
@JReinhold JReinhold force-pushed the jeppe-cursor/a236965c branch from 41c39f6 to c9b1aea Compare June 8, 2026 18:59

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
code/core/src/csf/core-annotations.ts (1)

51-56: 💤 Low value

Consider hardening the marker property descriptor.

The configurable: true and writable: true flags allow the marker to be deleted or modified after creation. Since this is an internal marker for deduplication logic, you could prevent tampering by using configurable: false, writable: false instead.

That said, the current approach is acceptable for controlled internal use and provides flexibility for testing or future adjustments.

🤖 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/csf/core-annotations.ts` around lines 51 - 56, The property
descriptor for the internal deduplication marker (CORE_ANNOTATIONS_COMPOSED) is
currently created on the annotations object via Object.defineProperty with
configurable: true and writable: true; change this to configurable: false and
writable: false to prevent accidental deletion or modification at runtime while
still keeping enumerable: false and configurable only during definition; update
the Object.defineProperty call that sets CORE_ANNOTATIONS_COMPOSED on
annotations accordingly.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@code/core/src/csf/core-annotations.ts`:
- Around line 51-56: The property descriptor for the internal deduplication
marker (CORE_ANNOTATIONS_COMPOSED) is currently created on the annotations
object via Object.defineProperty with configurable: true and writable: true;
change this to configurable: false and writable: false to prevent accidental
deletion or modification at runtime while still keeping enumerable: false and
configurable only during definition; update the Object.defineProperty call that
sets CORE_ANNOTATIONS_COMPOSED on annotations accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 88caaeeb-6be0-40b6-b37e-932fbaa46124

📥 Commits

Reviewing files that changed from the base of the PR and between 8035532 and c9b1aea.

📒 Files selected for processing (9)
  • code/core/src/csf/core-annotations.test.ts
  • code/core/src/csf/core-annotations.ts
  • code/core/src/csf/csf-factories.test.ts
  • code/core/src/csf/csf-factories.ts
  • code/core/src/preview-api/modules/store/StoryStore.test.ts
  • code/core/src/preview-api/modules/store/StoryStore.ts
  • code/core/src/preview-api/modules/store/csf/portable-stories.test.ts
  • code/core/src/preview-api/modules/store/csf/portable-stories.ts
  • code/core/src/preview-api/modules/store/csf/processCSFFile.test.ts
✅ Files skipped from review due to trivial changes (1)
  • code/core/src/csf/core-annotations.test.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • code/core/src/csf/csf-factories.test.ts
  • code/core/src/preview-api/modules/store/csf/processCSFFile.test.ts
  • code/core/src/preview-api/modules/store/csf/portable-stories.ts
  • code/core/src/preview-api/modules/store/StoryStore.test.ts
  • code/core/src/preview-api/modules/store/StoryStore.ts
  • code/core/src/csf/csf-factories.ts
  • code/core/src/preview-api/modules/store/csf/portable-stories.test.ts

@yannbf yannbf left a comment

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.

LGTM, though it seems like the logic is a little scattered and might be easy to miss a couple places if we need to modify the core annotations logic. I don't have a good solution though

@JReinhold JReinhold merged commit a2971d9 into next Jun 9, 2026
143 of 144 checks passed
@JReinhold JReinhold deleted the jeppe-cursor/a236965c branch June 9, 2026 19:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug ci:normal Run our default set of CI jobs (choose this for most PRs). patch:yes Bugfix & documentation PR that need to be picked to main branch preview-api qa:needed Pull Requests that will need manual QA prior to release.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants