Skip to content
Merged
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 @@ -63,8 +63,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 @@ -143,7 +143,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';

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';

// 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
Loading