Skip to content
Open
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
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@
"start": "cross-env NODE_ENV=storybook storybook dev -p 9003 --ci -c '.storybook'",
"build:storybook": "storybook build -c .storybook -o dist/$(git rev-parse HEAD)/storybook",
"start:chromatic": "CHROMATIC=1 NODE_ENV=storybook storybook dev -p 9004 --ci -c '.chromatic'",
"build:chromatic": "CHROMATIC=1 storybook build -c .chromatic -o dist/$(git rev-parse HEAD)/chromatic",
"build:chromatic": "CHROMATIC=1 storybook build -c .chromatic -o dist/$(git rev-parse HEAD)/chromatic --stats-json",
"start:chromatic-fc": "CHROMATIC=1 NODE_ENV=storybook storybook dev -p 9005 --ci -c '.chromatic-fc'",
"build:chromatic-fc": "CHROMATIC=1 storybook build -c .chromatic-fc -o dist/$(git rev-parse HEAD)/chromatic-fc",
"build:chromatic-fc": "CHROMATIC=1 storybook build -c .chromatic-fc -o dist/$(git rev-parse HEAD)/chromatic-fc --stats-json",
"start:s2": "NODE_ENV=storybook storybook dev -p 6006 --ci -c '.storybook-s2'",
"build:storybook-s2": "NODE_ENV=storybook storybook build -c .storybook-s2 -o dist/$(git rev-parse HEAD)/storybook-s2",
"build:s2-storybook-docs": "NODE_ENV=storybook storybook build -c .storybook-s2 --docs",
Expand Down Expand Up @@ -62,8 +62,8 @@
"build:icons": "babel-node --presets @babel/env ./scripts/buildIcons.js",
"clean:icons": "babel-node --presets @babel/env ./scripts/cleanIcons.js",
"postinstall": "patch-package && yarn build:icons",
"chromatic": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_PROJECT_TOKEN --build-script-name 'build:chromatic'",
"chromatic:forced-colors": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_FC_PROJECT_TOKEN --build-script-name 'build:chromatic-fc'",
"chromatic": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_PROJECT_TOKEN --build-script-name 'build:chromatic' --only-changed --trace-changed --externals './packages/**/style/**/*'",
"chromatic:forced-colors": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_FC_PROJECT_TOKEN --build-script-name 'build:chromatic-fc' --only-changed --trace-changed --externals './packages/**/style/**/*'",
"merge:css": "babel-node --presets @babel/env ./scripts/merge-spectrum-css.js",
"release": "lerna publish from-package --yes",
"version:nightly": "yarn workspaces foreach --all --no-private -t version -d 3.0.0-nightly-$(git rev-parse --short HEAD)-$(date +'%y%m%d') && yarn apply-nightly --all",
Expand Down Expand Up @@ -142,7 +142,7 @@
"babel-plugin-react-remove-properties": "^0.3.0",
"babel-plugin-transform-glob-import": "^1.0.1",
"chalk": "^4.1.2",
"chromatic": "^15.0.0",
"chromatic": "^17.0.0",
"clsx": "^2.0.0",
"color-space": "^1.16.0",
"concurrently": "^6.0.2",
Expand Down
25 changes: 25 additions & 0 deletions packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {addStoryEntries, buildStatsMap, rewriteStoryVirtuals, writeStats} from './helpers';
import {Reporter} from '@parcel/plugin';

const reporter = new Reporter({
async report({event, options, logger}) {
if (event.type !== 'buildSuccess') return;

const statsMap = buildStatsMap(event.bundleGraph, options.projectRoot);
rewriteStoryVirtuals(statsMap);
addStoryEntries(statsMap, logger);

const bundles = event.bundleGraph.getBundles();
const distDir = bundles[0]?.target.distDir;
if (!distDir) {
throw new Error(
'parcel-reporter-turbosnap-stats: no bundles were produced; cannot determine output dir.'
);
}
await writeStats(distDir, statsMap, options.outputFS, logger);
}
});

// Parcel's plugin loader expects `module.exports = <pluginInstance>`,
// not the `.default` wrapper TypeScript would otherwise produce.
module.exports = reporter;
220 changes: 220 additions & 0 deletions packages/dev/parcel-reporter-turbosnap-stats/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// Helpers for parcel-reporter-turbosnap-stats. See ./StatsReporter.ts for the
// plugin entry; this file holds the pure functions exported for unit testing.

