diff --git a/package.json b/package.json index 63938ee5375..c3cc7195db2 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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", diff --git a/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts b/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts new file mode 100644 index 00000000000..8efb6d57376 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts @@ -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 = `, +// not the `.default` wrapper TypeScript would otherwise produce. +module.exports = reporter; diff --git a/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts new file mode 100644 index 00000000000..83964803597 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts @@ -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): 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, + projectRoot: string +): Map { + const statsMap = new Map(); + 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(); + + 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, 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, + outputFS: FileSystem, + logger: Logger +): Promise { + // 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}` + }); +} diff --git a/packages/dev/parcel-reporter-turbosnap-stats/index.js b/packages/dev/parcel-reporter-turbosnap-stats/index.js new file mode 100644 index 00000000000..67db41c2448 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/index.js @@ -0,0 +1 @@ +module.exports = require('./StatsReporter.ts'); diff --git a/packages/dev/parcel-reporter-turbosnap-stats/package.json b/packages/dev/parcel-reporter-turbosnap-stats/package.json new file mode 100644 index 00000000000..5dd0816f4c4 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/package.json @@ -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" + } +} diff --git a/packages/dev/storybook-builder-parcel/package.json b/packages/dev/storybook-builder-parcel/package.json index 13d01f6bfd4..a39767cd0d8 100644 --- a/packages/dev/storybook-builder-parcel/package.json +++ b/packages/dev/storybook-builder-parcel/package.json @@ -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" diff --git a/packages/dev/storybook-builder-parcel/preset.mjs b/packages/dev/storybook-builder-parcel/preset.mjs index 62c699baaec..d8fb65dab92 100644 --- a/packages/dev/storybook-builder-parcel/preset.mjs +++ b/packages/dev/storybook-builder-parcel/preset.mjs @@ -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, diff --git a/yarn.lock b/yarn.lock index e460585af96..3c86198380a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5715,6 +5715,15 @@ __metadata: languageName: node linkType: hard +"@parcel/reporter-turbosnap-stats@npm:0.0.0, @parcel/reporter-turbosnap-stats@workspace:packages/dev/parcel-reporter-turbosnap-stats": + version: 0.0.0-use.local + resolution: "@parcel/reporter-turbosnap-stats@workspace:packages/dev/parcel-reporter-turbosnap-stats" + dependencies: + "@parcel/plugin": "npm:^2.16.3" + "@parcel/types": "npm:^2.16.3" + languageName: unknown + linkType: soft + "@parcel/resolver-default@npm:2.16.3, @parcel/resolver-default@npm:^2.16.3": version: 2.16.3 resolution: "@parcel/resolver-default@npm:2.16.3" @@ -6297,7 +6306,7 @@ __metadata: languageName: node linkType: hard -"@parcel/types@npm:2.16.4": +"@parcel/types@npm:2.16.4, @parcel/types@npm:^2.16.3": version: 2.16.4 resolution: "@parcel/types@npm:2.16.4" dependencies: @@ -14012,22 +14021,27 @@ __metadata: languageName: node linkType: hard -"chromatic@npm:^15.0.0": - version: 15.1.0 - resolution: "chromatic@npm:15.1.0" +"chromatic@npm:^17.0.0": + version: 17.0.1 + resolution: "chromatic@npm:17.0.1" + dependencies: + semver: "npm:^7.3.5" peerDependencies: "@chromatic-com/cypress": ^0.*.* || ^1.0.0 "@chromatic-com/playwright": ^0.*.* || ^1.0.0 + "@chromatic-com/vitest": ^0.*.* || ^1.0.0 peerDependenciesMeta: "@chromatic-com/cypress": optional: true "@chromatic-com/playwright": optional: true + "@chromatic-com/vitest": + optional: true bin: - chroma: dist/bin.js - chromatic: dist/bin.js - chromatic-cli: dist/bin.js - checksum: 10c0/aea449b3c07e599e9b4c1cd866ffa57a5fc6b158b7c1ae4c462f74133869927d0932a077191011bdb841ab81a2dde54b0a35370736ef1986b6854453f01086de + chroma: dist/bin.cjs + chromatic: dist/bin.cjs + chromatic-cli: dist/bin.cjs + checksum: 10c0/bd605a11508a293f1bb4f01b99a52f411a8fa56e74b9a10234e93ed196dcc20281609d5277662da3e70e2af75e30abeb4df2e9ef9b337e8c7eabd46a1b4846cf languageName: node linkType: hard @@ -26637,7 +26651,7 @@ __metadata: babel-plugin-react-remove-properties: "npm:^0.3.0" babel-plugin-transform-glob-import: "npm:^1.0.1" chalk: "npm:^4.1.2" - chromatic: "npm:^15.0.0" + chromatic: "npm:^17.0.0" clsx: "npm:^2.0.0" color-space: "npm:^1.16.0" concurrently: "npm:^6.0.2" @@ -28892,6 +28906,7 @@ __metadata: dependencies: "@parcel/core": "npm:^2.16.3" "@parcel/reporter-cli": "npm:^2.16.3" + "@parcel/reporter-turbosnap-stats": "npm:0.0.0" "@parcel/utils": "npm:^2.16.3" http-proxy-middleware: "npm:^2.0.6" react: "npm:*"