Skip to content

Commit 29aabd4

Browse files
chore: Chromatic turbosnap (adobe#10125)
* upgrade chromatic cli * chore: enable turbosnap for chromatic * fix lint and tests * testing more * finish debugging, new test that should result in a visual difference * revert and fixes * try again * create a different change * fix the color back to normal * cleanup * fix test * change file writing and add externals * update from code review * fix format * remove tests, they were mostly for claude --------- Co-authored-by: Yihui Liao <44729383+yihuiliao@users.noreply.github.com>
1 parent 898c50f commit 29aabd4

8 files changed

Lines changed: 308 additions & 15 deletions

File tree

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@
3232
"start": "cross-env NODE_ENV=storybook storybook dev -p 9003 --ci -c '.storybook'",
3333
"build:storybook": "storybook build -c .storybook -o dist/$(git rev-parse HEAD)/storybook",
3434
"start:chromatic": "CHROMATIC=1 NODE_ENV=storybook storybook dev -p 9004 --ci -c '.chromatic'",
35-
"build:chromatic": "CHROMATIC=1 storybook build -c .chromatic -o dist/$(git rev-parse HEAD)/chromatic",
35+
"build:chromatic": "CHROMATIC=1 storybook build -c .chromatic -o dist/$(git rev-parse HEAD)/chromatic --stats-json",
3636
"start:chromatic-fc": "CHROMATIC=1 NODE_ENV=storybook storybook dev -p 9005 --ci -c '.chromatic-fc'",
37-
"build:chromatic-fc": "CHROMATIC=1 storybook build -c .chromatic-fc -o dist/$(git rev-parse HEAD)/chromatic-fc",
37+
"build:chromatic-fc": "CHROMATIC=1 storybook build -c .chromatic-fc -o dist/$(git rev-parse HEAD)/chromatic-fc --stats-json",
3838
"start:s2": "NODE_ENV=storybook storybook dev -p 6006 --ci -c '.storybook-s2'",
3939
"build:storybook-s2": "NODE_ENV=storybook storybook build -c .storybook-s2 -o dist/$(git rev-parse HEAD)/storybook-s2",
4040
"build:s2-storybook-docs": "NODE_ENV=storybook storybook build -c .storybook-s2 --docs",
@@ -63,8 +63,8 @@
6363
"build:icons": "babel-node --presets @babel/env ./scripts/buildIcons.js",
6464
"clean:icons": "babel-node --presets @babel/env ./scripts/cleanIcons.js",
6565
"postinstall": "patch-package && yarn build:icons",
66-
"chromatic": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_PROJECT_TOKEN --build-script-name 'build:chromatic'",
67-
"chromatic:forced-colors": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_FC_PROJECT_TOKEN --build-script-name 'build:chromatic-fc'",
66+
"chromatic": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_PROJECT_TOKEN --build-script-name 'build:chromatic' --only-changed --trace-changed --externals './packages/**/style/**/*'",
67+
"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/**/*'",
6868
"merge:css": "babel-node --presets @babel/env ./scripts/merge-spectrum-css.js",
6969
"release": "lerna publish from-package --yes",
7070
"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",
@@ -143,7 +143,7 @@
143143
"babel-plugin-react-remove-properties": "^0.3.0",
144144
"babel-plugin-transform-glob-import": "^1.0.1",
145145
"chalk": "^4.1.2",
146-
"chromatic": "^15.0.0",
146+
"chromatic": "^17.0.0",
147147
"clsx": "^2.0.0",
148148
"color-space": "^1.16.0",
149149
"concurrently": "^6.0.2",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {addStoryEntries, buildStatsMap, rewriteStoryVirtuals, writeStats} from './helpers';
2+
import {Reporter} from '@parcel/plugin';
3+
4+
const reporter = new Reporter({
5+
async report({event, options, logger}) {
6+
if (event.type !== 'buildSuccess') return;
7+
8+
const statsMap = buildStatsMap(event.bundleGraph, options.projectRoot);
9+
rewriteStoryVirtuals(statsMap);
10+
addStoryEntries(statsMap, logger);
11+
12+
const bundles = event.bundleGraph.getBundles();
13+
const distDir = bundles[0]?.target.distDir;
14+
if (!distDir) {
15+
throw new Error(
16+
'parcel-reporter-turbosnap-stats: no bundles were produced; cannot determine output dir.'
17+
);
18+
}
19+
await writeStats(distDir, statsMap, options.outputFS, logger);
20+
}
21+
});
22+
23+
// Parcel's plugin loader expects `module.exports = <pluginInstance>`,
24+
// not the `.default` wrapper TypeScript would otherwise produce.
25+
module.exports = reporter;
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Helpers for parcel-reporter-turbosnap-stats. See ./StatsReporter.ts for the
2+
// plugin entry; this file holds the pure functions exported for unit testing.
3+
4+
import type {Asset, BundleGraph, FileSystem} from '@parcel/types';
5+
import path from 'path';
6+
7+
// TurboSnap may still report 0% reuse for reasons outside this reporter's control:
8+
// 1. Lockfile-only diff with no node_modules in stats — we DO include node_modules,
9+
// but filter @parcel/runtime-* and react/jsx-runtime (mirrors builder-vite). If
10+
// a react upgrade fails to propagate, this filter is the suspect.
11+
// 2. Changes under .storybook/ or .chromatic/ — chromatic-cli treats these as
12+
// Storybook-config changes and bails to full snapshot. By design.
13+
// 3. Changes under any configured staticDir — same bail.
14+
// See chromatic-cli node-src/lib/turbosnap/getDependentStoryFiles.ts lines 250-269.
15+
16+
export interface Reason {
17+
moduleName: string;
18+
}
19+
export interface Module {
20+
id: string;
21+
name: string;
22+
reasons: Reason[];
23+
}
24+
25+
export function stripQueryParams(id: string): string {
26+
const idx = id.indexOf('?');
27+
return idx === -1 ? id : id.slice(0, idx);
28+
}
29+
30+
export function normalize(filePath: string, projectRoot: string): string {
31+
const stripped = stripQueryParams(filePath);
32+
// Convert backslashes to forward slashes regardless of platform —
33+
// path.sep is '/' on Mac/Linux so .split(path.sep) wouldn't catch literal
34+
// backslashes inside an input string. Universal replace avoids the gap.
35+
const rel = path.relative(projectRoot, stripped).replace(/\\/g, '/');
36+
return './' + rel;
37+
}
38+
39+
// Filter Parcel runtime chunks (path may be bare "@parcel/runtime-*" or
40+
// normalized "./node_modules/@parcel/runtime-*"). Also filter the React JSX
41+
// runtime — mirrors builder-vite's filter; means React-version bumps won't
42+
// propagate via stats, but avoids every JSX file having identical noisy reasons.
43+
const FILTER_PATTERNS: RegExp[] = [/@parcel\/runtime-/, /\/react\/jsx-runtime\.js$/];
44+
45+
export function isUserCode(name: string): boolean {
46+
for (const re of FILTER_PATTERNS) {
47+
if (re.test(name)) return false;
48+
}
49+
return true;
50+
}
51+
52+
const STORY_VIRTUAL_RE = /\/storybook-builder-parcel\/generated-entries\/stories\.js$/;
53+
const CANONICAL_CSF_GLOB = './storybook-stories.js';
54+
55+
export function rewriteStoryVirtuals(statsMap: Map<string, Module>): void {
56+
for (const [oldName, entry] of [...statsMap]) {
57+
if (!STORY_VIRTUAL_RE.test(oldName)) continue;
58+
statsMap.delete(oldName);
59+
entry.id = CANONICAL_CSF_GLOB;
60+
entry.name = CANONICAL_CSF_GLOB;
61+
const existing = statsMap.get(CANONICAL_CSF_GLOB);
62+
if (existing) {
63+
for (const r of entry.reasons) {
64+
if (existing.reasons.every(x => x.moduleName !== r.moduleName)) {
65+
existing.reasons.push(r);
66+
}
67+
}
68+
} else {
69+
statsMap.set(CANONICAL_CSF_GLOB, entry);
70+
}
71+
}
72+
for (const entry of statsMap.values()) {
73+
for (const reason of entry.reasons) {
74+
if (STORY_VIRTUAL_RE.test(reason.moduleName)) {
75+
reason.moduleName = CANONICAL_CSF_GLOB;
76+
}
77+
}
78+
}
79+
}
80+
81+
export function buildStatsMap(
82+
bundleGraph: BundleGraph<any>,
83+
projectRoot: string
84+
): Map<string, Module> {
85+
const statsMap = new Map<string, Module>();
86+
const ensure = (name: string): Module => {
87+
let entry = statsMap.get(name);
88+
if (!entry) {
89+
entry = {id: name, name, reasons: []};
90+
statsMap.set(name, entry);
91+
}
92+
return entry;
93+
};
94+
const seen = new Set<string>();
95+
96+
for (const bundle of bundleGraph.getBundles()) {
97+
bundle.traverseAssets((asset: Asset) => {
98+
if (seen.has(asset.id)) return;
99+
seen.add(asset.id);
100+
101+
const assetName = normalize(asset.filePath, projectRoot);
102+
if (!isUserCode(assetName)) return;
103+
ensure(assetName);
104+
105+
for (const dep of bundleGraph.getDependencies(asset)) {
106+
// resolveAsyncDependency unwraps Parcel's @parcel/runtime-js code-splitting
107+
// wrappers for `() => import('...')` deps so the edge points at the real
108+
// target asset (e.g. ./Foo.stories.tsx) instead of the runtime chunk.
109+
// Returns null for sync deps; fall back to getResolvedAsset there.
110+
const asyncResult = bundleGraph.resolveAsyncDependency(dep, bundle);
111+
let target: Asset | null | undefined;
112+
if (asyncResult) {
113+
target =
114+
asyncResult.type === 'asset'
115+
? asyncResult.value
116+
: bundleGraph.getAssetById(asyncResult.value.entryAssetId);
117+
} else {
118+
target = bundleGraph.getResolvedAsset(dep, bundle);
119+
}
120+
if (!target) continue;
121+
const depName = normalize(target.filePath, projectRoot);
122+
if (!isUserCode(depName)) continue;
123+
// Skip self-edges. Parcel sometimes emits multiple Asset objects for the
124+
// same source file (e.g., a transformer's sibling output, HMR runtime
125+
// injection), giving them distinct asset.id values but identical filePath.
126+
// Without this guard those collapse into "TagGroup.tsx is a reason for
127+
// TagGroup.tsx" entries — harmless (chromatic-cli filters them at
128+
// getDependentStoryFiles.ts:169) but noisy in the emitted JSON.
129+
if (depName === assetName) continue;
130+
const entry = ensure(depName);
131+
if (entry.reasons.every(r => r.moduleName !== assetName)) {
132+
entry.reasons.push({moduleName: assetName});
133+
}
134+
}
135+
});
136+
}
137+
return statsMap;
138+
}
139+
140+
const CSF_GLOB_ENTRY = './parcel-csf-glob.js';
141+
142+
// chromatic-cli's getDependentStoryFiles expects this three-level chain:
143+
//
144+
// ./storybook-stories.js ← (CSF entry, imported by preview-main.js)
145+
// ↓ imports
146+
// ./parcel-csf-glob.js ← reasons=[storybook-stories.js] → identified as the CSF glob
147+
// ↓ imports
148+
// ./Foo.stories.tsx ← reasons=[parcel-csf-glob.js] → added to affectedModuleIds
149+
//
150+
// We discover story files structurally: after buildStatsMap (with resolveAsyncDependency)
151+
// and rewriteStoryVirtuals, every story file has './storybook-stories.js' as a reason.
152+
// We rewrite that reason to point at the synthetic ./parcel-csf-glob.js instead.
153+
//
154+
// Pointing story files directly at './storybook-stories.js' would make THEM the
155+
// CSF globs (per getDependentStoryFiles.ts:175-181), causing traceName to bail
156+
// at the story file (line 286) and source files (not story files) to end up
157+
// in affectedModuleIds — which chromatic then can't match to storyIndex entries.
158+
export function addStoryEntries(statsMap: Map<string, Module>, logger?: Logger): number {
159+
let tagged = 0;
160+
for (const entry of statsMap.values()) {
161+
if (entry.name === CSF_GLOB_ENTRY) continue;
162+
let rewritten = false;
163+
for (const reason of entry.reasons) {
164+
if (reason.moduleName === CANONICAL_CSF_GLOB) {
165+
reason.moduleName = CSF_GLOB_ENTRY;
166+
rewritten = true;
167+
}
168+
}
169+
if (rewritten) tagged++;
170+
}
171+
if (tagged > 0 && !statsMap.has(CSF_GLOB_ENTRY)) {
172+
statsMap.set(CSF_GLOB_ENTRY, {
173+
id: CSF_GLOB_ENTRY,
174+
name: CSF_GLOB_ENTRY,
175+
reasons: [{moduleName: CANONICAL_CSF_GLOB}]
176+
});
177+
}
178+
logger?.info({
179+
message: `parcel-reporter-turbosnap-stats: tagged ${tagged} story file(s) via synthetic CSF glob`
180+
});
181+
return tagged;
182+
}
183+
184+
interface Logger {
185+
info: (m: {message: string}) => void;
186+
}
187+
188+
export async function writeStats(
189+
distDir: string,
190+
statsMap: Map<string, Module>,
191+
outputFS: FileSystem,
192+
logger: Logger
193+
): Promise<void> {
194+
// Sort modules by name so the emitted JSON is byte-stable across Parcel
195+
// versions even if bundle.traverseAssets order shifts. chromatic-cli doesn't
196+
// care about order; this only helps reproducibility for caching/diff use cases.
197+
const modules = [...statsMap.values()].sort((a, b) => a.name.localeCompare(b.name));
198+
const stats = {modules};
199+
200+
if (stats.modules.length === 0) {
201+
throw new Error(
202+
'parcel-reporter-turbosnap-stats: empty modules array — nothing was traversed.'
203+
);
204+
}
205+
const hasCsfGlob = stats.modules.some(m =>
206+
m.reasons.some(r => r.moduleName === CANONICAL_CSF_GLOB)
207+
);
208+
if (!hasCsfGlob) {
209+
throw new Error(
210+
'parcel-reporter-turbosnap-stats: no module references ./storybook-stories.js as a reason. ' +
211+
'chromatic-cli will hard-error with "Did not find any CSF globs in preview-stats.json". ' +
212+
'Check that parcel-resolver-storybook generated a stories.js virtual and STORY_VIRTUAL_RE matches its filePath.'
213+
);
214+
}
215+
216+
await outputFS.writeFile(path.join(distDir, 'preview-stats.json'), JSON.stringify(stats), null);
217+
logger.info({
218+
message: `parcel-reporter-turbosnap-stats: wrote preview-stats.json (${stats.modules.length} modules) to ${distDir}`
219+
});
220+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('./StatsReporter.ts');
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@parcel/reporter-turbosnap-stats",
3+
"version": "0.0.0",
4+
"private": true,
5+
"source": "StatsReporter.ts",
6+
"main": "dist/StatsReporter.js",
7+
"publishConfig": {
8+
"access": "public"
9+
},
10+
"scripts": {
11+
"build": "rm -rf dist && swc . -d dist --config-file ../../.swcrc",
12+
"clean": "rm -rf dist"
13+
},
14+
"dependencies": {
15+
"@parcel/plugin": "^2.16.3",
16+
"@parcel/types": "^2.16.3"
17+
},
18+
"engines": {
19+
"parcel": "^2.8.0"
20+
}
21+
}

packages/dev/storybook-builder-parcel/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"dependencies": {
1616
"@parcel/core": "^2.16.3",
1717
"@parcel/reporter-cli": "^2.16.3",
18+
"@parcel/reporter-turbosnap-stats": "0.0.0",
1819
"@parcel/utils": "^2.16.3",
1920
"http-proxy-middleware": "^2.0.6",
2021
"storybook": "^10.0.0"

packages/dev/storybook-builder-parcel/preset.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,17 @@ async function createParcel(options, isDev = false) {
123123
mode: isDev ? 'development' : 'production',
124124
serveOptions: isDev ? {port: 3000} : null,
125125
hmrOptions: isDev ? {port: 3001} : null,
126-
additionalReporters: [{packageName: '@parcel/reporter-cli', resolveFrom: __filename}],
126+
additionalReporters: [
127+
{packageName: '@parcel/reporter-cli', resolveFrom: __filename},
128+
...(options.statsJson
129+
? [
130+
{
131+
packageName: '@parcel/reporter-turbosnap-stats',
132+
resolveFrom: __filename
133+
}
134+
]
135+
: [])
136+
],
127137
targets: {
128138
storybook: {
129139
distDir: options.outputDir,

0 commit comments

Comments
 (0)