import type {Asset, BundleGraph, FileSystem} from '@parcel/types';
import path from 'path';

// TurboSnap may still report 0% reuse for reasons outside this reporter's control:
// 1. Lockfile-only diff with no node_modules in stats — we DO include node_modules,
// but filter @parcel/runtime-* and react/jsx-runtime (mirrors builder-vite). If
// a react upgrade fails to propagate, this filter is the suspect.
// 2. Changes under .storybook/ or .chromatic/ — chromatic-cli treats these as
// Storybook-config changes and bails to full snapshot. By design.
// 3. Changes under any configured staticDir — same bail.
// See chromatic-cli node-src/lib/turbosnap/getDependentStoryFiles.ts lines 250-269.

export interface Reason {
moduleName: string;
}
export interface Module {
id: string;
name: string;
reasons: Reason[];
}

export function stripQueryParams(id: string): string {
const idx = id.indexOf('?');
return idx === -1 ? id : id.slice(0, idx);
}

export function normalize(filePath: string, projectRoot: string): string {
const stripped = stripQueryParams(filePath);
// Convert backslashes to forward slashes regardless of platform —
// path.sep is '/' on Mac/Linux so .split(path.sep) wouldn't catch literal
// backslashes inside an input string. Universal replace avoids the gap.
const rel = path.relative(projectRoot, stripped).replace(/\\/g, '/');
return './' + rel;
}

// Filter Parcel runtime chunks (path may be bare "@parcel/runtime-*" or
// normalized "./node_modules/@parcel/runtime-*"). Also filter the React JSX
// runtime — mirrors builder-vite's filter; means React-version bumps won't
// propagate via stats, but avoids every JSX file having identical noisy reasons.
const FILTER_PATTERNS: RegExp[] = [/@parcel\/runtime-/, /\/react\/jsx-runtime\.js$/];

export function isUserCode(name: string): boolean {
for (const re of FILTER_PATTERNS) {
if (re.test(name)) return false;
}
return true;
}

const STORY_VIRTUAL_RE = /\/storybook-builder-parcel\/generated-entries\/stories\.js$/;
const CANONICAL_CSF_GLOB = './storybook-stories.js';

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.

Any idea where this comes from?

@snowystinger snowystinger May 29, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Looks like it comes from this: https://github.com/chromaui/chromatic-cli/blob/f409b3d6900c77263cc8558fe59bc79ba7d28359/node-src/lib/turbosnap/getDependentStoryFiles.ts#L122

Which, the comment at the top seems to imply it's just needed and Claude claims this "TurboSnap won't work unless some module in preview-stats.json references one of those exact strings as a reasons[].moduleName."


export function rewriteStoryVirtuals(statsMap: Map<string, Module>): void {
for (const [oldName, entry] of [...statsMap]) {
if (!STORY_VIRTUAL_RE.test(oldName)) continue;
statsMap.delete(oldName);
entry.id = CANONICAL_CSF_GLOB;
entry.name = CANONICAL_CSF_GLOB;
const existing = statsMap.get(CANONICAL_CSF_GLOB);
if (existing) {
for (const r of entry.reasons) {
if (existing.reasons.every(x => x.moduleName !== r.moduleName)) {
existing.reasons.push(r);
}
}
} else {
statsMap.set(CANONICAL_CSF_GLOB, entry);
}
}
for (const entry of statsMap.values()) {
for (const reason of entry.reasons) {
if (STORY_VIRTUAL_RE.test(reason.moduleName)) {
reason.moduleName = CANONICAL_CSF_GLOB;
}
}
}
}

export function buildStatsMap(
bundleGraph: BundleGraph<any>,
projectRoot: string
): Map<string, Module> {
const statsMap = new Map<string, Module>();
const ensure = (name: string): Module => {
let entry = statsMap.get(name);
if (!entry) {
entry = {id: name, name, reasons: []};
statsMap.set(name, entry);
}
return entry;
};
const seen = new Set<string>();

for (const bundle of bundleGraph.getBundles()) {
bundle.traverseAssets((asset: Asset) => {
if (seen.has(asset.id)) return;
seen.add(asset.id);

const assetName = normalize(asset.filePath, projectRoot);
if (!isUserCode(assetName)) return;
ensure(assetName);

for (const dep of bundleGraph.getDependencies(asset)) {
// resolveAsyncDependency unwraps Parcel's @parcel/runtime-js code-splitting
// wrappers for `() => import('...')` deps so the edge points at the real
// target asset (e.g. ./Foo.stories.tsx) instead of the runtime chunk.
// Returns null for sync deps; fall back to getResolvedAsset there.
const asyncResult = bundleGraph.resolveAsyncDependency(dep, bundle);
let target: Asset | null | undefined;
if (asyncResult) {
target =
asyncResult.type === 'asset'
? asyncResult.value
: bundleGraph.getAssetById(asyncResult.value.entryAssetId);
} else {
target = bundleGraph.getResolvedAsset(dep, bundle);
}
if (!target) continue;
const depName = normalize(target.filePath, projectRoot);
if (!isUserCode(depName)) continue;
// Skip self-edges. Parcel sometimes emits multiple Asset objects for the
// same source file (e.g., a transformer's sibling output, HMR runtime
// injection), giving them distinct asset.id values but identical filePath.
// Without this guard those collapse into "TagGroup.tsx is a reason for
// TagGroup.tsx" entries — harmless (chromatic-cli filters them at
// getDependentStoryFiles.ts:169) but noisy in the emitted JSON.
if (depName === assetName) continue;
const entry = ensure(depName);
if (entry.reasons.every(r => r.moduleName !== assetName)) {
entry.reasons.push({moduleName: assetName});
}
}
});
}
return statsMap;
}

const CSF_GLOB_ENTRY = './parcel-csf-glob.js';

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.

where does this come from?

@snowystinger snowystinger May 29, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Apparently from https://github.com/chromaui/chromatic-cli/blob/v17.0.1/node-src/lib/turbosnap/getDependentStoryFiles.ts#L175-L181
I'm having trouble following it through this chain, but this is what Claude claims.

With the CSF_GLOB_ENTRY:

  • ./parcel-csf-glob.js has reasons=[./storybook-stories.js] → classified as CSF glob ✓
  • ./TagGroup.stories.tsx has reasons=[./parcel-csf-glob.js] → does NOT match any candidate prefix → NOT a CSF glob
  • traceName('TagGroup.ts') → reasons include TagGroup.stories.tsx, which has a CSF-glob reason → TagGroup.stories.tsx added to affectedModuleIds ✓

Without CSF_GLOB_ENTRY, pointing story files directly at ./storybook-stories.js:

  • ./TagGroup.stories.tsx has reasons=[./storybook-stories.js] → matches candidate prefix → classified as CSF glob
  • L286 (https://github.com/chromaui/chromatic-cli/blob/v17.0.1/node-src/lib/turbosnap/getDependentStoryFiles.ts#L286): traceName returns early when name is a CSF
    glob, so TagGroup.stories.tsx is never added to affectedModuleIds
  • Instead TagGroup.ts ends up in affectedModuleIds (its reasons include a CSF glob). chromatic-cli's storyIndex is keyed by story-file paths, not source paths →
    no match → 0 stories affected (which is exactly the symptom you'd see)


// chromatic-cli's getDependentStoryFiles expects this three-level chain:
//
// ./storybook-stories.js ← (CSF entry, imported by preview-main.js)
// ↓ imports
// ./parcel-csf-glob.js ← reasons=[storybook-stories.js] → identified as the CSF glob
// ↓ imports
// ./Foo.stories.tsx ← reasons=[parcel-csf-glob.js] → added to affectedModuleIds
//
// We discover story files structurally: after buildStatsMap (with resolveAsyncDependency)
// and rewriteStoryVirtuals, every story file has './storybook-stories.js' as a reason.
// We rewrite that reason to point at the synthetic ./parcel-csf-glob.js instead.
//
// Pointing story files directly at './storybook-stories.js' would make THEM the
// CSF globs (per getDependentStoryFiles.ts:175-181), causing traceName to bail
// at the story file (line 286) and source files (not story files) to end up
// in affectedModuleIds — which chromatic then can't match to storyIndex entries.
export function addStoryEntries(statsMap: Map<string, Module>, logger?: Logger): number {
let tagged = 0;
for (const entry of statsMap.values()) {
if (entry.name === CSF_GLOB_ENTRY) continue;
let rewritten = false;
for (const reason of entry.reasons) {
if (reason.moduleName === CANONICAL_CSF_GLOB) {
reason.moduleName = CSF_GLOB_ENTRY;
rewritten = true;
}
}
if (rewritten) tagged++;
}
if (tagged > 0 && !statsMap.has(CSF_GLOB_ENTRY)) {
statsMap.set(CSF_GLOB_ENTRY, {
id: CSF_GLOB_ENTRY,
name: CSF_GLOB_ENTRY,
reasons: [{moduleName: CANONICAL_CSF_GLOB}]
});
}
logger?.info({
message: `parcel-reporter-turbosnap-stats: tagged ${tagged} story file(s) via synthetic CSF glob`
});
return tagged;
}

interface Logger {
info: (m: {message: string}) => void;
}

export async function writeStats(
distDir: string,
statsMap: Map<string, Module>,
outputFS: FileSystem,
logger: Logger
): Promise<void> {
// Sort modules by name so the emitted JSON is byte-stable across Parcel
// versions even if bundle.traverseAssets order shifts. chromatic-cli doesn't
// care about order; this only helps reproducibility for caching/diff use cases.
const modules = [...statsMap.values()].sort((a, b) => a.name.localeCompare(b.name));
const stats = {modules};

if (stats.modules.length === 0) {
throw new Error(
'parcel-reporter-turbosnap-stats: empty modules array — nothing was traversed.'
);
}
const hasCsfGlob = stats.modules.some(m =>
m.reasons.some(r => r.moduleName === CANONICAL_CSF_GLOB)
);
if (!hasCsfGlob) {
throw new Error(
'parcel-reporter-turbosnap-stats: no module references ./storybook-stories.js as a reason. ' +
'chromatic-cli will hard-error with "Did not find any CSF globs in preview-stats.json". ' +
'Check that parcel-resolver-storybook generated a stories.js virtual and STORY_VIRTUAL_RE matches its filePath.'
);
}

await outputFS.writeFile(path.join(distDir, 'preview-stats.json'), JSON.stringify(stats), null);
logger.info({
message: `parcel-reporter-turbosnap-stats: wrote preview-stats.json (${stats.modules.length} modules) to ${distDir}`
});
}
1 change: 1 addition & 0 deletions packages/dev/parcel-reporter-turbosnap-stats/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('./StatsReporter.ts');
21 changes: 21 additions & 0 deletions packages/dev/parcel-reporter-turbosnap-stats/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@parcel/reporter-turbosnap-stats",
"version": "0.0.0",
"private": true,
"source": "StatsReporter.ts",
"main": "dist/StatsReporter.js",
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "rm -rf dist && swc . -d dist --config-file ../../.swcrc",
"clean": "rm -rf dist"
},
"dependencies": {
"@parcel/plugin": "^2.16.3",
"@parcel/types": "^2.16.3"
},
"engines": {
"parcel": "^2.8.0"
}
}
1 change: 1 addition & 0 deletions packages/dev/storybook-builder-parcel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dependencies": {
"@parcel/core": "^2.16.3",
"@parcel/reporter-cli": "^2.16.3",
"@parcel/reporter-turbosnap-stats": "0.0.0",
"@parcel/utils": "^2.16.3",
"http-proxy-middleware": "^2.0.6",
"storybook": "^10.0.0"
Expand Down
12 changes: 11 additions & 1 deletion packages/dev/storybook-builder-parcel/preset.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,17 @@ async function createParcel(options, isDev = false) {
mode: isDev ? 'development' : 'production',
serveOptions: isDev ? {port: 3000} : null,
hmrOptions: isDev ? {port: 3001} : null,
additionalReporters: [{packageName: '@parcel/reporter-cli', resolveFrom: __filename}],
additionalReporters: [
{packageName: '@parcel/reporter-cli', resolveFrom: __filename},
...(options.statsJson
? [
{
packageName: '@parcel/reporter-turbosnap-stats',
resolveFrom: __filename
}
]
: [])
],
targets: {
storybook: {
distDir: options.outputDir,
Expand Down
Loading