From d28fb3839b1f901da8212ce67d29895aaf0abe39 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 07:26:59 +0200 Subject: [PATCH 01/24] Build: Replace rollup-plugin-dts with rolldown-plugin-dts + tsgo for d.ts generation Single rolldown pass per package with the tsgo declaration emitter replaces the per-entry child-process rollup fan-out. A hybrid resolver keeps the fast oxc resolution but resolves bare, non-external specifiers with TypeScript's module resolution, because oxc does not fall back to @types/* packages and ignores typesVersions (sxzz/rolldown-plugin-dts#130, oxc-resolver#549). Core d.ts generation drops from ~2.2 min to ~5 s. - --dts-bundler flag: rolldown-tsgo (default), rolldown (tsc emitter), rollup (legacy fallback) - --dts-resolver flag: hybrid (default), tsc, oxc - generate-source-files: enumerate globalized-runtime exports statically via rolldown - export QueryFunctions/ServiceRegistryApi/BoxOptions/LogMessageOptions used in public signatures - annotate checklistData and the react-syntax-highlighter deep import so declaration emit stays portable - delete the dead rollup dts() helper in scripts/utils/tools.ts --- .gitignore | 1 + code/core/package.json | 1 + code/core/scripts/generate-source-files.ts | 122 +++-- .../syntaxhighlighter/syntaxhighlighter.tsx | 8 +- code/core/src/manager/globals/globals.ts | 4 +- code/core/src/node-logger/index.ts | 3 +- code/core/src/node-logger/logger/logger.ts | 2 +- .../shared/checklist-store/checklistData.tsx | 2 +- code/core/src/shared/open-service/index.ts | 2 + scripts/build/build-package.ts | 37 +- .../build/utils/generate-types-rolldown.ts | 195 ++++++++ scripts/package.json | 3 + scripts/utils/tools.ts | 50 +-- yarn.lock | 424 ++++++++++++++++++ 14 files changed, 735 insertions(+), 119 deletions(-) create mode 100644 scripts/build/utils/generate-types-rolldown.ts diff --git a/.gitignore b/.gitignore index a289b7e25180..7d1ed0ec2b3d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ test-results .next /.npmrc tsconfig.vitest-temp.json +tsconfig.dts-tmp-*.json .env.local diff --git a/code/core/package.json b/code/core/package.json index 6b7b92f2596a..67d8c3aabb88 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -383,6 +383,7 @@ "react-textarea-autosize": "^8.3.0", "react-transition-state": "^2.3.1", "require-from-string": "^2.0.2", + "rolldown": "^1.1.4", "sirv": "^2.0.4", "slash": "^5.0.0", "source-map": "^0.7.4", diff --git a/code/core/scripts/generate-source-files.ts b/code/core/scripts/generate-source-files.ts index 499d42bf67b0..fe2afceab8db 100644 --- a/code/core/scripts/generate-source-files.ts +++ b/code/core/scripts/generate-source-files.ts @@ -1,50 +1,17 @@ import { existsSync } from 'node:fs'; -import { mkdirSync } from 'node:fs'; -import { readdir, realpath, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +import { createRequire } from 'node:module'; +import { readdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { GlobalRegistrator } from '@happy-dom/global-registrator'; import { isNotNil } from 'es-toolkit/predicate'; -import * as esbuild from 'esbuild'; import { format } from 'oxfmt'; +import { rolldown } from 'rolldown'; import { dedent } from 'ts-dedent'; import { getWorkspace } from '../../../scripts/utils/tools.ts'; -import { - BROWSER_TARGETS, - SUPPORTED_FEATURES, -} from '../src/shared/constants/environments-support.ts'; - -GlobalRegistrator.register({ url: 'http://localhost:3000', width: 1920, height: 1080 }); const CODE_DIR = join(import.meta.dirname, '..', '..', '..', 'code'); const CORE_ROOT_DIR = join(CODE_DIR, 'core'); -const tempDir = () => realpath(os.tmpdir()); -const getPath = async (prefix = '') => - join(await tempDir(), prefix + (Math.random() + 1).toString(36).substring(7)); - -async function temporaryDirectory({ prefix = '' } = {}) { - const directory = await getPath(prefix); - mkdirSync(directory); - return directory; -} -async function temporaryFile({ name, extension }: { name?: string; extension?: string } = {}) { - if (name) { - if (extension !== undefined && extension !== null) { - // eslint-disable-next-line local-rules/no-uncategorized-errors - throw new Error('The `name` and `extension` options are mutually exclusive'); - } - - return join(await temporaryDirectory(), name); - } - - return ( - (await getPath()) + - (extension === undefined || extension === null ? '' : '.' + extension.replace(/^\./, '')) - ); -} // read code/frameworks subfolders and generate a list of available frameworks // save this list into ./code/core/src/types/frameworks.ts and export it as a union type. @@ -135,41 +102,70 @@ const localAlias = { storybook: join(CORE_ROOT_DIR, 'src'), }; async function generateExportsFile(): Promise { - function removeDefault(input: string) { - return input !== 'default'; - } - const destination = join(CORE_ROOT_DIR, 'src', 'manager', 'globals', 'exports.ts'); + const require = createRequire(join(CORE_ROOT_DIR, 'package.json')); - const entryFile = join(CORE_ROOT_DIR, 'src', 'manager', 'globals', 'runtime.ts'); - const outFile = await temporaryFile({ extension: 'js' }); - - await esbuild.build({ - entryPoints: [entryFile], - bundle: true, - format: 'esm', - drop: ['console'], - outfile: outFile, - alias: localAlias, - legalComments: 'none', - splitting: false, + // Get the module list from the globals reference map (the source of truth) + const { globalPackages } = await import('../src/manager/globals/globals.ts'); + + // Extract named exports using a single rolldown build with virtual entry per module. + // Rolldown statically resolves exports without evaluating any code. + // For CJS modules (e.g. react) that don't expose ESM exports, fall back to require(). + const input: Record = {}; + const virtualModules: Record = {}; + + for (const mod of globalPackages) { + const key = mod.replace(/[/@]/g, '_'); + input[key] = `\0${key}`; + virtualModules[`\0${key}`] = `export * from '${mod}'`; + } + + const bundle = await rolldown({ + input, + resolve: { alias: localAlias }, platform: 'browser', - target: BROWSER_TARGETS, - supported: SUPPORTED_FEATURES, + logLevel: 'silent', + plugins: [ + { + name: 'virtual', + resolveId(id) { + if (id.startsWith('\0')) return id; + }, + load(id) { + if (virtualModules[id]) return virtualModules[id]; + }, + }, + ], }); - const { globalsNameValueMap: data } = await import(pathToFileURL(outFile).href); + const { output } = await bundle.generate({ format: 'esm' }); + + const data: Record = {}; - // loop over all values of the keys of the data object and remove the default key - for (const key in data) { - const value = data[key]; - if (typeof value === 'object') { - data[key] = Object.keys( - Object.fromEntries(Object.entries(value).filter(([k]) => removeDefault(k))) - ).sort(); + for (const chunk of output) { + if (chunk.type !== 'chunk' || !chunk.isEntry) continue; + const mod = globalPackages.find((m: string) => m.replace(/[/@]/g, '_') === chunk.name); + if (!mod) continue; + + let exports = chunk.exports.filter((e: string) => e !== 'default').sort(); + + // CJS modules don't expose ESM exports โ€” fall back to require() + if (exports.length === 0) { + try { + exports = Object.keys(require(mod)) + .filter((k) => k !== 'default' && k !== '__esModule') + .sort(); + } catch { + // ignore โ€” module may not be requireable + } } + + data[mod] = exports; } + // Preserve key order from globalsNameReferenceMap for deterministic output + const ordered = Object.fromEntries(globalPackages.map((mod: string) => [mod, data[mod]])); + const { code: formatted } = await format( 'exports.ts', dedent` @@ -177,7 +173,7 @@ async function generateExportsFile(): Promise { // this is done to prevent runtime dependencies from making it's way into the build/start script of the manager // the manager builder needs to know which dependencies are 'globalized' in the ui - export default ${JSON.stringify(data)} as const; + export default ${JSON.stringify(ordered)} as const; `, { singleQuote: true } ); diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index f55e91f87802..134753ce01b1 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -15,7 +15,8 @@ import html from 'react-syntax-highlighter/dist/esm/languages/prism/markup'; import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx'; import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript'; import yml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml'; -import ReactSyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light'; +import type { PrismLight } from 'react-syntax-highlighter'; +import ReactSyntaxHighlighterRuntime from 'react-syntax-highlighter/dist/esm/prism-light'; import { styled } from 'storybook/theming'; import { ActionBar } from '../ActionBar/ActionBar.tsx'; @@ -28,6 +29,11 @@ import type { SyntaxHighlighterRendererProps, } from './syntaxhighlighter-types.ts'; +// Type the deep runtime import via the package root: the deep path is only +// typed through ambient `declare module` blocks in @types/react-syntax-highlighter, +// which declaration emit and type bundlers cannot reference by specifier. +const ReactSyntaxHighlighter: typeof PrismLight = ReactSyntaxHighlighterRuntime; + export const supportedLanguages = { jsextra: jsExtras, jsx, diff --git a/code/core/src/manager/globals/globals.ts b/code/core/src/manager/globals/globals.ts index 76f240b502d4..e0c1e705a529 100644 --- a/code/core/src/manager/globals/globals.ts +++ b/code/core/src/manager/globals/globals.ts @@ -7,11 +7,11 @@ export const globalsNameReferenceMap = { 'storybook/manager-api': '__STORYBOOK_API__', - 'storybook/test': '__STORYBOOK_TEST__', - 'storybook/theming': '__STORYBOOK_THEMING__', 'storybook/theming/create': '__STORYBOOK_THEMING_CREATE__', + 'storybook/test': '__STORYBOOK_TEST__', + 'storybook/internal/channels': '__STORYBOOK_CHANNELS__', 'storybook/internal/client-logger': '__STORYBOOK_CLIENT_LOGGER__', 'storybook/internal/components': '__STORYBOOK_COMPONENTS__', diff --git a/code/core/src/node-logger/index.ts b/code/core/src/node-logger/index.ts index 01883ef293b1..09bd81913203 100644 --- a/code/core/src/node-logger/index.ts +++ b/code/core/src/node-logger/index.ts @@ -11,7 +11,8 @@ export { protectUrls, createHyperlink } from './wrap-utils.ts'; export { CLI_COLORS } from './logger/colors.ts'; export { ConsoleLogger, StyledConsoleLogger } from './logger/console.ts'; -export type { LogLevel } from './logger/logger.ts'; +export type { BoxOptions, LogLevel } from './logger/logger.ts'; +export type { LogMessageOptions } from '@clack/prompts'; // The default is stderr, which can cause some tools (like rush.js) to think // there are issues with the build: https://github.com/storybookjs/storybook/issues/14621 diff --git a/code/core/src/node-logger/logger/logger.ts b/code/core/src/node-logger/logger/logger.ts index 0d21aea1e2ec..be79adbfa6a3 100644 --- a/code/core/src/node-logger/logger/logger.ts +++ b/code/core/src/node-logger/logger/logger.ts @@ -161,7 +161,7 @@ export const error = createLogger('error', (...args: LogFunctionArgs generateBundle({ cwd: DIR_CWD, entry, name, isWatch })), measure(async () => { if (isProduction) { - await generateTypesFiles(DIR_CWD, entry); + switch (dtsBundler) { + case 'rolldown': + await generateTypesFilesRolldown(DIR_CWD, entry, { + tsgo: false, + resolver: resolvedDtsResolver, + }); + break; + case 'rolldown-tsgo': + await generateTypesFilesRolldown(DIR_CWD, entry, { + tsgo: true, + resolver: resolvedDtsResolver, + }); + break; + case 'rollup': + default: + await generateTypesFiles(DIR_CWD, entry); + break; + } } }), ]); diff --git a/scripts/build/utils/generate-types-rolldown.ts b/scripts/build/utils/generate-types-rolldown.ts new file mode 100644 index 000000000000..dcbfdd1d5355 --- /dev/null +++ b/scripts/build/utils/generate-types-rolldown.ts @@ -0,0 +1,195 @@ +import { readFile, rm, writeFile } from 'node:fs/promises'; +import { sep } from 'node:path'; + +import { basename, dirname, join, relative } from 'pathe'; +import picocolors from 'picocolors'; +import type { Plugin } from 'rolldown'; +import { rolldown } from 'rolldown'; +import { dts } from 'rolldown-plugin-dts'; +import ts from 'typescript'; + +import type { BuildEntries } from './entry-utils'; +import { getExternal } from './entry-utils'; + +const DIR_CODE = join(import.meta.dirname, '..', '..', '..', 'code'); +const DIR_ROOT = join(DIR_CODE, '..'); + +const DTS_EXCLUDES = [ + '**/*.test.*', + '**/*.spec.*', + '**/*.stories.*', + '**/*.mockdata.*', + '**/__tests__/**', + '**/__mocks__/**', + '**/node_modules/**', +]; + +/** + * Resolve a tsconfig chain (following `extends`) and return merged compilerOptions. + * Strips JSON comments and trailing commas before parsing. + */ +async function resolveTsconfigCompilerOptions(tsconfigPath: string): Promise> { + const raw = await readFile(tsconfigPath, 'utf8'); + const stripped = raw.replace(/\/\/.*$/gm, '').replace(/,\s*([\]}])/g, '$1'); + const parsed = JSON.parse(stripped); + + let base: Record = {}; + if (parsed.extends) { + const parentPath = join(tsconfigPath, '..', parsed.extends); + base = await resolveTsconfigCompilerOptions(parentPath); + } + + return { ...base, ...parsed.compilerOptions }; +} + +const RE_DTS_IMPORTER = /\.d\.[cm]?ts(?:\?|$)/; + +/** + * The plugin's default 'oxc' resolver does not fall back to DefinitelyTyped + * `@types/*` packages for untyped dependencies and ignores `typesVersions` + * (see sxzz/rolldown-plugin-dts#130, oxc-project/oxc-resolver#549). Types + * that must be inlined (e.g. `@babel/*`) would silently stay external or be + * inlined from the wrong file (`index-legacy.d.ts`). This pre-resolver fixes + * exactly those cases: bare, non-external specifiers imported from a .d.ts + * context are resolved with TypeScript's own module resolution, which handles + * both `@types/*` fallback and `typesVersions`. Everything else falls through + * to the plugin's fast Rust resolver. + */ +function createTypesFallbackResolverPlugin(isExternal: (id: string) => boolean): Plugin { + const cache = new Map(); + const compilerOptions: ts.CompilerOptions = { + moduleResolution: ts.ModuleResolutionKind.Bundler, + resolveJsonModule: true, + }; + + return { + name: 'storybook:dts-types-fallback-resolver', + resolveId: { + order: 'pre', + handler(id, importer) { + if (!importer || !RE_DTS_IMPORTER.test(importer.split('?')[0])) { + return; + } + if (id.startsWith('.') || id.startsWith('/') || id.startsWith('\0') || isExternal(id)) { + return; + } + const importerPath = importer.split('?')[0]; + const key = `${dirname(importerPath)}\n${id}`; + let resolved = cache.get(key); + if (resolved === undefined) { + const { resolvedModule } = ts.resolveModuleName( + id, + importerPath, + compilerOptions, + ts.sys + ); + resolved = + resolvedModule && /\.d\.[cm]?ts$/.test(resolvedModule.resolvedFileName) + ? resolvedModule.resolvedFileName + : null; + cache.set(key, resolved); + } + return resolved ?? undefined; + }, + }, + }; +} + +export async function generateTypesFiles( + cwd: string, + data: BuildEntries, + options?: { tsgo?: boolean; resolver?: 'oxc' | 'tsc' | 'hybrid' } +) { + const DIR_REL = relative(DIR_CODE, cwd); + + const dtsEntries = Object.values(data.entries) + .flat() + .filter((entry) => entry.dts !== false) + .map((e) => e.entryPoint); + + if (dtsEntries.length === 0) { + return; + } + + const { typesExternal: external } = await getExternal(cwd); + + const externalFn = (id: string) => + external.some( + (dep: string) => + id === dep || + id.startsWith(`${dep}/`) || + id.includes(`${sep}node_modules${sep}${dep}${sep}`) + ); + + // Build entry map: { 'client-logger/index': '/absolute/path/src/client-logger/index.ts', ... } + const entryMap: Record = {}; + for (const entry of dtsEntries) { + // ./src/client-logger/index.ts -> client-logger/index + const name = entry.replace(/^\.\/src\//, '').replace(/\.tsx?$/, ''); + entryMap[name] = join(cwd, entry); + } + + // rolldown-plugin-dts derives rootDir from path.dirname(tsconfig). + // When rootDir is the package dir, tsgo emits stray .d.ts files next to + // source files for cross-package imports that fall outside rootDir. + // Fix: create a temporary tsconfig at the repo root so rootDir covers + // the entire monorepo. Use a per-package filename to avoid races when + // NX compiles multiple packages in parallel. + const wrapperTsconfig = join(DIR_ROOT, `tsconfig.dts-tmp-${basename(cwd)}.json`); + const packageTsconfig = join(cwd, 'tsconfig.json'); + + const useTsgo = options?.tsgo ?? true; + + if (useTsgo) { + // tsgo removed support for `baseUrl`. Resolve the tsconfig chain, + // strip `baseUrl`, and write a flat config so tsgo doesn't error. + const compilerOptions = await resolveTsconfigCompilerOptions(packageTsconfig); + delete compilerOptions.baseUrl; + await writeFile( + wrapperTsconfig, + JSON.stringify({ + compilerOptions, + include: [`${relative(DIR_ROOT, cwd)}/src/**/*`], + exclude: DTS_EXCLUDES, + }) + ); + } else { + await writeFile( + wrapperTsconfig, + JSON.stringify({ + extends: `./${relative(DIR_ROOT, packageTsconfig)}`, + exclude: DTS_EXCLUDES, + }) + ); + } + + const resolver = options?.resolver ?? 'hybrid'; + + try { + const out = await rolldown({ + input: entryMap, + external: externalFn, + plugins: [ + ...(resolver === 'hybrid' ? [createTypesFallbackResolverPlugin(externalFn)] : []), + dts({ + cwd, + tsconfig: wrapperTsconfig, + tsgo: useTsgo, + emitDtsOnly: true, + resolver: resolver === 'tsc' ? 'tsc' : 'oxc', + }), + ], + logLevel: 'warn', + }); + + await out.write({ dir: join(cwd, 'dist'), format: 'es' }); + } finally { + await rm(wrapperTsconfig, { force: true }); + } + + if (!process.env.CI) { + for (const entry of dtsEntries) { + console.log('Generated types for', picocolors.cyan(join(DIR_REL, entry))); + } + } +} diff --git a/scripts/package.json b/scripts/package.json index 8996908269e7..ac0861ec7c63 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -79,6 +79,7 @@ "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/experimental-utils": "^5.62.0", "@typescript-eslint/parser": "^8.48.0", + "@typescript/native-preview": "^7.0.0-dev.20260701.1", "@vitest/coverage-v8": "^4.1.5", "ansi-regex": "^6.0.1", "chromatic": "^13.3.4", @@ -147,6 +148,8 @@ "react-dom": "^18.3.1", "recast": "^0.23.9", "rimraf": "^6.1.2", + "rolldown": "^1.1.4", + "rolldown-plugin-dts": "^0.26.0", "rollup": "^4.21.0", "rollup-plugin-dts": "^6.1.1", "semver": "^7.7.3", diff --git a/scripts/utils/tools.ts b/scripts/utils/tools.ts index e8adb256b321..77cc4af38796 100644 --- a/scripts/utils/tools.ts +++ b/scripts/utils/tools.ts @@ -1,5 +1,5 @@ -import { access, readFile, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { access, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import * as process from 'node:process'; import { globalExternals } from '@fal-works/esbuild-plugin-global-externals'; @@ -10,15 +10,12 @@ import { glob } from 'glob'; import limit from 'p-limit'; import picocolors from 'picocolors'; import prettyTime from 'pretty-hrtime'; -import * as rollup from 'rollup'; -import * as rpd from 'rollup-plugin-dts'; // eslint-disable-next-line depend/ban-dependencies import slash from 'slash'; import sortPackageJson from 'sort-package-json'; import { dedent } from 'ts-dedent'; import type * as typefest from 'type-fest'; import typescript from 'typescript'; -import ts from 'typescript'; import { ROOT_DIRECTORY } from './constants.ts'; @@ -33,49 +30,6 @@ const pathExists = async (path: string) => { } }; -export const dts = async (entry: string, externals: string[], tsconfig: string) => { - const dir = dirname(entry).replace('src', 'dist'); - const out = await rollup.rollup({ - input: entry, - external: [...externals, 'ast-types', 'react'].map((dep) => new RegExp(`^${dep}($|\\/|\\\\)`)), - output: { file: entry.replace('src', 'dist').replace('.ts', '.d.ts'), format: 'es' }, - plugins: [ - rpd.dts({ - respectExternal: true, - tsconfig, - compilerOptions: { - esModuleInterop: true, - baseUrl: '.', - jsx: ts.JsxEmit.React, - declaration: true, - noEmit: false, - emitDeclarationOnly: true, - noEmitOnError: true, - checkJs: false, - declarationMap: false, - skipLibCheck: true, - preserveSymlinks: false, - target: ts.ScriptTarget.ESNext, - }, - }), - ], - }); - const { output } = await out.generate({ - format: 'es', - file: entry.replace('src', 'dist').replace('.ts', '.d.ts'), - }); - - await Promise.all( - output.map(async (o) => { - if (o.type === 'chunk') { - await writeFile(join(dir, o.fileName), o.code); - } else { - throw new Error(`Unexpected output type: ${o.type} for ${entry} (${o.fileName})`); - } - }) - ); -}; - export { spawn }; export const defineEntry = diff --git a/yarn.lock b/yarn.lock index e0e623858d0e..c6dbd045850b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -990,6 +990,20 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^8.0.0": + version: 8.0.0 + resolution: "@babel/generator@npm:8.0.0" + dependencies: + "@babel/parser": "npm:^8.0.0" + "@babel/types": "npm:^8.0.0" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + "@types/jsesc": "npm:^2.5.0" + jsesc: "npm:^3.0.2" + checksum: 10c0/2b2d171beaedcacca62164f0f39bc8962df5f9700a54c5515174276ea20b3a53933e9804e0b35c2be1059108a764447d99d0c5beb63ecbbf8c66035ca6c003c4 + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:7.25.9": version: 7.25.9 resolution: "@babel/helper-annotate-as-pure@npm:7.25.9" @@ -1190,6 +1204,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^8.0.0": + version: 8.0.2 + resolution: "@babel/helper-validator-identifier@npm:8.0.2" + checksum: 10c0/629eda2a0a99442a1628bbef5f9fd8e26c1aa21ddbf02760d1c482b737932b1bb21fb1f89aacf9c3cf0642b481b810e39f94b8ae9aa44fc4f5516ebec6798b7b + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.25.9, @babel/helper-validator-option@npm:^7.27.1, @babel/helper-validator-option@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-validator-option@npm:7.29.7" @@ -1229,6 +1250,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^8.0.0": + version: 8.0.0 + resolution: "@babel/parser@npm:8.0.0" + dependencies: + "@babel/types": "npm:^8.0.0" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/f10665bf3289f4bdc43bc58c482d6baa9fc4e306460d1bc57b059486dc9f23ed4463e746f62d721396466e2a2e0b32d856648b8c30dedbf6b69787b430f21e75 + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.9, @babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.28.5": version: 7.28.5 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.28.5" @@ -2742,6 +2774,16 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:1.11.1": + version: 1.11.1 + resolution: "@emnapi/core@npm:1.11.1" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.2" + tslib: "npm:^2.4.0" + checksum: 10c0/2c6defdac2d1d26090384655d7d6c9614fa553853b1760597686749e9375dc2aa0dae80a2615b81c254600f5d531d07d8466cde0d331a8caae64b93f3ca5937e + languageName: node + linkType: hard + "@emnapi/core@npm:1.9.2": version: 1.9.2 resolution: "@emnapi/core@npm:1.9.2" @@ -2761,6 +2803,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:1.11.1": + version: 1.11.1 + resolution: "@emnapi/runtime@npm:1.11.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/04332fb62076afc440aa23316c04bec42f584ca8b074e5507d08e2b33a47cbe0493b1aadb8f3c1057b64ae1e17f5bde1a7bc37f7facc9d0bc25c18197cbd366f + languageName: node + linkType: hard + "@emnapi/runtime@npm:1.9.2": version: 1.9.2 resolution: "@emnapi/runtime@npm:1.9.2" @@ -2779,6 +2830,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/wasi-threads@npm:1.2.2": + version: 1.2.2 + resolution: "@emnapi/wasi-threads@npm:1.2.2" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/f0dc8269d6b20ae5a7c7b36e7a6a333452009d461038ef4febb29da2f3f78c1e2b1576d7e8970a5c5789ed3caedc1f80f5b0c2a5373bdaf8d03b20432bb55747 + languageName: node + linkType: hard + "@emotion/babel-plugin@npm:^11.13.5": version: 11.13.5 resolution: "@emotion/babel-plugin@npm:11.13.5" @@ -4479,6 +4539,18 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^1.1.6": + version: 1.1.6 + resolution: "@napi-rs/wasm-runtime@npm:1.1.6" + dependencies: + "@tybys/wasm-util": "npm:^0.10.3" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/344518bf3ef65051dda4c00969f293aa4a21ab7dc7822b3f48519b17cd5eaa3f0bc34898d115d50ba59b1817a0cb905d46f7a7223c8249239cd14c28db388e10 + languageName: node + linkType: hard + "@neoconfetti/react@npm:^1.0.0": version: 1.0.0 resolution: "@neoconfetti/react@npm:1.0.0" @@ -5656,6 +5728,13 @@ __metadata: languageName: node linkType: hard +"@oxc-project/types@npm:=0.138.0": + version: 0.138.0 + resolution: "@oxc-project/types@npm:0.138.0" + checksum: 10c0/eacd260df0b961cb8863d0a0b3fab00c1f7701edfca28834b54628ca50ce703a4a3e7e6f9932ca11607d0cf876a0e1047b343a4672f1ad86d961017f7501524f + languageName: node + linkType: hard + "@oxc-project/types@npm:^0.121.0": version: 0.121.0 resolution: "@oxc-project/types@npm:0.121.0" @@ -8495,6 +8574,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-android-arm64@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-android-arm64@npm:1.1.4" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.4" @@ -8509,6 +8595,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-darwin-arm64@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-darwin-arm64@npm:1.1.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-darwin-x64@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.4" @@ -8523,6 +8616,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-darwin-x64@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-darwin-x64@npm:1.1.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.4" @@ -8537,6 +8637,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-freebsd-x64@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-freebsd-x64@npm:1.1.4" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.4" @@ -8551,6 +8658,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-arm-gnueabihf@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.1.4" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.4" @@ -8565,6 +8679,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-arm64-gnu@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.1.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.4" @@ -8579,6 +8700,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-arm64-musl@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.1.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@rolldown/binding-linux-ppc64-gnu@npm:1.0.3": version: 1.0.3 resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.3" @@ -8586,6 +8714,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-ppc64-gnu@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.1.4" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-s390x-gnu@npm:1.0.3": version: 1.0.3 resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.3" @@ -8593,6 +8728,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-s390x-gnu@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.1.4" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.4" @@ -8607,6 +8749,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-x64-gnu@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.1.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.4" @@ -8621,6 +8770,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-x64-musl@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.1.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.4" @@ -8635,6 +8791,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-openharmony-arm64@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.1.4" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.4" @@ -8655,6 +8818,17 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-wasm32-wasi@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.1.4" + dependencies: + "@emnapi/core": "npm:1.11.1" + "@emnapi/runtime": "npm:1.11.1" + "@napi-rs/wasm-runtime": "npm:^1.1.6" + conditions: cpu=wasm32 + languageName: node + linkType: hard + "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.4" @@ -8669,6 +8843,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-win32-arm64-msvc@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.1.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.4" @@ -8683,6 +8864,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-win32-x64-msvc@npm:1.1.4": + version: 1.1.4 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.1.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.18": version: 1.0.0-beta.18 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.18" @@ -10130,6 +10318,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^8.48.0" "@typescript-eslint/experimental-utils": "npm:^5.62.0" "@typescript-eslint/parser": "npm:^8.48.0" + "@typescript/native-preview": "npm:^7.0.0-dev.20260701.1" "@verdaccio/types": "npm:^10.8.0" "@vitest/coverage-v8": "npm:^4.1.5" ansi-regex: "npm:^6.0.1" @@ -10200,6 +10389,8 @@ __metadata: react-dom: "npm:^18.3.1" recast: "npm:^0.23.9" rimraf: "npm:^6.1.2" + rolldown: "npm:^1.1.4" + rolldown-plugin-dts: "npm:^0.26.0" rollup: "npm:^4.21.0" rollup-plugin-dts: "npm:^6.1.1" semver: "npm:^7.7.3" @@ -10975,6 +11166,15 @@ __metadata: languageName: node linkType: hard +"@tybys/wasm-util@npm:^0.10.3": + version: 0.10.3 + resolution: "@tybys/wasm-util@npm:0.10.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/fd2bd2a79c6cd8c79ed1cf7a0fa375c64589264c88a27acaf9756d556b453ea222b62a4f68dd2fbb8b3a78b6bab3b1f4fb2431b6afc6aeda8344b53a521a1cd3 + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.9.0": version: 0.9.0 resolution: "@tybys/wasm-util@npm:0.9.0" @@ -11439,6 +11639,13 @@ __metadata: languageName: node linkType: hard +"@types/jsesc@npm:^2.5.0": + version: 2.5.1 + resolution: "@types/jsesc@npm:2.5.1" + checksum: 10c0/12ba7bf5968aeeb36408269f4b5a39718efc6411fa197cf0f5e967ba36ad7b7d555b78787fc480db43ce63ebe6ab0ffe5fd9f64b1ea3b0d073877f0747491b30 + languageName: node + linkType: hard + "@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -12132,6 +12339,87 @@ __metadata: languageName: node linkType: hard +"@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260701.1": + version: 7.0.0-dev.20260701.1 + resolution: "@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260701.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260701.1": + version: 7.0.0-dev.20260701.1 + resolution: "@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260701.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260701.1": + version: 7.0.0-dev.20260701.1 + resolution: "@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260701.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260701.1": + version: 7.0.0-dev.20260701.1 + resolution: "@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260701.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260701.1": + version: 7.0.0-dev.20260701.1 + resolution: "@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260701.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260701.1": + version: 7.0.0-dev.20260701.1 + resolution: "@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260701.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260701.1": + version: 7.0.0-dev.20260701.1 + resolution: "@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260701.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview@npm:^7.0.0-dev.20260701.1": + version: 7.0.0-dev.20260701.1 + resolution: "@typescript/native-preview@npm:7.0.0-dev.20260701.1" + dependencies: + "@typescript/native-preview-darwin-arm64": "npm:7.0.0-dev.20260701.1" + "@typescript/native-preview-darwin-x64": "npm:7.0.0-dev.20260701.1" + "@typescript/native-preview-linux-arm": "npm:7.0.0-dev.20260701.1" + "@typescript/native-preview-linux-arm64": "npm:7.0.0-dev.20260701.1" + "@typescript/native-preview-linux-x64": "npm:7.0.0-dev.20260701.1" + "@typescript/native-preview-win32-arm64": "npm:7.0.0-dev.20260701.1" + "@typescript/native-preview-win32-x64": "npm:7.0.0-dev.20260701.1" + dependenciesMeta: + "@typescript/native-preview-darwin-arm64": + optional: true + "@typescript/native-preview-darwin-x64": + optional: true + "@typescript/native-preview-linux-arm": + optional: true + "@typescript/native-preview-linux-arm64": + optional: true + "@typescript/native-preview-linux-x64": + optional: true + "@typescript/native-preview-win32-arm64": + optional: true + "@typescript/native-preview-win32-x64": + optional: true + bin: + tsgo: bin/tsgo + checksum: 10c0/100ce4a5894b927ba9111c40b45b38a48c8de7caacca3bfcb67a4c7b40c98edc8feb067a4a6fd468ea68246fbd470309fc01b69868021a7b20f260aa47a0dbd4 + languageName: node + linkType: hard + "@ungap/structured-clone@npm:^1.0.0, @ungap/structured-clone@npm:^1.2.0": version: 1.3.0 resolution: "@ungap/structured-clone@npm:1.3.0" @@ -13944,6 +14232,17 @@ __metadata: languageName: node linkType: hard +"ast-kit@npm:^3.0.0": + version: 3.0.0 + resolution: "ast-kit@npm:3.0.0" + dependencies: + "@babel/parser": "npm:^8.0.0" + estree-walker: "npm:^3.0.3" + pathe: "npm:^2.0.3" + checksum: 10c0/598b286961dbf62a0b61025e4574b0c1a7547f39f94681cb38eb7250ad70bec9d4f82ea05a59d1f12f913aacee48261cb1af950bc62f5dc0ba79b60be28c765a + languageName: node + linkType: hard + "ast-metadata-inferer@npm:^0.8.1": version: 0.8.1 resolution: "ast-metadata-inferer@npm:0.8.1" @@ -14580,6 +14879,13 @@ __metadata: languageName: node linkType: hard +"birpc@npm:^4.0.0": + version: 4.0.0 + resolution: "birpc@npm:4.0.0" + checksum: 10c0/61f4e893ff4c5948b2c587c971c04883af0d8b2658d4632c8e77073db9f9e8b040402f985d56308021890b2ad32ef8392e36a8335cab1e3771d99e1b025d1af6 + languageName: node + linkType: hard + "bl@npm:^1.0.0": version: 1.2.3 resolution: "bl@npm:1.2.3" @@ -17395,6 +17701,18 @@ __metadata: languageName: node linkType: hard +"dts-resolver@npm:^3.0.0": + version: 3.0.0 + resolution: "dts-resolver@npm:3.0.0" + peerDependencies: + oxc-resolver: ">=11.0.0" + peerDependenciesMeta: + oxc-resolver: + optional: true + checksum: 10c0/ae52c4e09b43def702b02d7edbfa04798522907f22879809f8890e5c61fc762403f46a1c415c2d1a6440b01960bbaf98c72def1771b0962d3b15fec40983a4ab + languageName: node + linkType: hard + "dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -20338,6 +20656,15 @@ __metadata: languageName: node linkType: hard +"get-tsconfig@npm:5.0.0-beta.5": + version: 5.0.0-beta.5 + resolution: "get-tsconfig@npm:5.0.0-beta.5" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10c0/fd96e2a7d872703d8c5d7ed5d5d884a4e33f6cd9f012aa68a3c7aae700b84b502e41c06641a3d0667b1e8c8f7d497857dd6d3bba3987d2e5f59d95e49fb6e3b9 + languageName: node + linkType: hard + "get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.7.5": version: 4.13.7 resolution: "get-tsconfig@npm:4.13.7" @@ -26015,6 +26342,13 @@ __metadata: languageName: node linkType: hard +"obug@npm:^2.1.3": + version: 2.1.3 + resolution: "obug@npm:2.1.3" + checksum: 10c0/cb8187fed0a5fc8445507c950e89f3c1bd43895658c398b5803f6b7804dfa0c562975ecce1e67f3d9247d521452a5bfade9e0e951cc0326b7444272f7c24d25f + languageName: node + linkType: hard + "ohash@npm:^2.0.11": version: 2.0.11 resolution: "ohash@npm:2.0.11" @@ -29580,6 +29914,37 @@ __metadata: languageName: node linkType: hard +"rolldown-plugin-dts@npm:^0.26.0": + version: 0.26.0 + resolution: "rolldown-plugin-dts@npm:0.26.0" + dependencies: + "@babel/generator": "npm:^8.0.0" + "@babel/helper-validator-identifier": "npm:^8.0.0" + "@babel/parser": "npm:^8.0.0" + ast-kit: "npm:^3.0.0" + birpc: "npm:^4.0.0" + dts-resolver: "npm:^3.0.0" + get-tsconfig: "npm:5.0.0-beta.5" + obug: "npm:^2.1.3" + peerDependencies: + "@ts-macro/tsc": ^0.3.6 + "@typescript/native-preview": ">=7.0.0-dev.20260325.1" + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + "@ts-macro/tsc": + optional: true + "@typescript/native-preview": + optional: true + typescript: + optional: true + vue-tsc: + optional: true + checksum: 10c0/fcedb9770b15fe5d9c45a0260bb226e8e3dad92880d03aa7dbaffb8b4929ea8406897063a19140cd62803c87862c010b54a1546ca5de24e375cd69632060407f + languageName: node + linkType: hard + "rolldown@npm:1.0.0-rc.4": version: 1.0.0-rc.4 resolution: "rolldown@npm:1.0.0-rc.4" @@ -29690,6 +30055,64 @@ __metadata: languageName: node linkType: hard +"rolldown@npm:^1.1.4": + version: 1.1.4 + resolution: "rolldown@npm:1.1.4" + dependencies: + "@oxc-project/types": "npm:=0.138.0" + "@rolldown/binding-android-arm64": "npm:1.1.4" + "@rolldown/binding-darwin-arm64": "npm:1.1.4" + "@rolldown/binding-darwin-x64": "npm:1.1.4" + "@rolldown/binding-freebsd-x64": "npm:1.1.4" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.1.4" + "@rolldown/binding-linux-arm64-gnu": "npm:1.1.4" + "@rolldown/binding-linux-arm64-musl": "npm:1.1.4" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.1.4" + "@rolldown/binding-linux-s390x-gnu": "npm:1.1.4" + "@rolldown/binding-linux-x64-gnu": "npm:1.1.4" + "@rolldown/binding-linux-x64-musl": "npm:1.1.4" + "@rolldown/binding-openharmony-arm64": "npm:1.1.4" + "@rolldown/binding-wasm32-wasi": "npm:1.1.4" + "@rolldown/binding-win32-arm64-msvc": "npm:1.1.4" + "@rolldown/binding-win32-x64-msvc": "npm:1.1.4" + "@rolldown/pluginutils": "npm:^1.0.0" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-wasm32-wasi": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: ./bin/cli.mjs + checksum: 10c0/58ca78ec0f20f2276db129ac38db3ebe6dd68236be76f0fdf1e76276934ed67abcb2925cdcb297f52a77c0bdc1a508573176e167443ec737c8cb4a1d24083113 + languageName: node + linkType: hard + "rollup-plugin-dts@npm:^6.1.1": version: 6.1.1 resolution: "rollup-plugin-dts@npm:6.1.1" @@ -31378,6 +31801,7 @@ __metadata: react-transition-state: "npm:^2.3.1" recast: "npm:^0.23.5" require-from-string: "npm:^2.0.2" + rolldown: "npm:^1.1.4" semver: "npm:^7.7.3" sirv: "npm:^2.0.4" slash: "npm:^5.0.0" From ab5778fa12285acae8ce79fac53ede0be8d8db61 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 07:46:54 +0200 Subject: [PATCH 02/24] Build: satisfy noImplicitReturns in dts resolver plugin --- scripts/build/utils/generate-types-rolldown.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/build/utils/generate-types-rolldown.ts b/scripts/build/utils/generate-types-rolldown.ts index dcbfdd1d5355..f1f429b015d9 100644 --- a/scripts/build/utils/generate-types-rolldown.ts +++ b/scripts/build/utils/generate-types-rolldown.ts @@ -68,10 +68,10 @@ function createTypesFallbackResolverPlugin(isExternal: (id: string) => boolean): order: 'pre', handler(id, importer) { if (!importer || !RE_DTS_IMPORTER.test(importer.split('?')[0])) { - return; + return undefined; } if (id.startsWith('.') || id.startsWith('/') || id.startsWith('\0') || isExternal(id)) { - return; + return undefined; } const importerPath = importer.split('?')[0]; const key = `${dirname(importerPath)}\n${id}`; From 4999781c07c9d53d518454cd19d362bf8625223d Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 07:58:41 +0200 Subject: [PATCH 03/24] Build: align enum imports with package specifiers for bundled d.ts compatibility The rolldown-bundled d.ts renames chunk-level enum declarations (e.g. SupportedRenderer -> SupportedRenderer$1) when the plain name is taken by another chunk's import binding. TypeScript's cross-declaration enum compatibility is name-based, so in-repo code that mixes source-relative enum imports with dist-typed values stops type-checking. Import enums via the package specifier so both sides resolve to the same declaration in dev and production checks. Also export QueryFunctions and ServiceRegistryApi, which appear in inferred public signatures of dependent packages. --- .../core-server/server-channel/file-search-channel.test.ts | 6 +++++- scripts/tasks/sandbox-parts.ts | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/code/core/src/core-server/server-channel/file-search-channel.test.ts b/code/core/src/core-server/server-channel/file-search-channel.test.ts index 47a4ca3b78b3..4aa74507e702 100644 --- a/code/core/src/core-server/server-channel/file-search-channel.test.ts +++ b/code/core/src/core-server/server-channel/file-search-channel.test.ts @@ -12,8 +12,12 @@ import { FILE_COMPONENT_SEARCH_REQUEST, FILE_COMPONENT_SEARCH_RESPONSE, } from 'storybook/internal/core-events'; +// Import via the package specifier (not the relative path) so this is the same +// enum declaration that the mocked `storybook/internal/common` module is typed +// against. The bundled d.ts renames chunk-level enum declarations, which breaks +// TypeScript's name-based cross-declaration enum compatibility. +import { SupportedRenderer } from 'storybook/internal/types'; -import { SupportedRenderer } from '../../types/index.ts'; import { searchFiles } from '../utils/search-files.ts'; import { initFileSearchChannel } from './file-search-channel.ts'; diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index c10a910f25c7..e6ba8973c1ac 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -20,7 +20,12 @@ import { formatConfig, writeConfig, } from '../../code/core/src/csf-tools/index.ts'; -import { SupportedLanguage } from '../../code/core/src/types/index.ts'; +// Import via the package specifier (not the core source path) so this is the +// same enum declaration that ProjectTypeService's return type resolves to; the +// bundled d.ts renames chunk-level enum declarations, which breaks +// TypeScript's name-based cross-declaration enum compatibility. +import { SupportedLanguage } from 'storybook/internal/types'; + import type { TemplateKey } from '../../code/lib/cli-storybook/src/sandbox-templates.ts'; import { ProjectTypeService } from '../../code/lib/create-storybook/src/services/ProjectTypeService.ts'; import type { PassedOptionValues, Task, TemplateDetails } from '../task.ts'; From 6dbbb02809c6051c6f1b1f2e6091457be2466862 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 08:40:24 +0200 Subject: [PATCH 04/24] Build: emit eslint-plugin declarations with tsc instead of tsgo tsgo declaration emit hangs for minutes to hours on this package (likely @typescript-eslint's recursive types). Add a per-package dtsBundler override to the build config and use the tsc emitter there; it finishes in seconds. --- code/lib/eslint-plugin/build-config.ts | 4 ++++ scripts/build/build-package.ts | 2 +- scripts/build/utils/entry-utils.ts | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/code/lib/eslint-plugin/build-config.ts b/code/lib/eslint-plugin/build-config.ts index c5f954e2aeef..600838fe5a39 100644 --- a/code/lib/eslint-plugin/build-config.ts +++ b/code/lib/eslint-plugin/build-config.ts @@ -5,6 +5,10 @@ import { x as exec } from 'tinyexec'; import type { BuildEntries } from '../../../scripts/build/utils/entry-utils.ts'; const config: BuildEntries = { + // tsgo declaration emit is pathologically slow on this package (minutes to + // hours, likely choking on @typescript-eslint's recursive types); the tsc + // emitter finishes in seconds. + dtsBundler: 'rolldown', prebuild: async (cwd) => { await exec('jiti', [path.join(import.meta.dirname, 'scripts', 'update-all.ts')], { nodeOptions: { diff --git a/scripts/build/build-package.ts b/scripts/build/build-package.ts index 940ae640444c..ac3651f8e873 100755 --- a/scripts/build/build-package.ts +++ b/scripts/build/build-package.ts @@ -101,7 +101,7 @@ async function run() { measure(async () => generateBundle({ cwd: DIR_CWD, entry, name, isWatch })), measure(async () => { if (isProduction) { - switch (dtsBundler) { + switch (entry.dtsBundler ?? dtsBundler) { case 'rolldown': await generateTypesFilesRolldown(DIR_CWD, entry, { tsgo: false, diff --git a/scripts/build/utils/entry-utils.ts b/scripts/build/utils/entry-utils.ts index cea541b1485f..2b89134d3717 100644 --- a/scripts/build/utils/entry-utils.ts +++ b/scripts/build/utils/entry-utils.ts @@ -23,6 +23,12 @@ export type BuildEntries = { * Each platform is optional */ entries: BuildEntriesByPlatform; + /** + * Override the d.ts bundler for this package (defaults to the --dts-bundler + * CLI flag). Use 'rolldown' to emit declarations with tsc instead of tsgo + * for packages where tsgo misbehaves. + */ + dtsBundler?: 'rolldown-tsgo' | 'rolldown' | 'rollup'; /** * The map of extra outputs to be added to the package.json's exports * From 6d85ea91421c036634379aa26909fd2ce193844f Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 08:50:21 +0200 Subject: [PATCH 05/24] Build: default to the tsc declaration emitter, keep tsgo opt-in Two clean tsgo builds are not byte-identical: type aliases flap between the alias and its expansion (e.g. TestProviderId vs string), which cascades into different chunk hash names, and one observed build wrote entry files whose chunk imports did not match the emitted chunk files (silently broken types, masked by skipLibCheck). The tsc emitter bundled by rolldown is byte-deterministic across builds, still ~14x faster than the rollup path (core: 2.2 min -> 9.1 s), and avoids the tsgo hang on eslint-plugin. --- scripts/build/build-package.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/build/build-package.ts b/scripts/build/build-package.ts index ac3651f8e873..45846bc2987f 100755 --- a/scripts/build/build-package.ts +++ b/scripts/build/build-package.ts @@ -43,7 +43,12 @@ const { optimized: { type: 'boolean', default: false }, watch: { type: 'boolean', default: false }, cwd: { type: 'string' }, - 'dts-bundler': { type: 'string', default: 'rolldown-tsgo' }, + // 'rolldown' bundles tsc-emitted declarations: ~14x faster than the old + // rollup path and byte-deterministic. 'rolldown-tsgo' is ~2x faster still, + // but tsgo declaration emit is not yet deterministic (type aliases flap, + // chunk references can go stale) and hangs on some packages; keep it + // opt-in until it stabilizes. + 'dts-bundler': { type: 'string', default: 'rolldown' }, 'dts-resolver': { type: 'string', default: 'hybrid' }, }, allowNegative: true, From c562c95e8398ad57d3cfd827d2fe55c406409bbc Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 09:25:58 +0200 Subject: [PATCH 06/24] Build: make d.ts generation deterministic via whole-program pre-emit Emit each package's declarations with a single program.emit() call (noCheck, repo-root rootDir) and feed the pre-emitted tree to rolldown-plugin-dts via dtsInput. TypeScript's declaration emit is check-order-dependent, so letting the plugin emit per module in rolldown's concurrent load order produced byte-different output across runs (type aliases flapped between alias and expansion); the plugin's own build mode fixes the order but cannot handle this repo's cross-package source imports. Handwritten typings.d.ts inputs are mirrored into the emitted tree so side-effect imports resolve, and shared chunks are named chunk-[hash] because the default [name]-[hash] inherits an unstable base name. Two clean full production compiles now produce byte-identical declarations (SHA-256 over all 169 files); the full production check suite passes for all 44 projects. --- .gitignore | 1 + .../build/utils/generate-types-rolldown.ts | 175 ++++++++++++++---- 2 files changed, 142 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 7d1ed0ec2b3d..a83652ad6876 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ test-results /.npmrc tsconfig.vitest-temp.json tsconfig.dts-tmp-*.json +.dts-emit/ .env.local diff --git a/scripts/build/utils/generate-types-rolldown.ts b/scripts/build/utils/generate-types-rolldown.ts index f1f429b015d9..3423d62dd032 100644 --- a/scripts/build/utils/generate-types-rolldown.ts +++ b/scripts/build/utils/generate-types-rolldown.ts @@ -95,6 +95,87 @@ function createTypesFallbackResolverPlugin(isExternal: (id: string) => boolean): }; } +/** + * Emit declarations for the whole package (plus any cross-package sources it + * imports) with a single `program.emit()` call into `outDir`, mirroring the + * repo structure (`rootDir` is the repo root). + * + * Doing the emit ourselves, up front, is what makes the output deterministic: + * TypeScript's declaration emit is check-order-dependent (type aliases flap + * between the alias and its expansion depending on which files were checked + * first), so letting the dts plugin emit per module in rolldown's concurrent + * load order produces byte-different output across runs. + */ +function emitPackageDeclarations(wrapperTsconfig: string, outDir: string): void { + const parsed = ts.getParsedCommandLineOfConfigFile(wrapperTsconfig, undefined, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + // eslint-disable-next-line local-rules/no-uncategorized-errors + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')); + }, + }); + if (!parsed) { + // eslint-disable-next-line local-rules/no-uncategorized-errors + throw new Error(`Unable to parse ${wrapperTsconfig}`); + } + + const program = ts.createProgram({ + rootNames: parsed.fileNames, + options: { + ...parsed.options, + noEmit: false, + noCheck: true, + declaration: true, + emitDeclarationOnly: true, + declarationMap: false, + sourceMap: false, + composite: false, + incremental: false, + skipLibCheck: true, + noEmitOnError: false, + stripInternal: true, + // Source files import with explicit .ts extensions; emitted d.ts must + // reference .js so that consumers (and the bundling pass) resolve them. + allowImportingTsExtensions: true, + rewriteRelativeImportExtensions: true, + outDir, + rootDir: DIR_ROOT, + }, + }); + + let written = 0; + const result = program.emit(undefined, (fileName, text, writeByteOrderMark) => { + written++; + ts.sys.writeFile(fileName, text, writeByteOrderMark); + }); + const errors = result.diagnostics.filter((d) => d.category === ts.DiagnosticCategory.Error); + if (errors.length > 0) { + console.error( + ts.formatDiagnosticsWithColorAndContext(errors, { + getCurrentDirectory: () => DIR_ROOT, + getCanonicalFileName: (fileName) => fileName, + getNewLine: () => ts.sys.newLine, + }) + ); + } + // `emitSkipped` is unreliable with `noCheck` (it reports true even though + // every file was written), so gate on actual output instead. + if (written === 0) { + // eslint-disable-next-line local-rules/no-uncategorized-errors + throw new Error(`Declaration emit produced no output for ${wrapperTsconfig}`); + } + + // Handwritten declaration files (e.g. `typings.d.ts`) are program inputs, + // not outputs, so tsc does not place them in outDir; copy them so that + // side-effect imports like `import './typings.d.ts'` resolve in the tree. + for (const fileName of parsed.fileNames) { + if (/\.d\.[cm]?ts$/.test(fileName)) { + const target = join(outDir, relative(DIR_ROOT, fileName)); + ts.sys.writeFile(target, ts.sys.readFile(fileName) ?? ''); + } + } +} + export async function generateTypesFiles( cwd: string, data: BuildEntries, @@ -129,45 +210,50 @@ export async function generateTypesFiles( entryMap[name] = join(cwd, entry); } - // rolldown-plugin-dts derives rootDir from path.dirname(tsconfig). - // When rootDir is the package dir, tsgo emits stray .d.ts files next to - // source files for cross-package imports that fall outside rootDir. - // Fix: create a temporary tsconfig at the repo root so rootDir covers - // the entire monorepo. Use a per-package filename to avoid races when - // NX compiles multiple packages in parallel. + // The dts plugin and tsgo derive rootDir from path.dirname(tsconfig). + // When rootDir is the package dir, declarations for cross-package imports + // would be emitted next to their source files. Fix: create a temporary + // tsconfig at the repo root so rootDir covers the entire monorepo. Use a + // per-package filename to avoid races when NX compiles packages in parallel. const wrapperTsconfig = join(DIR_ROOT, `tsconfig.dts-tmp-${basename(cwd)}.json`); const packageTsconfig = join(cwd, 'tsconfig.json'); - const useTsgo = options?.tsgo ?? true; - - if (useTsgo) { - // tsgo removed support for `baseUrl`. Resolve the tsconfig chain, - // strip `baseUrl`, and write a flat config so tsgo doesn't error. - const compilerOptions = await resolveTsconfigCompilerOptions(packageTsconfig); - delete compilerOptions.baseUrl; - await writeFile( - wrapperTsconfig, - JSON.stringify({ - compilerOptions, - include: [`${relative(DIR_ROOT, cwd)}/src/**/*`], - exclude: DTS_EXCLUDES, - }) - ); - } else { - await writeFile( - wrapperTsconfig, - JSON.stringify({ - extends: `./${relative(DIR_ROOT, packageTsconfig)}`, - exclude: DTS_EXCLUDES, - }) - ); - } - + const useTsgo = options?.tsgo ?? false; const resolver = options?.resolver ?? 'hybrid'; + // tsgo removed support for `baseUrl`, and ts.getParsedCommandLineOfConfigFile + // needs concrete options anyway: resolve the tsconfig chain and write a flat + // config. + const compilerOptions = await resolveTsconfigCompilerOptions(packageTsconfig); + delete compilerOptions.baseUrl; + await writeFile( + wrapperTsconfig, + JSON.stringify({ + compilerOptions, + include: [`${relative(DIR_ROOT, cwd)}/src/**/*`], + exclude: DTS_EXCLUDES, + }) + ); + + // Pre-emitted declarations land here and are bundled from there; the + // directory never ships (removed in `finally`). + const emitDir = join(cwd, '.dts-emit'); + try { + let input: Record = entryMap; + + if (!useTsgo) { + emitPackageDeclarations(wrapperTsconfig, emitDir); + input = Object.fromEntries( + Object.entries(entryMap).map(([name, sourcePath]) => [ + name, + join(emitDir, relative(DIR_ROOT, sourcePath)).replace(/\.tsx?$/, '.d.ts'), + ]) + ); + } + const out = await rolldown({ - input: entryMap, + input, external: externalFn, plugins: [ ...(resolver === 'hybrid' ? [createTypesFallbackResolverPlugin(externalFn)] : []), @@ -175,6 +261,7 @@ export async function generateTypesFiles( cwd, tsconfig: wrapperTsconfig, tsgo: useTsgo, + dtsInput: !useTsgo, emitDtsOnly: true, resolver: resolver === 'tsc' ? 'tsc' : 'oxc', }), @@ -182,9 +269,29 @@ export async function generateTypesFiles( logLevel: 'warn', }); - await out.write({ dir: join(cwd, 'dist'), format: 'es' }); + const { output } = await out.write({ + dir: join(cwd, 'dist'), + format: 'es', + // Name shared chunks purely by content hash: the default `[name]-[hash]` + // inherits a base name from whichever module rolldown happens to pick, + // which varies across runs and would make the output non-deterministic. + chunkFileNames: 'chunk-[hash].js', + }); + + // Everything meaningful in this build is a .d.ts file; rolldown still + // emits its runtime helper chunk as plain JS, which nothing references. + // Only touch our own chunk namespace: the JS bundle writes real entry + // files into the same dist directory in parallel. + await Promise.all( + output + .filter((chunk) => /^chunk-[^/]+\.js$/.test(chunk.fileName)) + .map((chunk) => rm(join(cwd, 'dist', chunk.fileName), { force: true })) + ); } finally { - await rm(wrapperTsconfig, { force: true }); + await Promise.all([ + rm(wrapperTsconfig, { force: true }), + rm(emitDir, { recursive: true, force: true }), + ]); } if (!process.env.CI) { From 89e579328606988e7d79b76d0ba6548cb30f03bc Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 09:48:34 +0200 Subject: [PATCH 07/24] Build: dedupe lockfile entries added by the rolldown toolchain --- yarn.lock | 42 +++++++----------------------------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/yarn.lock b/yarn.lock index c6dbd045850b..7b7b4f7b91c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2764,7 +2764,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:1.10.0, @emnapi/core@npm:^1.1.0, @emnapi/core@npm:^1.4.3": +"@emnapi/core@npm:1.10.0": version: 1.10.0 resolution: "@emnapi/core@npm:1.10.0" dependencies: @@ -2774,7 +2774,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:1.11.1": +"@emnapi/core@npm:1.11.1, @emnapi/core@npm:^1.1.0, @emnapi/core@npm:^1.4.3": version: 1.11.1 resolution: "@emnapi/core@npm:1.11.1" dependencies: @@ -2794,7 +2794,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:1.10.0, @emnapi/runtime@npm:^1.1.0, @emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.7.0": +"@emnapi/runtime@npm:1.10.0": version: 1.10.0 resolution: "@emnapi/runtime@npm:1.10.0" dependencies: @@ -2803,7 +2803,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:1.11.1": +"@emnapi/runtime@npm:1.11.1, @emnapi/runtime@npm:^1.1.0, @emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.7.0": version: 1.11.1 resolution: "@emnapi/runtime@npm:1.11.1" dependencies: @@ -4527,19 +4527,7 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.1, @napi-rs/wasm-runtime@npm:^1.1.4": - version: 1.1.4 - resolution: "@napi-rs/wasm-runtime@npm:1.1.4" - dependencies: - "@tybys/wasm-util": "npm:^0.10.1" - peerDependencies: - "@emnapi/core": ^1.7.1 - "@emnapi/runtime": ^1.7.1 - checksum: 10c0/2e88e1955258949ccf2d18c79975821ad38071b465ef126a5e14110977b97868867b016c1ad046e963cccc42c0bd9db6c8ff5fd1ebb61b87bb3487f339041658 - languageName: node - linkType: hard - -"@napi-rs/wasm-runtime@npm:^1.1.6": +"@napi-rs/wasm-runtime@npm:^1.1.1, @napi-rs/wasm-runtime@npm:^1.1.4, @napi-rs/wasm-runtime@npm:^1.1.6": version: 1.1.6 resolution: "@napi-rs/wasm-runtime@npm:1.1.6" dependencies: @@ -11157,16 +11145,7 @@ __metadata: languageName: node linkType: hard -"@tybys/wasm-util@npm:^0.10.0, @tybys/wasm-util@npm:^0.10.1": - version: 0.10.1 - resolution: "@tybys/wasm-util@npm:0.10.1" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10c0/b255094f293794c6d2289300c5fbcafbb5532a3aed3a5ffd2f8dc1828e639b88d75f6a376dd8f94347a44813fd7a7149d8463477a9a49525c8b2dcaa38c2d1e8 - languageName: node - linkType: hard - -"@tybys/wasm-util@npm:^0.10.3": +"@tybys/wasm-util@npm:^0.10.0, @tybys/wasm-util@npm:^0.10.3": version: 0.10.3 resolution: "@tybys/wasm-util@npm:0.10.3" dependencies: @@ -26335,14 +26314,7 @@ __metadata: languageName: node linkType: hard -"obug@npm:^2.1.1": - version: 2.1.1 - resolution: "obug@npm:2.1.1" - checksum: 10c0/59dccd7de72a047e08f8649e94c1015ec72f94eefb6ddb57fb4812c4b425a813bc7e7cd30c9aca20db3c59abc3c85cc7a62bb656a968741d770f4e8e02bc2e78 - languageName: node - linkType: hard - -"obug@npm:^2.1.3": +"obug@npm:^2.1.1, obug@npm:^2.1.3": version: 2.1.3 resolution: "obug@npm:2.1.3" checksum: 10c0/cb8187fed0a5fc8445507c950e89f3c1bd43895658c398b5803f6b7804dfa0c562975ecce1e67f3d9247d521452a5bfade9e0e951cc0326b7444272f7c24d25f From 8251a624d640ac46ec45c7b3f0946fc409d54fc6 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 11:16:58 +0200 Subject: [PATCH 08/24] Build: run NX compile with 8 workers on CI The compile task's --parallel flag was still derived from the old NX agents experiment (amountOfVCPUs - 1 = 1), so the 43-project compile ran fully serially on the 8-vCPU xlarge executors of build--linux / build--windows. Peak RSS of the largest package build (core) is ~750MB, so 8 concurrent workers fit comfortably into the executor's 16GB. --- scripts/tasks/compile.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/tasks/compile.ts b/scripts/tasks/compile.ts index 96381fa23da6..9e7097165622 100644 --- a/scripts/tasks/compile.ts +++ b/scripts/tasks/compile.ts @@ -6,10 +6,13 @@ import { ROOT_DIRECTORY } from '../utils/constants.ts'; import { exec } from '../utils/exec.ts'; import { maxConcurrentTasks } from '../utils/maxConcurrentTasks.ts'; -// The amount of VCPUs for the check task on CI is 4 (large resource) -const amountOfVCPUs = 2; +// The compile task only actually runs in the build--linux / build--windows +// jobs, both on xlarge executors (8 vCPUs). os.cpus() reports the Docker +// host's cores rather than the cgroup limit, so use an explicit value on CI +// instead of maxConcurrentTasks. +const CI_VCPUS = 8; -const parallel = `--parallel=${process.env.CI ? amountOfVCPUs - 1 : maxConcurrentTasks}`; +const parallel = `--parallel=${process.env.CI ? CI_VCPUS : maxConcurrentTasks}`; const linkCommand = `yarn nx run-many -t compile ${parallel}`; const noLinkCommand = `yarn nx run-many -t compile -c production ${parallel}`; From ba027e8379afe530d0a96375afbc2a1efb6e3260 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 11:17:21 +0200 Subject: [PATCH 09/24] Build: launch package builds with native node instead of yarn exec jiti Each of the 43 compile tasks paid ~0.7s for the yarn exec shim plus jiti's transform pass before doing any work. Node 22 runs .ts files natively via type stripping, which needs explicit .ts extensions on relative imports (sanctioned by the AGENTS.md jiti-to-node migration note). The build-config imports in entry-configs.ts pointed one directory too high (../../../code resolves above the repo root); jiti silently rescued them by falling back to CWD-relative resolution, which also forced @ts-ignore on every line. With the correct depth and explicit extensions TypeScript can resolve them, so the @ts-ignore lines are gone. MODULE_TYPELESS_PACKAGE_JSON warnings are disabled because the build-config files live in packages whose package.json intentionally has no "type" field; module-syntax sniffing still works, the flag only silences the log noise. --- nx.json | 2 +- scripts/build/build-package.ts | 14 +- scripts/build/entry-configs.ts | 131 ++++++------------ scripts/build/utils/dts-process.ts | 2 +- scripts/build/utils/generate-bundle.ts | 8 +- scripts/build/utils/generate-package-json.ts | 2 +- .../build/utils/generate-types-rolldown.ts | 4 +- scripts/build/utils/generate-types.ts | 4 +- scripts/utils/constants.ts | 2 +- 9 files changed, 63 insertions(+), 106 deletions(-) diff --git a/nx.json b/nx.json index f315972a194c..5cfd266dbb2c 100644 --- a/nx.json +++ b/nx.json @@ -24,7 +24,7 @@ "targetDefaults": { "compile": { "dependsOn": ["^compile"], - "command": "yarn exec jiti ./scripts/build/build-package.ts --cwd {projectRoot}", + "command": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON ./scripts/build/build-package.ts --cwd {projectRoot}", "configurations": { "production": { "args": "--prod" } }, "cache": true, "inputs": ["production", "^production"], diff --git a/scripts/build/build-package.ts b/scripts/build/build-package.ts index 45846bc2987f..b734bfe5bd09 100755 --- a/scripts/build/build-package.ts +++ b/scripts/build/build-package.ts @@ -18,13 +18,13 @@ import { join, relative } from 'pathe'; import picocolors from 'picocolors'; import prettyTime from 'pretty-hrtime'; -import { ROOT_DIRECTORY } from '../utils/constants'; -import { buildEntries, hasPrebuild, isBuildEntries } from './entry-configs'; -import { measure } from './utils/entry-utils'; -import { generateBundle } from './utils/generate-bundle'; -import { generatePackageJsonFile } from './utils/generate-package-json'; -import { generateTypesFiles } from './utils/generate-types'; -import { generateTypesFiles as generateTypesFilesRolldown } from './utils/generate-types-rolldown'; +import { ROOT_DIRECTORY } from '../utils/constants.ts'; +import { buildEntries, hasPrebuild, isBuildEntries } from './entry-configs.ts'; +import { measure } from './utils/entry-utils.ts'; +import { generateBundle } from './utils/generate-bundle.ts'; +import { generatePackageJsonFile } from './utils/generate-package-json.ts'; +import { generateTypesFiles } from './utils/generate-types.ts'; +import { generateTypesFiles as generateTypesFilesRolldown } from './utils/generate-types-rolldown.ts'; const { values: { diff --git a/scripts/build/entry-configs.ts b/scripts/build/entry-configs.ts index 4d4a84fe2ee5..1fe57071177f 100644 --- a/scripts/build/entry-configs.ts +++ b/scripts/build/entry-configs.ts @@ -1,90 +1,47 @@ -// @ts-ignore -import a11yConfig from '../../../code/addons/a11y/build-config'; -// @ts-ignore -import docsConfig from '../../../code/addons/docs/build-config'; -// @ts-ignore -import linksConfig from '../../../code/addons/links/build-config'; -// @ts-ignore -import onboardingConfig from '../../../code/addons/onboarding/build-config'; -// @ts-ignore -import pseudoStatesConfig from '../../../code/addons/pseudo-states/build-config'; -// @ts-ignore -import themesConfig from '../../../code/addons/themes/build-config'; -// @ts-ignore -import vitestConfig from '../../../code/addons/vitest/build-config'; -// @ts-ignore -import builderViteConfig from '../../../code/builders/builder-vite/build-config'; -// @ts-ignore -import builderWebpack5Config from '../../../code/builders/builder-webpack5/build-config'; -// @ts-ignore -import storybookConfig from '../../../code/core/build-config'; -// @ts-ignore -import angularViteFrameworkConfig from '../../../code/frameworks/angular-vite/build-config'; -// @ts-ignore -import angularFrameworkConfig from '../../../code/frameworks/angular/build-config'; -// @ts-ignore -import emberFrameworkConfig from '../../../code/frameworks/ember/build-config'; -// @ts-ignore -import htmlViteFrameworkConfig from '../../../code/frameworks/html-vite/build-config'; -// @ts-ignore -import nextjsViteFrameworkConfig from '../../../code/frameworks/nextjs-vite/build-config'; -// @ts-ignore -import nextjsFrameworkConfig from '../../../code/frameworks/nextjs/build-config'; -// @ts-ignore -import preactViteFrameworkConfig from '../../../code/frameworks/preact-vite/build-config'; -// @ts-ignore -import reactNativeWebViteFrameworkConfig from '../../../code/frameworks/react-native-web-vite/build-config'; -// @ts-ignore -import reactViteFrameworkConfig from '../../../code/frameworks/react-vite/build-config'; -// @ts-ignore -import reactWebpack5FrameworkConfig from '../../../code/frameworks/react-webpack5/build-config'; -// @ts-ignore -import serverWebpack5FrameworkConfig from '../../../code/frameworks/server-webpack5/build-config'; -// @ts-ignore -import svelteViteFrameworkConfig from '../../../code/frameworks/svelte-vite/build-config'; -// @ts-ignore -import sveltekitFrameworkConfig from '../../../code/frameworks/sveltekit/build-config'; -// @ts-ignore -import vue3ViteFrameworkConfig from '../../../code/frameworks/vue3-vite/build-config'; -// @ts-ignore -import webComponentsViteFrameworkConfig from '../../../code/frameworks/web-components-vite/build-config'; -// @ts-ignore -import tanstackReactFrameworkConfig from '../../../code/frameworks/tanstack-react/build-config'; -// @ts-ignore -import cliConfig from '../../../code/lib/cli-storybook/build-config'; -// @ts-ignore -import codemodConfig from '../../../code/lib/codemod/build-config'; -// @ts-ignore -import coreWebpackConfig from '../../../code/lib/core-webpack/build-config'; -// @ts-ignore -import createStorybookConfig from '../../../code/lib/create-storybook/build-config'; -// @ts-ignore -import csfPluginConfig from '../../../code/lib/csf-plugin/build-config'; -// @ts-ignore -import eslintPluginConfig from '../../../code/lib/eslint-plugin/build-config'; -// @ts-ignore -import reactDomShimConfig from '../../../code/lib/react-dom-shim/build-config'; -// @ts-ignore -import presetCraConfig from '../../../code/presets/create-react-app/build-config'; -// @ts-ignore -import presetReactWebpackConfig from '../../../code/presets/react-webpack/build-config'; -// @ts-ignore -import presetServerWebpackConfig from '../../../code/presets/server-webpack/build-config'; -// @ts-ignore -import htmlRendererConfig from '../../../code/renderers/html/build-config'; -// @ts-ignore -import preactRendererConfig from '../../../code/renderers/preact/build-config'; -// @ts-ignore -import reactRendererConfig from '../../../code/renderers/react/build-config'; -// @ts-ignore -import serverRendererConfig from '../../../code/renderers/server/build-config'; -// @ts-ignore -import svelteRendererConfig from '../../../code/renderers/svelte/build-config'; -// @ts-ignore -import vue3RendererConfig from '../../../code/renderers/vue3/build-config'; -// @ts-ignore -import webComponentsRendererConfig from '../../../code/renderers/web-components/build-config'; -import type { BuildEntriesByPackageName } from './utils/entry-utils'; +import a11yConfig from '../../code/addons/a11y/build-config.ts'; +import docsConfig from '../../code/addons/docs/build-config.ts'; +import linksConfig from '../../code/addons/links/build-config.ts'; +import onboardingConfig from '../../code/addons/onboarding/build-config.ts'; +import pseudoStatesConfig from '../../code/addons/pseudo-states/build-config.ts'; +import themesConfig from '../../code/addons/themes/build-config.ts'; +import vitestConfig from '../../code/addons/vitest/build-config.ts'; +import builderViteConfig from '../../code/builders/builder-vite/build-config.ts'; +import builderWebpack5Config from '../../code/builders/builder-webpack5/build-config.ts'; +import storybookConfig from '../../code/core/build-config.ts'; +import angularViteFrameworkConfig from '../../code/frameworks/angular-vite/build-config.ts'; +import angularFrameworkConfig from '../../code/frameworks/angular/build-config.ts'; +import emberFrameworkConfig from '../../code/frameworks/ember/build-config.ts'; +import htmlViteFrameworkConfig from '../../code/frameworks/html-vite/build-config.ts'; +import nextjsViteFrameworkConfig from '../../code/frameworks/nextjs-vite/build-config.ts'; +import nextjsFrameworkConfig from '../../code/frameworks/nextjs/build-config.ts'; +import preactViteFrameworkConfig from '../../code/frameworks/preact-vite/build-config.ts'; +import reactNativeWebViteFrameworkConfig from '../../code/frameworks/react-native-web-vite/build-config.ts'; +import reactViteFrameworkConfig from '../../code/frameworks/react-vite/build-config.ts'; +import reactWebpack5FrameworkConfig from '../../code/frameworks/react-webpack5/build-config.ts'; +import serverWebpack5FrameworkConfig from '../../code/frameworks/server-webpack5/build-config.ts'; +import svelteViteFrameworkConfig from '../../code/frameworks/svelte-vite/build-config.ts'; +import sveltekitFrameworkConfig from '../../code/frameworks/sveltekit/build-config.ts'; +import vue3ViteFrameworkConfig from '../../code/frameworks/vue3-vite/build-config.ts'; +import webComponentsViteFrameworkConfig from '../../code/frameworks/web-components-vite/build-config.ts'; +import tanstackReactFrameworkConfig from '../../code/frameworks/tanstack-react/build-config.ts'; +import cliConfig from '../../code/lib/cli-storybook/build-config.ts'; +import codemodConfig from '../../code/lib/codemod/build-config.ts'; +import coreWebpackConfig from '../../code/lib/core-webpack/build-config.ts'; +import createStorybookConfig from '../../code/lib/create-storybook/build-config.ts'; +import csfPluginConfig from '../../code/lib/csf-plugin/build-config.ts'; +import eslintPluginConfig from '../../code/lib/eslint-plugin/build-config.ts'; +import reactDomShimConfig from '../../code/lib/react-dom-shim/build-config.ts'; +import presetCraConfig from '../../code/presets/create-react-app/build-config.ts'; +import presetReactWebpackConfig from '../../code/presets/react-webpack/build-config.ts'; +import presetServerWebpackConfig from '../../code/presets/server-webpack/build-config.ts'; +import htmlRendererConfig from '../../code/renderers/html/build-config.ts'; +import preactRendererConfig from '../../code/renderers/preact/build-config.ts'; +import reactRendererConfig from '../../code/renderers/react/build-config.ts'; +import serverRendererConfig from '../../code/renderers/server/build-config.ts'; +import svelteRendererConfig from '../../code/renderers/svelte/build-config.ts'; +import vue3RendererConfig from '../../code/renderers/vue3/build-config.ts'; +import webComponentsRendererConfig from '../../code/renderers/web-components/build-config.ts'; +import type { BuildEntriesByPackageName } from './utils/entry-utils.ts'; export const buildEntries = { storybook: storybookConfig, diff --git a/scripts/build/utils/dts-process.ts b/scripts/build/utils/dts-process.ts index 8fad650f2a4c..9e55c131c0b0 100644 --- a/scripts/build/utils/dts-process.ts +++ b/scripts/build/utils/dts-process.ts @@ -5,7 +5,7 @@ import { rollup } from 'rollup'; import { dts } from 'rollup-plugin-dts'; import { JsxEmit, ScriptTarget } from 'typescript'; -import { getExternal } from './entry-utils'; +import { getExternal } from './entry-utils.ts'; async function run() { const [entryPoint] = process.argv.slice(2); diff --git a/scripts/build/utils/generate-bundle.ts b/scripts/build/utils/generate-bundle.ts index 3566c96aec02..017e33ca0e95 100644 --- a/scripts/build/utils/generate-bundle.ts +++ b/scripts/build/utils/generate-bundle.ts @@ -9,19 +9,19 @@ import { basename, join, relative } from 'pathe'; import picocolors from 'picocolors'; import { dedent } from 'ts-dedent'; -import { globalsModuleInfoMap } from '../../../code/core/src/manager/globals/globals-module-info'; +import { globalsModuleInfoMap } from '../../../code/core/src/manager/globals/globals-module-info.ts'; import { BROWSER_TARGETS, NODE_TARGET, SUPPORTED_FEATURES, -} from '../../../code/core/src/shared/constants/environments-support'; -import { resolvePackageDir } from '../../../code/core/src/shared/utils/module'; +} from '../../../code/core/src/shared/constants/environments-support.ts'; +import { resolvePackageDir } from '../../../code/core/src/shared/utils/module.ts'; import { type BuildEntries, type EntryType, type EsbuildContextOptions, getExternal, -} from './entry-utils'; +} from './entry-utils.ts'; // repo root/bench/esbuild-metafiles/core const DIR_METAFILE_BASE = join( diff --git a/scripts/build/utils/generate-package-json.ts b/scripts/build/utils/generate-package-json.ts index f57a7be9baa6..8dbbd5b5d022 100644 --- a/scripts/build/utils/generate-package-json.ts +++ b/scripts/build/utils/generate-package-json.ts @@ -3,7 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'pathe'; import sortPackageJson from 'sort-package-json'; -import type { BuildEntries } from './entry-utils'; +import type { BuildEntries } from './entry-utils.ts'; export async function generatePackageJsonFile(cwd: string, data: BuildEntries) { const location = join(cwd, 'package.json'); diff --git a/scripts/build/utils/generate-types-rolldown.ts b/scripts/build/utils/generate-types-rolldown.ts index 3423d62dd032..0fb92d200898 100644 --- a/scripts/build/utils/generate-types-rolldown.ts +++ b/scripts/build/utils/generate-types-rolldown.ts @@ -8,8 +8,8 @@ import { rolldown } from 'rolldown'; import { dts } from 'rolldown-plugin-dts'; import ts from 'typescript'; -import type { BuildEntries } from './entry-utils'; -import { getExternal } from './entry-utils'; +import type { BuildEntries } from './entry-utils.ts'; +import { getExternal } from './entry-utils.ts'; const DIR_CODE = join(import.meta.dirname, '..', '..', '..', 'code'); const DIR_ROOT = join(DIR_CODE, '..'); diff --git a/scripts/build/utils/generate-types.ts b/scripts/build/utils/generate-types.ts index a4dd3d7398a7..4d52352f01b8 100644 --- a/scripts/build/utils/generate-types.ts +++ b/scripts/build/utils/generate-types.ts @@ -3,8 +3,8 @@ import limit from 'p-limit'; import { join, relative } from 'pathe'; import picocolors from 'picocolors'; -import { ROOT_DIRECTORY } from '../../utils/constants'; -import type { BuildEntries } from './entry-utils'; +import { ROOT_DIRECTORY } from '../../utils/constants.ts'; +import type { BuildEntries } from './entry-utils.ts'; const DIR_CODE = join(import.meta.dirname, '..', '..', '..', 'code'); diff --git a/scripts/utils/constants.ts b/scripts/utils/constants.ts index c1c8c293283c..62b6122b05c8 100644 --- a/scripts/utils/constants.ts +++ b/scripts/utils/constants.ts @@ -5,7 +5,7 @@ import { join } from 'path'; export const AFTER_DIR_NAME = 'after-storybook'; export const BEFORE_DIR_NAME = 'before-storybook'; -export const ROOT_DIRECTORY = join(__dirname, '..', '..'); +export const ROOT_DIRECTORY = join(import.meta.dirname, '..', '..'); export const CODE_DIRECTORY = join(ROOT_DIRECTORY, 'code'); export const SNIPPETS_DIRECTORY = join(ROOT_DIRECTORY, 'docs', '_snippets'); export const PACKS_DIRECTORY = join(ROOT_DIRECTORY, 'packs'); From 979a2e7d013013448c16b9d3b964fc967b693944 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 11:17:38 +0200 Subject: [PATCH 10/24] ci: pack node_modules into a gzipped tarball for the pipeline workspace The 'Persisting to workspace' step is bytes-bound: CircleCI gzips the layer single-threaded at ~20MB/s, so the ~2.5GB of node_modules dominated the 139s step regardless of file count (a plain uncompressed tar measurably changed nothing). Pre-compressing with gzip -1 shrinks the payload ~3x, which cuts CircleCI's own compression, the upload, and the download in every one of ~100 downstream jobs. gzip is the only compressor available on all executor images; zstd is on none of them (verified via step diagnostics on cimg/node, playwright and machine images). Per-package node_modules folders only exist for packages with unhoistable dependencies, so the pack step filters them at runtime; the three root trees are passed to tar unconditionally so a missing one fails the job loudly. --- scripts/ci/common-jobs.ts | 57 ++++++++++++++++++------------------- scripts/ci/sandboxes.ts | 1 + scripts/ci/utils/helpers.ts | 40 ++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 29 deletions(-) diff --git a/scripts/ci/common-jobs.ts b/scripts/ci/common-jobs.ts index d02b187ce2b9..a503fe167ece 100644 --- a/scripts/ci/common-jobs.ts +++ b/scripts/ci/common-jobs.ts @@ -6,6 +6,7 @@ import { LINUX_ROOT_DIR, WINDOWS_ROOT_DIR, WORKING_DIR } from './utils/constants import { CACHE_KEYS, CACHE_PATHS, + PACKED_NODE_MODULES_ARCHIVE, artifact, cache, git, @@ -22,6 +23,11 @@ import { type JobOrNoOpJob, defineJob, defineNoOpJob } from './utils/types.ts'; const dirname = import.meta.dirname; +const packageDirs = glob.sync(['*/src', '*/*/src'], { + cwd: join(dirname, '../../code'), + onlyDirectories: true, +}); + export const build_linux = defineJob('Build (linux)', (workflowName) => ({ executor: { name: 'sb_node_22_classic', @@ -50,27 +56,25 @@ export const build_linux = defineJob('Build (linux)', (workflowName) => ({ git.check(), ...workflow.reportOnFailure(workflowName), artifact.persist(`code/bench/esbuild-metafiles`, 'bench'), + workspace.pack( + [ + // Workspace-root node_modules folders. Yarn hoists shared/singleton + // dependencies (e.g. `oxc-parser`, `vitest`, `type-fest`) here rather than + // into the per-package `code//node_modules` folders below. Downstream + // jobs otherwise only receive these via the shared `save_cache`, which is + // gated on `isTrustedAuthor()` โ€” so community/fork PRs end up with a + // freshly-built `dist` but no root `node_modules`, producing errors like + // `Cannot find package 'oxc-parser'`. Packing them into the (pipeline- + // scoped, un-gated) workspace makes downstream jobs correct for every PR. + `${WORKING_DIR}/node_modules`, + `${WORKING_DIR}/code/node_modules`, + `${WORKING_DIR}/scripts/node_modules`, + ], + packageDirs.map((p) => `${WORKING_DIR}/code/${p.replace('src', 'node_modules')}`) + ), workspace.persist([ - // Workspace-root node_modules folders. Yarn hoists shared/singleton - // dependencies (e.g. `oxc-parser`, `vitest`, `type-fest`) here rather than - // into the per-package `code//node_modules` folders below. Downstream - // jobs otherwise only receive these via the shared `save_cache`, which is - // gated on `isTrustedAuthor()` โ€” so community/fork PRs end up with a - // freshly-built `dist` but no root `node_modules`, producing errors like - // `Cannot find package 'oxc-parser'`. Persisting them to the (pipeline- - // scoped, un-gated) workspace makes downstream jobs correct for every PR. - `${WORKING_DIR}/node_modules`, - `${WORKING_DIR}/code/node_modules`, - `${WORKING_DIR}/scripts/node_modules`, - ...glob - .sync(['*/src', '*/*/src'], { - cwd: join(dirname, '../../code'), - onlyDirectories: true, - }) - .flatMap((p) => [ - `${WORKING_DIR}/code/${p.replace('src', 'dist')}`, - `${WORKING_DIR}/code/${p.replace('src', 'node_modules')}`, - ]), + PACKED_NODE_MODULES_ARCHIVE, + ...packageDirs.map((p) => `${WORKING_DIR}/code/${p.replace('src', 'dist')}`), `${WORKING_DIR}/.verdaccio-cache`, `${WORKING_DIR}/code/bench`, ]), @@ -115,15 +119,10 @@ export const build_windows = defineJob('Build (windows)', () => ({ verdaccio.start(), workspace.persist( [ - ...glob - .sync(['*/src', '*/*/src'], { - cwd: join(dirname, '../../code'), - onlyDirectories: true, - }) - .flatMap((p) => [ - `code/${p.replace('src', 'dist')}`, - `code/${p.replace('src', 'node_modules')}`, - ]), + ...packageDirs.flatMap((p) => [ + `code/${p.replace('src', 'dist')}`, + `code/${p.replace('src', 'node_modules')}`, + ]), `.verdaccio-cache`, `code/bench`, ], diff --git a/scripts/ci/sandboxes.ts b/scripts/ci/sandboxes.ts index 511c2185136b..ef3457ed6dba 100644 --- a/scripts/ci/sandboxes.ts +++ b/scripts/ci/sandboxes.ts @@ -245,6 +245,7 @@ export function defineSandboxFlow(key: Key) { ...getSandboxSetupSteps(key), 'checkout', // we need the full git history for chromatic workspace.attach(), + workspace.unpack(), { // we copy to the working directory to get git history, which chromatic needs for baselines run: { diff --git a/scripts/ci/utils/helpers.ts b/scripts/ci/utils/helpers.ts index 7c59637000ac..0be62548fa0d 100644 --- a/scripts/ci/utils/helpers.ts +++ b/scripts/ci/utils/helpers.ts @@ -1,6 +1,18 @@ import { LINUX_ROOT_DIR, WINDOWS_ROOT_DIR } from './constants.ts'; import { type JobOrNoOpJob, type Workflow } from './types.ts'; +/** + * The `Persisting to workspace` step is bytes-bound: CircleCI gzips the + * workspace layer single-threaded at roughly 20MB/s, so ~2.5GB of + * node_modules dominated the step regardless of file count (measured: packing + * the trees into a plain tar left persist at 140s). Pre-compressing with + * `gzip -1` (~3x smaller) makes CircleCI's own compression and every + * downstream download proportionally cheaper. gzip is the only compressor + * guaranteed on all executor images; zstd is not available on any of them + * (cimg/node, playwright, machine), verified via step diagnostics. + */ +export const PACKED_NODE_MODULES_ARCHIVE = 'workspace-node_modules.tar.gz'; + export const workspace = { attach: (at = LINUX_ROOT_DIR) => { return { @@ -17,6 +29,33 @@ export const workspace = { }, }; }, + pack: (requiredPaths: string[], optionalPaths: string[], root = LINUX_ROOT_DIR) => { + return { + run: { + name: 'Pack node_modules for workspace', + working_directory: root, + // Per-package node_modules only exist for packages with unhoistable + // dependencies, so they are filtered at runtime; the root trees are + // passed straight to tar so a missing one fails the job loudly. + command: [ + 'optional=""', + `for p in ${optionalPaths.join(' ')}; do`, + ' if [ -e "$p" ]; then optional="$optional $p"; fi', + 'done', + `tar --create ${requiredPaths.join(' ')} $optional | gzip -1 > ${PACKED_NODE_MODULES_ARCHIVE}`, + ].join('\n'), + }, + }; + }, + unpack: (root = LINUX_ROOT_DIR) => { + return { + run: { + name: 'Unpack node_modules from workspace', + working_directory: root, + command: `tar --extract --gzip --file ${PACKED_NODE_MODULES_ARCHIVE}`, + }, + }; + }, }; export const cache = { @@ -182,6 +221,7 @@ export const workflow = { // Downstream jobs should consume precomputed outputs exclusively from the // pipeline workspace to avoid stale cache interference and trust gating. workspace.attach(), + workspace.unpack(), ], restoreWindows: (at = WINDOWS_ROOT_DIR, checkoutOpts: { shallow?: boolean } = {}) => [ git.checkout({ ...checkoutOpts, forceHttps: true }), From 02dff0276f74d5c5d15633e46ee15dcb0a975685 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 11:57:15 +0200 Subject: [PATCH 11/24] Build: keep scripts/utils/constants free of ESM-only globals The Playwright e2e-sandbox specs import scripts/utils/constants.ts and Playwright transpiles it to CommonJS, where import.meta is a syntax error - every sandbox dev/e2e job failed with 'exports is not defined in ES module scope'. Restore __dirname there and compute the repo root locally in the two build modules instead, which run as native ESM and never load under Playwright. --- scripts/build/build-package.ts | 3 +-- scripts/build/utils/generate-types.ts | 7 +++++-- scripts/utils/constants.ts | 5 ++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/scripts/build/build-package.ts b/scripts/build/build-package.ts index b734bfe5bd09..c579768d1f59 100755 --- a/scripts/build/build-package.ts +++ b/scripts/build/build-package.ts @@ -18,7 +18,6 @@ import { join, relative } from 'pathe'; import picocolors from 'picocolors'; import prettyTime from 'pretty-hrtime'; -import { ROOT_DIRECTORY } from '../utils/constants.ts'; import { buildEntries, hasPrebuild, isBuildEntries } from './entry-configs.ts'; import { measure } from './utils/entry-utils.ts'; import { generateBundle } from './utils/generate-bundle.ts'; @@ -61,7 +60,7 @@ const resolvedDtsResolver: 'tsc' | 'oxc' | 'hybrid' = dtsResolver; async function run() { const DIR_ROOT = join(import.meta.dirname, '..', '..'); - const DIR_CWD = cwd ? join(ROOT_DIRECTORY, cwd) : process.cwd(); + const DIR_CWD = cwd ? join(DIR_ROOT, cwd) : process.cwd(); const DIR_DIST = join(DIR_CWD, 'dist'); const DIR_REL = relative(DIR_ROOT, DIR_CWD); diff --git a/scripts/build/utils/generate-types.ts b/scripts/build/utils/generate-types.ts index 4d52352f01b8..9d59e03b9027 100644 --- a/scripts/build/utils/generate-types.ts +++ b/scripts/build/utils/generate-types.ts @@ -3,10 +3,13 @@ import limit from 'p-limit'; import { join, relative } from 'pathe'; import picocolors from 'picocolors'; -import { ROOT_DIRECTORY } from '../../utils/constants.ts'; import type { BuildEntries } from './entry-utils.ts'; -const DIR_CODE = join(import.meta.dirname, '..', '..', '..', 'code'); +// Computed locally instead of importing scripts/utils/constants.ts: that +// module must stay CJS-compatible for Playwright consumers, while this one +// runs as native ESM. +const ROOT_DIRECTORY = join(import.meta.dirname, '..', '..', '..'); +const DIR_CODE = join(ROOT_DIRECTORY, 'code'); const MAX_DTS_ATTEMPTS = 2; const RETRY_DELAY_MS = 500; diff --git a/scripts/utils/constants.ts b/scripts/utils/constants.ts index 62b6122b05c8..89301b7eb1e1 100644 --- a/scripts/utils/constants.ts +++ b/scripts/utils/constants.ts @@ -5,7 +5,10 @@ import { join } from 'path'; export const AFTER_DIR_NAME = 'after-storybook'; export const BEFORE_DIR_NAME = 'before-storybook'; -export const ROOT_DIRECTORY = join(import.meta.dirname, '..', '..'); +// __dirname on purpose: Playwright transpiles importers of this file (e.g. +// code/e2e-sandbox/*.spec.ts) to CommonJS, where import.meta is a syntax +// error. Keep this module free of ESM-only globals. +export const ROOT_DIRECTORY = join(__dirname, '..', '..'); export const CODE_DIRECTORY = join(ROOT_DIRECTORY, 'code'); export const SNIPPETS_DIRECTORY = join(ROOT_DIRECTORY, 'docs', '_snippets'); export const PACKS_DIRECTORY = join(ROOT_DIRECTORY, 'packs'); From b2e5e29eb7e9a155f09c75c4ea7a98105a3f5add Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 13:32:51 +0200 Subject: [PATCH 12/24] ci: parallelize the typescript-validation package checks The check task looped ~44 workspaces sequentially, each spawning 'yarn exec jiti' (~0.7s shim + transform overhead per spawn, the same cost the compile task shed in the native-node migration). The whole TypeCheck code step ran 291s on a medium+ executor while using one of its vCPUs. The task now runs the per-package checks through a p-limit pool and launches check-package.ts with native node. The job executor moves to xlarge so eight checks run concurrently; the slowest packages (core, vue3, svelte, angular) are scheduled first so they don't stretch the tail of the run. Per-package output is buffered and only printed on failure so the parallel log stays readable. check-package.ts computes the repo root locally instead of importing scripts/utils/constants.ts, which intentionally uses __dirname (it must stay loadable from CJS-transpiled contexts like the Playwright specs) and therefore cannot load as native ESM. The scripts 'check' script moves from jiti to native node for the same reason the compile launcher did. --- scripts/check/check-package.ts | 7 ++- scripts/ci/common-jobs.ts | 5 +- scripts/package.json | 2 +- scripts/tasks/check.ts | 105 ++++++++++++++++++++------------- 4 files changed, 76 insertions(+), 43 deletions(-) diff --git a/scripts/check/check-package.ts b/scripts/check/check-package.ts index 5dc2e51063cc..6e1962f1d469 100755 --- a/scripts/check/check-package.ts +++ b/scripts/check/check-package.ts @@ -1,12 +1,17 @@ import { existsSync } from 'node:fs'; import { isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { join } from 'pathe'; -import { ROOT_DIRECTORY } from '../utils/constants.ts'; import { getTSDiagnostics, getTSFilesAndConfig, getTSProgramAndHost } from './utils/typescript.ts'; +// Computed locally instead of importing ../utils/constants.ts: that module +// intentionally uses __dirname (it must stay loadable from CJS-transpiled +// contexts), while this script runs under native node as an ES module. +const ROOT_DIRECTORY = join(fileURLToPath(import.meta.url), '..', '..', '..'); + const { values: { cwd }, } = parseArgs({ diff --git a/scripts/ci/common-jobs.ts b/scripts/ci/common-jobs.ts index a503fe167ece..6f7ca478994d 100644 --- a/scripts/ci/common-jobs.ts +++ b/scripts/ci/common-jobs.ts @@ -244,9 +244,12 @@ export const internalStorybookBuildE2e = defineJob( export const check = defineJob( 'TypeScript validation', (workflowName) => ({ + // xlarge so the check task can typecheck 8 packages concurrently; roughly + // cost-neutral vs the previous serial run on medium+ and much faster + // feedback for the cancel-on-failure gate. executor: { name: 'sb_node_22_classic', - class: 'medium+', + class: 'xlarge', }, steps: [ ...workflow.restoreLinux(), diff --git a/scripts/package.json b/scripts/package.json index ac0861ec7c63..48c33cebf264 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -7,7 +7,7 @@ "bench-packages": "jiti ./bench/bench-packages.ts", "bench:docgen-memory": "node --import jiti/register ./bench/docgen-memory/gate.ts", "build-package": "jiti ./build-package.ts", - "check": "jiti ./check/check-package.ts", + "check": "node ./check/check-package.ts", "check-package": "jiti ./check-package.ts", "docs:check": "jiti ./docs/check-docs.ts", "docs:codemod": "jiti ./snippets/codemod.ts", diff --git a/scripts/tasks/check.ts b/scripts/tasks/check.ts index c8b360ba5182..a8faba0f6116 100644 --- a/scripts/tasks/check.ts +++ b/scripts/tasks/check.ts @@ -2,11 +2,39 @@ import { join } from 'node:path'; // eslint-disable-next-line depend/ban-dependencies import { execaCommand } from 'execa'; +import pLimit from 'p-limit'; import type { Task } from '../task.ts'; import { CODE_DIRECTORY, ROOT_DIRECTORY } from '../utils/constants.ts'; +import { maxConcurrentTasks } from '../utils/maxConcurrentTasks.ts'; import { getCodeWorkspaces } from '../utils/workspace.ts'; +// The typescript-validation job runs on an xlarge (8 vCPU) executor. os.cpus() +// inside Docker reports the host's cores rather than the cgroup limit, so use +// an explicit value on CI instead of maxConcurrentTasks (same reasoning as +// compile.ts). +const CI_VCPUS = 8; + +// Started first so the slowest checks don't begin last and stretch the tail of +// the parallel run. +const HEAVY_WORKSPACES = [ + 'storybook', + '@storybook/vue3', + '@storybook/svelte', + '@storybook/angular', +]; + +function getCheckCommand(name: string, cwd: string) { + if (name === '@storybook/vue3') { + return `npx vue-tsc --noEmit --project ${join(cwd, 'tsconfig.json')}`; + } + if (name === '@storybook/svelte') { + return `npx svelte-check`; + } + const script = join(ROOT_DIRECTORY, 'scripts', 'check', 'check-package.ts'); + return `node ${script} --cwd ${cwd}`; +} + export const check: Task = { description: 'Typecheck the source code of the monorepo', async ready() { @@ -14,46 +42,43 @@ export const check: Task = { }, async run(_, {}) { const failed: string[] = []; - // const command = link ? linkCommand : nolinkCommand; - const workspaces = await getCodeWorkspaces(); - - for (const workspace of workspaces) { - if (workspace.location === '.') { - continue; // skip root directory - } - const cwd = join(CODE_DIRECTORY, workspace.location); - console.log(''); - console.log('Checking ' + workspace.name + ' at ' + cwd); - - let command = ''; - if (workspace.name === '@storybook/vue3') { - command = `npx vue-tsc --noEmit --project ${join(cwd, 'tsconfig.json')}`; - } else if (workspace.name === '@storybook/svelte') { - command = `npx svelte-check`; - } else { - const script = join(ROOT_DIRECTORY, 'scripts', 'check', 'check-package.ts'); - command = `yarn exec jiti ${script} --cwd ${cwd}`; - // command = `npx tsc --noEmit --project ${join(cwd, 'tsconfig.json')}`; - } - - const sub = execaCommand(`${command}`, { - cwd, - env: { - NODE_ENV: 'production', - }, - }); - - sub.stdout?.on('data', (data) => { - process.stdout.write(data); - }); - sub.stderr?.on('data', (data) => { - process.stderr.write(data); - }); - - await sub.catch(() => { - failed.push(workspace.name); - }); - } + const workspaces = (await getCodeWorkspaces()).filter( + (workspace) => workspace.location !== '.' + ); + + workspaces.sort((a, b) => { + const rank = (name: string) => { + const index = HEAVY_WORKSPACES.indexOf(name); + return index === -1 ? HEAVY_WORKSPACES.length : index; + }; + return rank(a.name) - rank(b.name); + }); + + const limit = pLimit(process.env.CI ? CI_VCPUS : maxConcurrentTasks); + + await Promise.all( + workspaces.map((workspace) => + limit(async () => { + const cwd = join(CODE_DIRECTORY, workspace.location); + const command = getCheckCommand(workspace.name, cwd); + + try { + await execaCommand(command, { + cwd, + env: { NODE_ENV: 'production' }, + all: true, + }); + console.log(`โœ… ${workspace.name}`); + } catch (error) { + failed.push(workspace.name); + console.log(`โŒ ${workspace.name}`); + // Output is buffered per package so parallel runs stay readable. + const output = (error as { all?: string }).all; + console.log(output || String(error)); + } + }) + ) + ); if (failed.length > 0) { throw new Error(`Failed to check ${failed.join(', ')}`); From 9e7a20c649e8889957f8269e6a8b4aa2617f7ff4 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 13:33:47 +0200 Subject: [PATCH 13/24] ci: run sandbox E2E specs with parallel Playwright workers Every sandbox E2E pass ran with workers: 1 on CI, leaving the 3-4 vCPU Playwright executors mostly idle: 460-484s per dev-mode pass and 223-239s per static pass for the same spec files. The specs are already independent (fullyParallel was on, each test drives its own page), with one exception: change-detection.spec.ts writes to the sandbox's source files, and the resulting dev-server invalidations can reload other tests' pages mid-assertion. That spec moves to a chromium-mutating project that runs serially after the parallel chromium pass. CI now runs 3 workers per E2E step (overridable via PLAYWRIGHT_WORKERS), and the dev-mode jobs move from medium+ to large so the dev server and the workers don't starve each other - the shorter runtime more than pays for the class. --- code/playwright.config.ts | 27 ++++++++++++++++++++++++--- scripts/ci/sandboxes.ts | 10 +++++++++- scripts/tasks/e2e-tests-build.ts | 7 +++++-- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/code/playwright.config.ts b/code/playwright.config.ts index 3366aaa95b89..813cf35166bf 100644 --- a/code/playwright.config.ts +++ b/code/playwright.config.ts @@ -7,6 +7,9 @@ import { defineConfig, devices } from '@playwright/test'; // process.env.STORYBOOK_URL = 'http://localhost:6006'; // process.env.STORYBOOK_TEMPLATE_NAME = 'react-vite/default-ts'; +/** Specs that mutate sandbox files; they must not run alongside other specs. */ +const MUTATING_SPECS = /change-detection\.spec\.ts/; + /** See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './e2e-sandbox', @@ -25,8 +28,13 @@ export default defineConfig({ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + /* + * Parallel workers on CI: the specs are independent (each test drives its own + * page against the shared Storybook server), except for specs that mutate + * sandbox files โ€” those are quarantined in the serial `chromium-mutating` + * project below. PLAYWRIGHT_WORKERS lets each CI job match its executor size. + */ + workers: process.env.CI ? Number(process.env.PLAYWRIGHT_WORKERS || 2) : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: process.env.PLAYWRIGHT_JUNIT_OUTPUT_NAME ? [ @@ -62,13 +70,26 @@ export default defineConfig({ }, { name: 'chromium', - testIgnore: /.*\.setup\.ts/, + testIgnore: [/.*\.setup\.ts/, MUTATING_SPECS], dependencies: ['setup'], use: { ...devices['Desktop Chrome'], permissions: ['clipboard-read', 'clipboard-write'], }, }, + { + // Specs that write to the sandbox's source files trigger dev-server + // invalidations (HMR, index refresh) that can reload other tests' pages + // mid-assertion. They run here, serially, after the parallel pass. + name: 'chromium-mutating', + testMatch: MUTATING_SPECS, + dependencies: ['chromium'], + fullyParallel: false, + use: { + ...devices['Desktop Chrome'], + permissions: ['clipboard-read', 'clipboard-write'], + }, + }, // { // name: 'firefox', diff --git a/scripts/ci/sandboxes.ts b/scripts/ci/sandboxes.ts index ef3457ed6dba..c70cd1150344 100644 --- a/scripts/ci/sandboxes.ts +++ b/scripts/ci/sandboxes.ts @@ -82,10 +82,12 @@ function defineSandboxJob_dev({ return defineJob( name, () => ({ + // large so the dev server and the parallel Playwright workers don't + // starve each other; the shorter runtime more than pays for the class. executor: options.e2e ? { name: 'sb_playwright', - class: 'medium+', + class: 'large', } : { name: 'sb_node_22_classic', @@ -108,6 +110,9 @@ function defineSandboxJob_dev({ { run: { name: 'Running E2E Tests', + environment: { + PLAYWRIGHT_WORKERS: '3', + }, command: [ 'TEST_FILES=$(circleci tests glob "code/e2e-sandbox/*.{test,spec}.{ts,js,mjs}")', `echo "$TEST_FILES" | circleci tests run --command="xargs yarn task e2e-tests-dev --template ${template} --no-link -s e2e-tests-dev --junit" --verbose --index=0 --total=1`, @@ -314,6 +319,9 @@ export function defineSandboxFlow(key: Key) { { run: { name: 'Running E2E Tests', + environment: { + PLAYWRIGHT_WORKERS: '3', + }, command: [ `TEST_FILES=$(circleci tests glob "code/e2e-sandbox/*.{test,spec}.{ts,js,mjs}")`, `echo "$TEST_FILES" | circleci tests run --command="xargs yarn task e2e-tests --template ${key} --no-link -s e2e-tests --junit" --verbose --index=0 --total=1`, diff --git a/scripts/tasks/e2e-tests-build.ts b/scripts/tasks/e2e-tests-build.ts index 85147e0d588a..90440735d90b 100644 --- a/scripts/tasks/e2e-tests-build.ts +++ b/scripts/tasks/e2e-tests-build.ts @@ -33,9 +33,12 @@ export const e2eTestsBuild: Task & { port: number; type: 'build' | 'dev' } = { const firstTestFileArgIndex = process.argv.findIndex((arg) => testFileRegex.test(arg)); const testFiles = firstTestFileArgIndex === -1 ? [] : process.argv.slice(firstTestFileArgIndex); + // chromium-mutating holds the specs that write to sandbox files; it runs + // after the parallel chromium pass (see code/playwright.config.ts). + const projects = '--project=chromium --project=chromium-mutating'; const playwrightCommand = process.env.DEBUG - ? `yarn playwright test --project=chromium --ui ${testFiles.join(' ')}` - : `yarn playwright test --project=chromium ${testFiles.join(' ')}`; + ? `yarn playwright test ${projects} --ui ${testFiles.join(' ')}` + : `yarn playwright test ${projects} ${testFiles.join(' ')}`; await waitOn({ resources: [`http://localhost:${port}`], interval: 16, timeout: 200000 }); await exec( From 2d47198529e967176a0fef73f703046319a5e9a8 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 13:34:21 +0200 Subject: [PATCH 14/24] ci: pack sandboxes into gzipped tarballs for the pipeline workspace Same treatment the node_modules workspace got: the create jobs persisted the raw sandbox directory (mostly node_modules), paying CircleCI's single-threaded workspace compression on persist (31-61s per template) and again on every downstream attach. Pre-compressing with gzip -1 shrinks the payload ~3x, which cuts the create job's persist - the handoff every build/dev/e2e/vitest/chromatic job in the template's flow waits on - and the attach in each of those jobs. Downstream jobs unpack the per-template archive right after the node_modules unpack; the Windows sandbox jobs (daily workflow) get the same step, since they consume the Linux-created sandbox from the shared workspace. --- scripts/ci/sandboxes.ts | 22 +++++++++++++++------- scripts/ci/utils/helpers.ts | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/scripts/ci/sandboxes.ts b/scripts/ci/sandboxes.ts index c70cd1150344..22f37de73438 100644 --- a/scripts/ci/sandboxes.ts +++ b/scripts/ci/sandboxes.ts @@ -6,6 +6,7 @@ import { build_linux } from './common-jobs.ts'; import { LINUX_ROOT_DIR, SANDBOX_DIR, WINDOWS_ROOT_DIR, WORKING_DIR } from './utils/constants.ts'; import { artifact, + sandboxArchive, server, testResults, toId, @@ -53,7 +54,7 @@ function defineSandboxJob_build({ }, steps: [ ...getSandboxSetupSteps(template), - ...workflow.restoreLinux(), + ...workflow.restoreLinux({ sandboxId: directory }), { run: { name: 'Build storybook', @@ -67,11 +68,13 @@ function defineSandboxJob_build({ ); } function defineSandboxJob_dev({ + directory, name, template, requires, options, }: { + directory: string; name: string; requires: JobOrNoOpJob[]; template: string; @@ -95,7 +98,7 @@ function defineSandboxJob_dev({ }, steps: [ ...getSandboxSetupSteps(template), - ...workflow.restoreLinux(), + ...workflow.restoreLinux({ sandboxId: directory }), ...(options.e2e ? [ { @@ -222,7 +225,8 @@ export function defineSandboxFlow(key: Key) { ] : []), artifact.persist(`${LINUX_ROOT_DIR}/${SANDBOX_DIR}/${id}/debug-storybook.log`, 'logs'), - workspace.persist([`${SANDBOX_DIR}/${id}`]), + workspace.packSandbox(id), + workspace.persist([sandboxArchive(id)]), ], }), [sandboxesNoOpJob] @@ -236,6 +240,7 @@ export function defineSandboxFlow(key: Key) { const devJob = defineSandboxJob_dev({ name: `${name} (dev)`, template: key, + directory: id, requires: [createJob], options: { e2e: !skipTasks?.includes('e2e-tests-dev') }, }); @@ -251,6 +256,7 @@ export function defineSandboxFlow(key: Key) { 'checkout', // we need the full git history for chromatic workspace.attach(), workspace.unpack(), + workspace.unpackSandbox(id), { // we copy to the working directory to get git history, which chromatic needs for baselines run: { @@ -280,7 +286,7 @@ export function defineSandboxFlow(key: Key) { }, steps: [ ...getSandboxSetupSteps(key), - ...workflow.restoreLinux(), + ...workflow.restoreLinux({ sandboxId: id }), { run: { name: 'Running Vitest', @@ -307,7 +313,7 @@ export function defineSandboxFlow(key: Key) { }, steps: [ ...getSandboxSetupSteps(key), - ...workflow.restoreLinux(), + ...workflow.restoreLinux({ sandboxId: id }), { run: { name: 'Serve storybook', @@ -347,7 +353,7 @@ export function defineSandboxFlow(key: Key) { }, steps: [ ...getSandboxSetupSteps(key), - ...workflow.restoreLinux(), + ...workflow.restoreLinux({ sandboxId: id }), { run: { name: 'Running test-runner', @@ -397,7 +403,7 @@ export function defineSandboxTestRunner(sandbox: ReturnType `workspace-sandbox-${id}.tar.gz`; + export const workspace = { attach: (at = LINUX_ROOT_DIR) => { return { @@ -56,6 +63,24 @@ export const workspace = { }, }; }, + packSandbox: (id: string, root = LINUX_ROOT_DIR) => { + return { + run: { + name: 'Pack sandbox for workspace', + working_directory: root, + command: `tar --create ${SANDBOX_DIR}/${id} | gzip -1 > ${sandboxArchive(id)}`, + }, + }; + }, + unpackSandbox: (id: string, root = LINUX_ROOT_DIR) => { + return { + run: { + name: 'Unpack sandbox from workspace', + working_directory: root, + command: `tar --extract --gzip --file ${sandboxArchive(id)}`, + }, + }; + }, }; export const cache = { @@ -216,12 +241,16 @@ export const verdaccio = { }; export const workflow = { - restoreLinux: (checkoutOpts: { forceHttps?: boolean; shallow?: boolean } = {}) => [ + restoreLinux: ({ + sandboxId, + ...checkoutOpts + }: { forceHttps?: boolean; shallow?: boolean; sandboxId?: string } = {}) => [ git.checkout(checkoutOpts), // Downstream jobs should consume precomputed outputs exclusively from the // pipeline workspace to avoid stale cache interference and trust gating. workspace.attach(), workspace.unpack(), + ...(sandboxId ? [workspace.unpackSandbox(sandboxId)] : []), ], restoreWindows: (at = WINDOWS_ROOT_DIR, checkoutOpts: { shallow?: boolean } = {}) => [ git.checkout({ ...checkoutOpts, forceHttps: true }), From d606ac45a4b75cbdf842708453002dffeaa070f1 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 13:58:34 +0200 Subject: [PATCH 15/24] ci: preserve cache validity and ownership through the sandbox tarball The first run of the sandbox tarball broke the dev jobs of every change-detection template two ways: - tar extraction as root (the Playwright image) preserved the create job's uid, unlike attach_workspace which materializes files as the current user. git then refused to operate on the sandbox repo ('dubious ownership'), breaking the change-detection feature and its E2E tests. --no-same-owner restores attach_workspace semantics. - the default gnu tar format truncates mtimes to whole seconds, which invalidated webpack's filesystem-cache snapshots and turned the next-js webpack dev start into a full cold compile. The e2e task's 1s dev readiness probe then timed out, spawned a second dev server on the same port, and crashed with EADDRINUSE when that server finished compiling. posix (pax) format keeps sub-second mtimes. The dev readiness probe also gets a CI-sized timeout: when a server is already listening but still compiling its first preview bundle, waiting is correct and spawning a second server never is. Locally the probe stays at 1s (a dead port still fails fast with ECONNREFUSED either way). --- scripts/ci/utils/helpers.ts | 13 +++++++++++-- scripts/tasks/dev.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/ci/utils/helpers.ts b/scripts/ci/utils/helpers.ts index 9800083023d6..b00d6d042240 100644 --- a/scripts/ci/utils/helpers.ts +++ b/scripts/ci/utils/helpers.ts @@ -68,7 +68,12 @@ export const workspace = { run: { name: 'Pack sandbox for workspace', working_directory: root, - command: `tar --create ${SANDBOX_DIR}/${id} | gzip -1 > ${sandboxArchive(id)}`, + // posix (pax) format keeps sub-second mtimes, which webpack's + // filesystem cache snapshots compare against: the default gnu format + // truncates them to whole seconds, invalidating the cache and turning + // the sandbox's dev-server start into a full cold compile. atime and + // ctime headers are dropped as they only add archive size. + command: `tar --create --format=posix --pax-option=delete=atime,delete=ctime ${SANDBOX_DIR}/${id} | gzip -1 > ${sandboxArchive(id)}`, }, }; }, @@ -77,7 +82,11 @@ export const workspace = { run: { name: 'Unpack sandbox from workspace', working_directory: root, - command: `tar --extract --gzip --file ${sandboxArchive(id)}`, + // --no-same-owner matches attach_workspace semantics: extraction as + // root (the Playwright image) must not preserve the create job's uid, + // or git refuses to operate on the sandbox repo ("dubious ownership") + // and the change-detection feature and its E2E tests break. + command: `tar --extract --gzip --no-same-owner --file ${sandboxArchive(id)}`, }, }; }, diff --git a/scripts/tasks/dev.ts b/scripts/tasks/dev.ts index 1b419704b722..d9422a6619c9 100644 --- a/scripts/tasks/dev.ts +++ b/scripts/tasks/dev.ts @@ -55,7 +55,14 @@ export const dev: Task = { async ready({ key }) { const port = getDevPort(key); try { - await fetch(`http://localhost:${port}/iframe.html`, { signal: AbortSignal.timeout(1000) }); + // When no server is running the fetch fails fast (connection refused), + // so a long timeout only applies while a server is listening but still + // compiling its first preview bundle. On CI that state must count as + // ready: giving up after 1s makes the e2e task spawn a second dev + // server on the same port, which crashes with EADDRINUSE once its own + // compile finishes. Locally keep the probe snappy. + const timeout = process.env.CI ? 180_000 : 1_000; + await fetch(`http://localhost:${port}/iframe.html`, { signal: AbortSignal.timeout(timeout) }); return true; } catch { return false; From c5a1a86d634852af580678580fdfc168fa2bf977 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 13:39:02 +0200 Subject: [PATCH 16/24] ci: fold format check and Knip into a single static-checks job ESLint (177s), Knip (109s) and format check (79s) each paid ~50s of spin-up, checkout and workspace restore per pipeline - and the format-check job additionally ran its own 46s yarn install because it never attached the workspace. None of the three is near the workflow critical path, so they now run as steps of one 'Static checks' job on the workspace, format check first for the fastest failure signal. The standalone fmt job remains for the docs workflow, which has no build job to restore a workspace from. --- scripts/ci/common-jobs.ts | 29 ++++++++++++++--------------- scripts/ci/main.ts | 3 --- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/scripts/ci/common-jobs.ts b/scripts/ci/common-jobs.ts index 6f7ca478994d..c0bf8b46d6b8 100644 --- a/scripts/ci/common-jobs.ts +++ b/scripts/ci/common-jobs.ts @@ -274,8 +274,15 @@ export const check = defineJob( [commonJobsNoOpJob] ); +/** + * ESLint, format check and Knip in one job: each is cheap next to the ~50s of + * spin-up + checkout + workspace restore a dedicated job pays, and none of + * them is anywhere near the workflow critical path. The standalone `fmt` job + * remains for the docs workflow, which has no build job to restore a + * workspace from. + */ export const lint = defineJob( - 'ESLint', + 'Static checks', () => ({ executor: { name: 'sb_node_22_classic', @@ -283,6 +290,12 @@ export const lint = defineJob( }, steps: [ ...workflow.restoreLinux(), + { + run: { + name: 'Format check', + command: 'yarn fmt:check', + }, + }, { run: { name: 'Lint code JS', @@ -297,20 +310,6 @@ export const lint = defineJob( command: 'yarn lint', }, }, - ], - }), - [commonJobsNoOpJob] -); - -export const knip = defineJob( - 'Knip validation', - () => ({ - executor: { - name: 'sb_node_22_classic', - class: 'medium', - }, - steps: [ - ...workflow.restoreLinux(), { run: { name: 'Run Knip', diff --git a/scripts/ci/main.ts b/scripts/ci/main.ts index c048e28a0fcb..b08cb48db49d 100644 --- a/scripts/ci/main.ts +++ b/scripts/ci/main.ts @@ -12,7 +12,6 @@ import { commonJobsNoOpJob, defineCircleciCompletion, docgenMemoryGate, - knip, lint, fmt, internalStorybookBuildE2e, @@ -65,9 +64,7 @@ function generateConfig(workflow: Workflow) { commonJobsNoOpJob, lint, - fmt, check, - knip, storybookChromatic, internalStorybookE2e, From d56b4c57bed675e821a71dfe7e89e530c9907316 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 13:41:24 +0200 Subject: [PATCH 17/24] ci: re-enable the NX cache for CI compiles --skip-nx-cache on CI was a leftover of the concluded NX Cloud agents experiment (per its own comment) and fully disconnected the run from Nx Cloud - the remote cache was neither read nor written. Removing it lets unchanged packages restore their dist from the remote cache instead of recompiling on both build--linux and build--windows. Cache correctness rests on the d.ts determinism guarantee from the rolldown-dts work (byte-identical outputs across clean runs) and on the compile target's inputs: sharedGlobals covers scripts/**/* (the whole build toolchain) and code/tsconfig.json, production covers each project's sources. Untrusted runs without an Nx Cloud token degrade to a warning and an uncached compile. --- scripts/tasks/compile.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/scripts/tasks/compile.ts b/scripts/tasks/compile.ts index 9e7097165622..43f57bce1a10 100644 --- a/scripts/tasks/compile.ts +++ b/scripts/tasks/compile.ts @@ -36,10 +36,7 @@ export const compile: Task = { const command = link && !prod ? linkCommand : noLinkCommand; await rm(join(codeDir, 'bench/esbuild-metafiles'), { recursive: true, force: true }); return exec( - // NX cache is disabled in Circle CI during the NX Cloud experiment so we can - // measure the true cost of NX Cloud agents without local cache hits. - // This will be reverted once the experiment concludes. - `${command} ${skipCache || process.env.CI ? '--skip-nx-cache' : ''}`, + `${command} ${skipCache ? '--skip-nx-cache' : ''}`, { cwd: ROOT_DIRECTORY }, { startMessage: '๐Ÿฅพ Bootstrapping', From 6e153ab2cf977d324250fdc104b1969e2cf267c9 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 14:08:38 +0200 Subject: [PATCH 18/24] ci: build the sandbox static output inside the create job The standalone (build) job per template was ~90s of which ~55s was fixed setup (spin-up, checkout, workspace attach and unpack) around a ~25-45s build - and it sat between (create) and the e2e/chromatic/vitest jobs on the workflow critical path, adding a full job hop plus a workspace round-trip to those chains. The static build now runs at the end of the create job, before the sandbox is packed, so storybook-static travels inside the existing sandbox tarball and downstream jobs need no extra restore step. e2e, chromatic, vitest and test-runner depend on create directly. The Windows sandbox jobs and the daily test-runner job referenced flow jobs by array index; they now use named references, since the build job no longer exists. saveBench('build') records identical data - it runs inside 'yarn task build' regardless of which CI job hosts it, and no job in the generated workflows consumed the bench task output. --- scripts/ci/sandboxes.ts | 72 +++++++++++++---------------------------- 1 file changed, 22 insertions(+), 50 deletions(-) diff --git a/scripts/ci/sandboxes.ts b/scripts/ci/sandboxes.ts index 22f37de73438..62f219f12c70 100644 --- a/scripts/ci/sandboxes.ts +++ b/scripts/ci/sandboxes.ts @@ -34,39 +34,6 @@ function getSandboxSetupSteps(template: string) { return extraSteps; } -function defineSandboxJob_build({ - directory, - name, - template, - requires, -}: { - directory: string; - name: string; - requires: JobOrNoOpJob[]; - template: string; -}) { - return defineJob( - name, - () => ({ - executor: { - name: 'sb_node_22_classic', - class: 'medium+', - }, - steps: [ - ...getSandboxSetupSteps(template), - ...workflow.restoreLinux({ sandboxId: directory }), - { - run: { - name: 'Build storybook', - command: `yarn task build --template ${template} --no-link -s build`, - }, - }, - workspace.persist([`${SANDBOX_DIR}/${directory}/storybook-static`]), - ], - }), - requires - ); -} function defineSandboxJob_dev({ directory, name, @@ -224,6 +191,16 @@ export function defineSandboxFlow(key: Key) { }, ] : []), + { + // Built here rather than in a dedicated job: a separate build job + // paid ~55s of fixed setup plus a workspace round-trip, and it sat + // between create and the e2e/chromatic/vitest jobs on the critical + // path. The static build lands inside the sandbox tarball below. + run: { + name: 'Build storybook', + command: `yarn task build --template ${key} --no-link -s build`, + }, + }, artifact.persist(`${LINUX_ROOT_DIR}/${SANDBOX_DIR}/${id}/debug-storybook.log`, 'logs'), workspace.packSandbox(id), workspace.persist([sandboxArchive(id)]), @@ -231,12 +208,6 @@ export function defineSandboxFlow(key: Key) { }), [sandboxesNoOpJob] ); - const buildJob = defineSandboxJob_build({ - name: `${name} (build)`, - template: key, - directory: id, - requires: [createJob], - }); const devJob = defineSandboxJob_dev({ name: `${name} (dev)`, template: key, @@ -275,7 +246,7 @@ export function defineSandboxFlow(key: Key) { }, ], }), - [buildJob] + [createJob] ); const vitestJob = defineJob( `${name} (vitest)`, @@ -302,7 +273,7 @@ export function defineSandboxFlow(key: Key) { testResults.persist(join(LINUX_ROOT_DIR, WORKING_DIR, 'test-results')), ], }), - [buildJob] + [createJob] ); const e2eJob = defineJob( `${name} (e2e)`, @@ -342,7 +313,7 @@ export function defineSandboxFlow(key: Key) { testResults.persist(join(LINUX_ROOT_DIR, WORKING_DIR, 'test-results')), ], }), - [buildJob] + [createJob] ); const testRunnerJob = defineJob( `${name} (test-runner)`, @@ -363,12 +334,11 @@ export function defineSandboxFlow(key: Key) { testResults.persist(join(LINUX_ROOT_DIR, WORKING_DIR, 'test-results')), ], }), - [buildJob] + [createJob] ); const jobs = [ createJob, - buildJob, devJob, !skipTasks?.includes('chromatic') ? chromaticJob : undefined, !skipTasks?.includes('vitest-integration') ? vitestJob : undefined, @@ -390,12 +360,14 @@ export function defineSandboxFlow(key: Key) { name: key, path, jobs, + createJob, + devJob, }; } export function defineSandboxTestRunner(sandbox: ReturnType) { return defineJob( - `${sandbox.jobs[1].id}-test-runner`, + `${toId(sandbox.name)}--test-runner`, () => ({ executor: { name: 'sb_playwright', @@ -413,13 +385,13 @@ export function defineSandboxTestRunner(sandbox: ReturnType) { return defineJob( - `${sandbox.jobs[2].id}-windows`, + `${sandbox.devJob.id}-windows`, () => ({ executor: { name: 'win/default', @@ -463,13 +435,13 @@ export function defineWindowsSandboxDev(sandbox: ReturnType) { return defineJob( - `${sandbox.jobs[1].id}-windows`, + `${toId(sandbox.name)}--build-windows`, () => ({ executor: { name: 'win/default', @@ -517,7 +489,7 @@ export function defineWindowsSandboxBuild(sandbox: ReturnType Date: Thu, 2 Jul 2026 14:11:52 +0200 Subject: [PATCH 19/24] ci: shard the sandbox dev E2E jobs across two containers Even with parallel Playwright workers, the dev-mode E2E jobs are the workflow's wall-clock tail (300-377s each, starting only after their template's create job). CircleCI parallelism 2 halves the spec list per container via the existing 'circleci tests run' scaffolding - the --index/--total flags were already in place, hardcoded to a single node. Each shard starts its own dev server; change-detection.spec.ts lands on exactly one shard, where the serial chromium-mutating project isolation still applies. The smoke-test dev variant (bench templates) stays unsharded - it runs no Playwright pass. --- scripts/ci/sandboxes.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/ci/sandboxes.ts b/scripts/ci/sandboxes.ts index 62f219f12c70..81346552be3e 100644 --- a/scripts/ci/sandboxes.ts +++ b/scripts/ci/sandboxes.ts @@ -63,6 +63,10 @@ function defineSandboxJob_dev({ name: 'sb_node_22_classic', class: 'medium', }, + // Dev-mode E2E is the workflow's wall-clock tail even with parallel + // Playwright workers: each shard runs its own dev server and half the + // spec files, split by `circleci tests run`. + ...(options.e2e ? { parallelism: 2 } : {}), steps: [ ...getSandboxSetupSteps(template), ...workflow.restoreLinux({ sandboxId: directory }), @@ -85,7 +89,7 @@ function defineSandboxJob_dev({ }, command: [ 'TEST_FILES=$(circleci tests glob "code/e2e-sandbox/*.{test,spec}.{ts,js,mjs}")', - `echo "$TEST_FILES" | circleci tests run --command="xargs yarn task e2e-tests-dev --template ${template} --no-link -s e2e-tests-dev --junit" --verbose --index=0 --total=1`, + `echo "$TEST_FILES" | circleci tests run --command="xargs yarn task e2e-tests-dev --template ${template} --no-link -s e2e-tests-dev --junit" --verbose --index=$CIRCLE_NODE_INDEX --total=$CIRCLE_NODE_TOTAL`, ].join('\n'), }, }, From 8875c389fb6a341f6d2ac07a727d268c4879b2b9 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 14:53:34 +0200 Subject: [PATCH 20/24] ci: order the mutating E2E pass by invocation, not project dependency Playwright runs dependency projects unfiltered: on a sharded dev job whose file subset contained change-detection.spec.ts, the chromium-mutating project's dependency on chromium re-ran the entire chromium suite (105 tests instead of the shard's 41), erasing the sharding win on exactly one of the two containers. The e2e task now runs two sequential playwright invocations - the parallel chromium pass, then the serial chromium-mutating pass - which preserves the isolation ordering while respecting the shard's file filter. --pass-with-no-tests covers shards whose subset has no match for one of the projects, and the mutating pass writes its junit to a -mutating suffixed file so the two reports don't overwrite each other. chromium-mutating keeps only the cheap 'setup' project as a dependency. --- code/playwright.config.ts | 9 +++-- scripts/tasks/e2e-tests-build.ts | 61 +++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/code/playwright.config.ts b/code/playwright.config.ts index 813cf35166bf..4e0d2f7e222e 100644 --- a/code/playwright.config.ts +++ b/code/playwright.config.ts @@ -80,10 +80,15 @@ export default defineConfig({ { // Specs that write to the sandbox's source files trigger dev-server // invalidations (HMR, index refresh) that can reload other tests' pages - // mid-assertion. They run here, serially, after the parallel pass. + // mid-assertion. They run serially, after the parallel pass - ordered by + // the task runner via two sequential invocations (see + // scripts/tasks/e2e-tests-build.ts), NOT via a dependency on 'chromium': + // Playwright runs dependency projects unfiltered, which made CI shards + // whose file subset contained a mutating spec re-run the entire + // chromium suite. name: 'chromium-mutating', testMatch: MUTATING_SPECS, - dependencies: ['chromium'], + dependencies: ['setup'], fullyParallel: false, use: { ...devices['Desktop Chrome'], diff --git a/scripts/tasks/e2e-tests-build.ts b/scripts/tasks/e2e-tests-build.ts index 90440735d90b..f79106650fcc 100644 --- a/scripts/tasks/e2e-tests-build.ts +++ b/scripts/tasks/e2e-tests-build.ts @@ -33,29 +33,48 @@ export const e2eTestsBuild: Task & { port: number; type: 'build' | 'dev' } = { const firstTestFileArgIndex = process.argv.findIndex((arg) => testFileRegex.test(arg)); const testFiles = firstTestFileArgIndex === -1 ? [] : process.argv.slice(firstTestFileArgIndex); - // chromium-mutating holds the specs that write to sandbox files; it runs - // after the parallel chromium pass (see code/playwright.config.ts). - const projects = '--project=chromium --project=chromium-mutating'; - const playwrightCommand = process.env.DEBUG - ? `yarn playwright test ${projects} --ui ${testFiles.join(' ')}` - : `yarn playwright test ${projects} ${testFiles.join(' ')}`; + const baseEnv = { + STORYBOOK_URL: `http://localhost:${port}`, + STORYBOOK_TYPE: this.type, + STORYBOOK_TEMPLATE_NAME: key, + STORYBOOK_SANDBOX_DIR: sandboxDir, + }; await waitOn({ resources: [`http://localhost:${port}`], interval: 16, timeout: 200000 }); - await exec( - playwrightCommand, - { - env: { - STORYBOOK_URL: `http://localhost:${port}`, - STORYBOOK_TYPE: this.type, - STORYBOOK_TEMPLATE_NAME: key, - STORYBOOK_SANDBOX_DIR: sandboxDir, - ...(junitFilename && { - PLAYWRIGHT_JUNIT_OUTPUT_NAME: junitFilename, - }), + + if (process.env.DEBUG) { + await exec( + `yarn playwright test --project=chromium --project=chromium-mutating --ui ${testFiles.join(' ')}`, + { env: baseEnv, cwd: codeDir }, + { dryRun, debug } + ); + return; + } + + // The sandbox-mutating specs run in a second, serial invocation so their + // dev-server invalidations cannot reload the parallel pass's pages. Two + // invocations instead of a Playwright project dependency: dependency + // projects run unfiltered, which would re-run the whole chromium suite on + // any CI shard whose file subset contains a mutating spec. + // --pass-with-no-tests covers shards whose subset has no match for one of + // the projects. + for (const [project, junitSuffix] of [ + ['chromium', ''], + ['chromium-mutating', '-mutating'], + ]) { + await exec( + `yarn playwright test --project=${project} --pass-with-no-tests ${testFiles.join(' ')}`, + { + env: { + ...baseEnv, + ...(junitFilename && { + PLAYWRIGHT_JUNIT_OUTPUT_NAME: junitFilename.replace(/\.xml$/, `${junitSuffix}.xml`), + }), + }, + cwd: codeDir, }, - cwd: codeDir, - }, - { dryRun, debug } - ); + { dryRun, debug } + ); + } }, }; From de091186fc2892565735fcfe788609a540061534 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 18:20:25 +0200 Subject: [PATCH 21/24] ci: trim fixed overhead from the sandbox create flow Four independent trims, measured on a timestamped local reproduction of angular-vite sandbox creation (107s -> 56s) and a phase decomposition of the CI create jobs (83-106s 'Create Sandbox' step): - Yarn setup becomes two file writes. installYarn2 ran 'yarn set version berry' plus chained 'yarn config set' calls: ~12 yarn boots and two network downloads (corepack's bootstrap yarn, then the latest Berry) per sandbox. The sandbox now vendors the repository's own pinned yarn release via yarnPath and writes .yarnrc.yml directly. packageManager is pinned in the sandbox package.json to the same version: without the field, corepack auto-pins its classic bootstrap yarn, and JsPackageManagerFactory would read that field and misclassify the sandbox as Yarn 1 (caught by the local reproduction; installs kept working through yarnPath delegation while the CLI ran classic-syntax commands). - The final 'yarn install' no longer wipes node_modules first. The wipe forced a full re-extraction of the tree (~10-25s on CI) that yarn's incremental install makes redundant. - Extra sandbox deps carry explicit version ranges, so addExtraDependencies does not probe the registry (a subprocess each) to resolve 'latest' for packages whose major we already know - and sandbox contents stop drifting when a new major is published. - The executor images move to cimg/node:22.22.3, matching .nvmrc. That satisfies Angular's minimum Node version, so the per-job 'node/install' step (~9s on every angular create/dev job) is gone; the create jobs also pre-warm the npx cache for the pinned gitpick version the CLI spawns, so the template download does not pay a cold npx install. Rejected while implementing: passing skip-install to the init CLI step (would fold init's install into the final one, ~15-25s) silently no-ops all addon configuration, because postinstallAddon resolves each addon's postinstall hook from the sandbox's node_modules and returns quietly when resolution fails - addon-vitest's setup would disappear from sandboxes. --- scripts/ci/sandboxes.ts | 42 +++++---- scripts/ci/utils/executors.ts | 4 +- scripts/tasks/sandbox.ts | 19 +++-- scripts/utils/yarn.ts | 155 ++++++++++++++++++++-------------- 4 files changed, 131 insertions(+), 89 deletions(-) diff --git a/scripts/ci/sandboxes.ts b/scripts/ci/sandboxes.ts index 81346552be3e..0a3daec085a7 100644 --- a/scripts/ci/sandboxes.ts +++ b/scripts/ci/sandboxes.ts @@ -17,21 +17,26 @@ import { import type { JobOrNoOpJob, Workflow } from './utils/types.ts'; import { defineJob, defineNoOpJob, isWorkflowOrAbove } from './utils/types.ts'; -function getSandboxSetupSteps(template: string) { - const extraSteps = []; +/** + * The cimg/node executors ship Node 22.22.3 (matching .nvmrc), which + * satisfies every template's minimum. The Playwright image pins its own, + * older Node, so jobs running on the sb_playwright executor must still + * install the minimum version for templates that declare one (Angular's CLI + * refuses to boot below it). + */ +function getPlaywrightNodeSetupSteps(template: string) { const templateData = sandboxTemplates.allTemplates[template as TemplateKey]; - - if (templateData.extraCiSteps?.ensureMinNodeVersion) { - extraSteps.push({ + if (!templateData.extraCiSteps?.ensureMinNodeVersion) { + return [] as unknown[]; + } + return [ + { 'node/install': { 'install-yarn': true, - // Currently using Node 22.22.3 as minimum supported version for Angular sandboxes 'node-version': '22.22.3', }, - }); - } - - return extraSteps; + }, + ]; } function defineSandboxJob_dev({ @@ -68,7 +73,7 @@ function defineSandboxJob_dev({ // spec files, split by `circleci tests run`. ...(options.e2e ? { parallelism: 2 } : {}), steps: [ - ...getSandboxSetupSteps(template), + ...(options.e2e ? getPlaywrightNodeSetupSteps(template) : []), ...workflow.restoreLinux({ sandboxId: directory }), ...(options.e2e ? [ @@ -131,7 +136,6 @@ export function defineSandboxFlow(key: Key) { class: 'large', }, steps: [ - ...getSandboxSetupSteps(key), ...workflow.restoreLinux(), verdaccio.start(), { @@ -147,10 +151,13 @@ export function defineSandboxFlow(key: Key) { run: { name: 'Setup Corepack', command: [ - // 'sudo corepack enable', 'which yarn', 'yarn --version', + // Pre-warm the npx cache for the exact gitpick version the CLI + // spawns during 'sb repro' (code/lib/cli-storybook/src/sandbox.ts), + // so the template download does not pay a cold npx install. + 'npx -y gitpick@4.12.4 --version', ].join('\n'), }, }, @@ -227,7 +234,6 @@ export function defineSandboxFlow(key: Key) { class: 'medium', }, steps: [ - ...getSandboxSetupSteps(key), 'checkout', // we need the full git history for chromatic workspace.attach(), workspace.unpack(), @@ -260,7 +266,7 @@ export function defineSandboxFlow(key: Key) { class: 'medium+', }, steps: [ - ...getSandboxSetupSteps(key), + ...getPlaywrightNodeSetupSteps(key), ...workflow.restoreLinux({ sandboxId: id }), { run: { @@ -287,7 +293,7 @@ export function defineSandboxFlow(key: Key) { class: 'medium+', }, steps: [ - ...getSandboxSetupSteps(key), + ...getPlaywrightNodeSetupSteps(key), ...workflow.restoreLinux({ sandboxId: id }), { run: { @@ -327,7 +333,7 @@ export function defineSandboxFlow(key: Key) { class: 'medium', }, steps: [ - ...getSandboxSetupSteps(key), + ...getPlaywrightNodeSetupSteps(key), ...workflow.restoreLinux({ sandboxId: id }), { run: { @@ -378,7 +384,7 @@ export function defineSandboxTestRunner(sandbox: ReturnType { await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)); }; -export const installYarn2 = async ({ cwd, dryRun, debug }: YarnOptions) => { +/** + * Merge keys into the sandbox's .yarnrc.yml with a plain file write. Yarn + * configuration used to happen through chained `yarn config set` calls, but + * each of those boots a yarn process (~0.5s each on CI) and the very first + * one triggers corepack network downloads - a measurable slice of every + * sandbox create job for what is ultimately just YAML authoring. + */ +const mergeYarnrc = async (cwd: string, overrides: Record) => { + const yarnrcPath = join(cwd, '.yarnrc.yml'); + const current = await readFile(yarnrcPath, 'utf-8').catch(() => ''); + const config = { ...(parse(current) ?? {}), ...overrides }; + await writeFile(yarnrcPath, stringify(config)); +}; + +export const installYarn2 = async ({ cwd, dryRun }: YarnOptions) => { + logger.info(`๐Ÿงถ Installing Yarn`); + + if (dryRun) { + return; + } + await rm(join(cwd, '.yarnrc.yml'), { force: true }).catch(() => {}); // TODO: Remove in SB11 const pnpApiExists = await pathExists(join(cwd, '.pnp.cjs')); - await mkdir(cwd, { recursive: true }).then(() => - Promise.all([ - // - writeFile(join(cwd, 'yarn.lock'), ''), - writeFile(join(cwd, '.yarnrc.yml'), ''), - ]) + await mkdir(cwd, { recursive: true }); + await writeFile(join(cwd, 'yarn.lock'), ''); + + // Vendor the repository's own pinned Yarn release instead of running + // `yarn set version berry`, which downloads the latest Berry from the + // network on every sandbox (on top of corepack fetching a bootstrap yarn). + // Any bootstrap yarn - corepack's default or the image's classic yarn - + // sees yarnPath and delegates to this file, so no download happens and the + // sandbox runs the exact yarn version the monorepo itself is tested with. + const { packageManager } = JSON.parse( + await readFile(join(ROOT_DIRECTORY, 'package.json'), 'utf-8') ); + const yarnRelease = `yarn-${packageManager.replace(/^yarn@/, '')}.cjs`; + const releaseSource = join(ROOT_DIRECTORY, '.yarn', 'releases', yarnRelease); + // Fails loudly if the repo ever stops vendoring its yarn release. + await access(releaseSource); + await mkdir(join(cwd, '.yarn', 'releases'), { recursive: true }); + await copyFile(releaseSource, join(cwd, '.yarn', 'releases', yarnRelease)); + + // Pin packageManager in the sandbox to the same version. Without the field, + // corepack auto-pins whatever bootstrap yarn it resolves (classic 1.22.x) + // on the first install; installs still work through yarnPath delegation, + // but JsPackageManagerFactory reads the field first and would classify the + // sandbox as Yarn 1, making the CLI run classic-syntax commands. + const sandboxPackageJsonPath = join(cwd, 'package.json'); + let sandboxPackageJson: Record | undefined; + try { + sandboxPackageJson = JSON.parse(await readFile(sandboxPackageJsonPath, 'utf-8')); + } catch { + sandboxPackageJson = undefined; + } + if (sandboxPackageJson) { + sandboxPackageJson.packageManager = packageManager; + await writeFile(sandboxPackageJsonPath, JSON.stringify(sandboxPackageJson, null, 2)); + } - const command = [ - `yarn set version berry`, - `yarn config set enableGlobalCache true`, // Use the global cache so we aren't re-caching dependencies each time we run sandbox - `yarn config set checksumBehavior ignore`, + await mergeYarnrc(cwd, { + yarnPath: `.yarn/releases/${yarnRelease}`, + // Use the global cache so we aren't re-caching dependencies each time we run sandbox + enableGlobalCache: true, + checksumBehavior: 'ignore', // Yarn 4.15.0 defaults `npmMinimalAgeGate` to 1d, which quarantines freshly // published Storybook packages from the local Verdaccio registry. Disable // the gate inside sandboxes so installs aren't blocked. - `yarn config set npmMinimalAgeGate 0`, - ]; - - if (!pnpApiExists) { - command.push(`yarn config set nodeLinker node-modules`); - } - - await exec( - command.join(' && '), - { cwd }, - { - dryRun, - debug, - startMessage: `๐Ÿงถ Installing Yarn`, - errorMessage: `๐Ÿšจ Installing Yarn failed`, - } - ); + npmMinimalAgeGate: 0, + ...(pnpApiExists ? {} : { nodeLinker: 'node-modules' }), + }); }; export const isViteSandbox = (key?: AllTemplatesKey) => { @@ -143,27 +180,20 @@ export const addWorkaroundResolutions = async ({ export const configureYarn2ForVerdaccio = async ({ cwd, dryRun, - debug, key, }: YarnOptions & { key: AllTemplatesKey }) => { + logger.info(`๐ŸŽ› Configuring Yarn 2`); + + if (dryRun) { + return; + } + // On NX Cloud agents, we use the global cache to avoid duplicating .yarn/cache across sandboxes. // Stale @storybook/* packages are cleaned from the global cache in the agent init step (agents.yaml). // Locally and on CircleCI, we disable the global cache to avoid stale packages from previous runs. const useGlobalCache = Boolean(process.env.STORYBOOK_NX_CLOUD_AGENT); - const command = [ - `yarn config set enableGlobalCache ${useGlobalCache}`, - `yarn config set enableMirror false`, - // โš ๏ธ Need to set registry because Yarn 2 is not using the conf of Yarn 1 (URL is hardcoded in CircleCI config.yml) - `yarn config set npmRegistryServer "http://localhost:6001/"`, - // Some required magic to be able to fetch deps from local registry - `yarn config set unsafeHttpWhitelist "localhost"`, - // Disable fallback mode to make sure everything is required correctly - `yarn config set pnpFallbackMode none`, - // We need to be able to update lockfile when bootstrapping the examples - `yarn config set enableImmutableInstalls false`, - ]; - + let logFilters: { code: string; level: string }[] | undefined; if ( key.includes('svelte-kit') || // React prereleases will have INCOMPATIBLE_PEER_DEPENDENCY errors because of transitive dependencies not allowing v19 betas @@ -176,27 +206,28 @@ export const configureYarn2ForVerdaccio = async ({ key.includes('web-components-rsbuild/default-ts') ) { // Don't error with INCOMPATIBLE_PEER_DEPENDENCY for SvelteKit sandboxes, it is expected to happen with @sveltejs/vite-plugin-svelte - command.push( - `yarn config set logFilters --json "[{\\"code\\":\\"YN0013\\",\\"level\\":\\"discard\\"}]"` - ); + logFilters = [{ code: 'YN0013', level: 'discard' }]; } else if (key.includes('nuxt')) { // Nothing to do for Nuxt } else { - // Discard all YN0013 - FETCH_NOT_CACHED messages - // Error on YN0060 - INCOMPATIBLE_PEER_DEPENDENCY - command.push( - `yarn config set logFilters --json "[{\\"code\\":\\"YN0013\\",\\"level\\":\\"discard\\"},{\\"code\\":\\"YN0060\\",\\"level\\":\\"discard\\"}]"` - ); + // Discard all YN0013 - FETCH_NOT_CACHED and YN0060 - INCOMPATIBLE_PEER_DEPENDENCY messages + logFilters = [ + { code: 'YN0013', level: 'discard' }, + { code: 'YN0060', level: 'discard' }, + ]; } - await exec( - command.join(' && '), - { cwd }, - { - dryRun, - debug, - startMessage: `๐ŸŽ› Configuring Yarn 2`, - errorMessage: `๐Ÿšจ Configuring Yarn 2 failed`, - } - ); + await mergeYarnrc(cwd, { + enableGlobalCache: useGlobalCache, + enableMirror: false, + // โš ๏ธ Need to set registry because Yarn 2 is not using the conf of Yarn 1 (URL is hardcoded in CircleCI config.yml) + npmRegistryServer: 'http://localhost:6001/', + // Some required magic to be able to fetch deps from local registry + unsafeHttpWhitelist: ['localhost'], + // Disable fallback mode to make sure everything is required correctly + pnpFallbackMode: 'none', + // We need to be able to update lockfile when bootstrapping the examples + enableImmutableInstalls: false, + ...(logFilters ? { logFilters } : {}), + }); }; From d3bb8edc4aa4487ee6beaefdaeaf76e5fa3d744e Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 19:32:04 +0200 Subject: [PATCH 22/24] ci: treat a held dev port as ready in the dev task probe The readiness probe fetched /iframe.html and re-ran the dev task on any failure. But the fetch can fail while a dev server owns the port - a cold compile outlasting the timeout, or the server resetting connections before its middleware is up (seen on a sveltekit shard: the probe got a reset, the task spawned a second server, and that server crashed the job with EADDRINUSE once its own compile finished). A held port now counts as ready via a TCP-connect fallback; every consumer of the dev task performs its own HTTP wait before using the server, so this cannot mask a dead server. This also replaces the earlier CI-only 180s probe timeout with behavior that is correct locally too. --- scripts/tasks/dev.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/scripts/tasks/dev.ts b/scripts/tasks/dev.ts index d9422a6619c9..a1dc70c447aa 100644 --- a/scripts/tasks/dev.ts +++ b/scripts/tasks/dev.ts @@ -1,5 +1,6 @@ import { execSync } from 'node:child_process'; import { existsSync } from 'node:fs'; +import { connect } from 'node:net'; import { join } from 'node:path'; import waitOn from 'wait-on'; @@ -55,17 +56,23 @@ export const dev: Task = { async ready({ key }) { const port = getDevPort(key); try { - // When no server is running the fetch fails fast (connection refused), - // so a long timeout only applies while a server is listening but still - // compiling its first preview bundle. On CI that state must count as - // ready: giving up after 1s makes the e2e task spawn a second dev - // server on the same port, which crashes with EADDRINUSE once its own - // compile finishes. Locally keep the probe snappy. - const timeout = process.env.CI ? 180_000 : 1_000; - await fetch(`http://localhost:${port}/iframe.html`, { signal: AbortSignal.timeout(timeout) }); + await fetch(`http://localhost:${port}/iframe.html`, { signal: AbortSignal.timeout(1000) }); return true; } catch { - return false; + // The fetch can fail while a dev server owns the port: a cold compile + // outlasting the timeout, or the server resetting connections before + // its middleware is up. Spawning a second server then can only crash + // with EADDRINUSE once it finishes compiling, so a held port must + // count as ready - every consumer of this task performs its own HTTP + // wait before using the server. + return await new Promise((resolve) => { + const socket = connect({ port, host: '127.0.0.1' }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + }); } }, async run({ sandboxDir, key, selectedTask }, { dryRun, debug, link }) { From f50a611479ac14719ae7973a13f66d3c6a4fd9e2 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 2 Jul 2026 18:35:37 +0200 Subject: [PATCH 23/24] CLI: skip redundant registry probes during sandbox creation Sandbox creation spawned ~24 package-manager subprocesses just to resolve versions, measured via a timestamped local reproduction of angular-vite sandbox creation and confirmed in the CI create-job logs: - latestVersion() probed the registry for every storybook package even though the CLI ships the authoritative version map (versions.ts) and the sandbox registry (verdaccio) serves exactly those versions. Inside sandbox creation (IN_STORYBOOK_SANDBOX, set by the monorepo task runner and never by real user installs) the probe is now short-circuited to the versions map. On CI the resulting specifiers are byte-identical; in link-mode local sandboxes storybook packages get ^version instead of an exact pin, where the linked workspace overrides resolution anyway. - addDependencies(skipInstall) resolved a version for every dependency serially - including dependencies whose specifier already carried one, discarding the result. Lookups now only happen for unversioned specifiers and run concurrently, with insertion order kept deterministic. This also speeds up real 'storybook init' runs. - getInstalledVersion() spawned a full dependency-graph walk per package (yarn info --recursive), which exits 1 with usage output for packages that are not installed at all - addon-vitest probes vitest, msw and coverage packages this way on every sandbox. The module's own package.json is now resolved first (a plain fs read, PnP included), and the graph walk only runs as a fallback for declared dependencies that don't resolve directly (e.g. pnpm virtual store layouts). - The 'sb repro' command probed npm for latest/next before downloading the template; inside sandbox creation those two roundtrips only feed the 'you are behind' warning copy and are skipped. The proxy unit tests asserting registry-probe behavior now stub IN_STORYBOOK_SANDBOX off explicitly - the repo-level .env marks all vitest runs as sandbox context - and a new test pins the short-circuit contract. Measured locally (angular-vite, full creation): 24 -> 8 version-probe spawns; 56s -> 48s on top of the CI-side trims, 107s cumulative baseline. --- .../js-package-manager/JsPackageManager.ts | 41 +++++++++++++++++-- .../js-package-manager/NPMProxy.test.ts | 20 ++++++++- .../js-package-manager/PNPMProxy.test.ts | 7 ++++ .../js-package-manager/Yarn1Proxy.test.ts | 9 +++- .../js-package-manager/Yarn2Proxy.test.ts | 7 ++++ code/lib/cli-storybook/src/sandbox.ts | 15 +++++-- 6 files changed, 90 insertions(+), 9 deletions(-) diff --git a/code/core/src/common/js-package-manager/JsPackageManager.ts b/code/core/src/common/js-package-manager/JsPackageManager.ts index 796b5d20b9ba..5a005c917c91 100644 --- a/code/core/src/common/js-package-manager/JsPackageManager.ts +++ b/code/core/src/common/js-package-manager/JsPackageManager.ts @@ -15,6 +15,7 @@ import invariant from 'tiny-invariant'; import { HandledError } from '../utils/HandledError.ts'; import type { ExecuteCommandOptions } from '../utils/command.ts'; +import { optionalEnvToBoolean } from '../utils/envs.ts'; import { findFilesUp, getProjectRoot } from '../utils/paths.ts'; import storybookPackagesVersions from '../versions.ts'; import type { PackageJson, PackageJsonWithDepsAndDevDeps } from './PackageJson.ts'; @@ -338,10 +339,17 @@ export abstract class JsPackageManager { const { operationDir, packageJson } = packageJsonInfo; const dependenciesMap: Record = {}; - for (const dep of dependencies) { - const [packageName, packageVersion] = getPackageDetails(dep); - const latestVersion = await this.getVersion(packageName); - dependenciesMap[packageName] = packageVersion ?? latestVersion; + // Versions resolve concurrently (each lookup may spawn the package + // manager), and only for dependencies that don't carry one already. + // The entries array keeps package.json key order deterministic. + const entries = await Promise.all( + dependencies.map(async (dep) => { + const [packageName, packageVersion] = getPackageDetails(dep); + return [packageName, packageVersion ?? (await this.getVersion(packageName))] as const; + }) + ); + for (const [packageName, version] of entries) { + dependenciesMap[packageName] = version; } const targetDeps = packageJson[options.type] as Record; @@ -519,6 +527,21 @@ export abstract class JsPackageManager { return cachedVersion; } + // In monorepo sandbox creation every storybook package is published to + // the local registry at exactly the version this CLI was built with, so + // the registry probe (a package-manager subprocess per package) is a + // foregone conclusion. Real user installs never have this variable set. + if ( + optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX) && + packageName in storybookPackagesVersions + ) { + const version = storybookPackagesVersions[packageName as StorybookPackage]; + if (!constraint || satisfies(version, constraint)) { + JsPackageManager.latestVersionCache.set(cacheKey, version); + return version; + } + } + let result: string; logger.debug(`Getting CLI versions from NPM for ${packageName}...`); @@ -691,7 +714,17 @@ export abstract class JsPackageManager { version = vitePlusVersions[packageName]!; } + // Resolving the module's own package.json is a plain fs read (PnP + // included) and covers the common cases - installed-and-resolvable, or + // not installed at all. findInstallations spawns the package manager + // for a full dependency-graph walk, so it stays as the fallback for + // declared dependencies that don't resolve directly (e.g. pnpm's + // virtual store layouts). if (!version) { + version = (await this.getModulePackageJSON(packageName))?.version ?? null; + } + + if (!version && this.isDependencyInstalled(packageName)) { const installations = await this.findInstallations([packageName]); if (installations) { version = Object.entries(installations.dependencies)[0]?.[1]?.[0].version || null; diff --git a/code/core/src/common/js-package-manager/NPMProxy.test.ts b/code/core/src/common/js-package-manager/NPMProxy.test.ts index 10ca14d44123..948b177be8cf 100644 --- a/code/core/src/common/js-package-manager/NPMProxy.test.ts +++ b/code/core/src/common/js-package-manager/NPMProxy.test.ts @@ -1,10 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { prompt } from 'storybook/internal/node-logger'; import { MinimumReleaseAgeHandledError } from 'storybook/internal/server-errors'; import { executeCommand } from '../utils/command.ts'; import { JsPackageManager } from './JsPackageManager.ts'; +import storybookPackagesVersions from '../versions.ts'; import { NPMProxy } from './NPMProxy.ts'; vi.mock('storybook/internal/node-logger', () => ({ @@ -30,9 +31,16 @@ describe('NPM Proxy', () => { vi.useRealTimers(); npmProxy = new NPMProxy(); JsPackageManager.clearLatestVersionCache(); + // The repo-level .env marks test runs as sandbox context; these tests + // assert user-facing registry-probe behavior, so run them without it. + vi.stubEnv('IN_STORYBOOK_SANDBOX', 'false'); vi.spyOn(npmProxy, 'writePackageJson').mockImplementation(vi.fn()); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('type should be npm', () => { expect(npmProxy.type).toEqual('npm'); }); @@ -310,6 +318,16 @@ describe('NPM Proxy', () => { }); describe('latestVersion', () => { + it('returns the monorepo version without spawning inside a sandbox task', async () => { + vi.stubEnv('IN_STORYBOOK_SANDBOX', 'true'); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '5.3.19' } as any); + + const version = await npmProxy.latestVersion('storybook'); + + expect(executeCommandSpy).not.toHaveBeenCalled(); + expect(version).toEqual(storybookPackagesVersions.storybook); + }); + it('without constraint it returns the latest version', async () => { const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '5.3.19' } as any); diff --git a/code/core/src/common/js-package-manager/PNPMProxy.test.ts b/code/core/src/common/js-package-manager/PNPMProxy.test.ts index f0039e12e344..3efb98eebe88 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.test.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.test.ts @@ -38,9 +38,16 @@ describe('PNPM Proxy', () => { beforeEach(() => { pnpmProxy = new PNPMProxy(); JsPackageManager.clearLatestVersionCache(); + // The repo-level .env marks test runs as sandbox context; these tests + // assert user-facing registry-probe behavior, so run them without it. + vi.stubEnv('IN_STORYBOOK_SANDBOX', 'false'); vi.spyOn(pnpmProxy, 'writePackageJson').mockImplementation(vi.fn()); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('type should be pnpm', () => { expect(pnpmProxy.type).toEqual('pnpm'); }); diff --git a/code/core/src/common/js-package-manager/Yarn1Proxy.test.ts b/code/core/src/common/js-package-manager/Yarn1Proxy.test.ts index 973c8d54127a..3239bc817438 100644 --- a/code/core/src/common/js-package-manager/Yarn1Proxy.test.ts +++ b/code/core/src/common/js-package-manager/Yarn1Proxy.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { prompt } from 'storybook/internal/node-logger'; @@ -41,9 +41,16 @@ describe('Yarn 1 Proxy', () => { beforeEach(() => { yarn1Proxy = new Yarn1Proxy(); JsPackageManager.clearLatestVersionCache(); + // The repo-level .env marks test runs as sandbox context; these tests + // assert user-facing registry-probe behavior, so run them without it. + vi.stubEnv('IN_STORYBOOK_SANDBOX', 'false'); vi.spyOn(yarn1Proxy, 'writePackageJson').mockImplementation(vi.fn()); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('type should be yarn1', () => { expect(yarn1Proxy.type).toEqual(PackageManagerName.YARN1); }); diff --git a/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts b/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts index 271d8410b868..17e49abdcea3 100644 --- a/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts +++ b/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts @@ -40,9 +40,16 @@ describe('Yarn 2 Proxy', () => { beforeEach(() => { yarn2Proxy = new Yarn2Proxy(); JsPackageManager.clearLatestVersionCache(); + // The repo-level .env marks test runs as sandbox context; these tests + // assert user-facing registry-probe behavior, so run them without it. + vi.stubEnv('IN_STORYBOOK_SANDBOX', 'false'); vi.spyOn(yarn2Proxy, 'writePackageJson').mockImplementation(vi.fn()); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('type should be yarn2', () => { expect(yarn2Proxy.type).toEqual('yarn2'); }); diff --git a/code/lib/cli-storybook/src/sandbox.ts b/code/lib/cli-storybook/src/sandbox.ts index c066250b2e4d..68f4974685cd 100644 --- a/code/lib/cli-storybook/src/sandbox.ts +++ b/code/lib/cli-storybook/src/sandbox.ts @@ -42,14 +42,23 @@ export const sandbox = async ({ let selectedConfig: Template | undefined = TEMPLATES[filterValue as TemplateKey]; let templateId: Choice | null = selectedConfig ? (filterValue as TemplateKey) : null; + const currentVersion = versions.storybook; + // In monorepo sandbox creation the CLI is freshly built from the local + // branch, so the latest/next registry probes can only ever produce the + // "you are behind" warning copy - at the cost of two npm roundtrips before + // the template download starts. Treat the local version as current there. + const insideSandboxTask = optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX); // Always use npm to fetch versions, as other package manager commands may fail when running in // non-project directories (e.g. parent sandbox directory). We just need to use npm info for this use case. const packageManager = JsPackageManagerFactory.getPackageManager({ force: PackageManagerName.NPM, }); - const latestVersion = (await packageManager.latestVersion('storybook'))!; - const nextVersion = (await packageManager.latestVersion('storybook@next')) ?? '0.0.0'; - const currentVersion = versions.storybook; + const latestVersion = insideSandboxTask + ? currentVersion + : (await packageManager.latestVersion('storybook'))!; + const nextVersion = insideSandboxTask + ? currentVersion + : ((await packageManager.latestVersion('storybook@next')) ?? '0.0.0'); const isPrerelease = prerelease(currentVersion); const isOutdated = lt(currentVersion, isPrerelease ? nextVersion : latestVersion); From ab81b9176451478ee4d574a97f229d1bbe8f179d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 11:28:14 +0000 Subject: [PATCH 24/24] CI: Split ci:normal into ci:core and framework/builder split labels Adds composable CI labels so PRs can run a much smaller, faster pipeline than ci:normal: - ci:core runs the internal Storybook e2e tests and every non-sandbox-specific job (build, unit/story tests, static checks, chromatic, benchmarks, test-storybooks, init-empty) - i.e. the normal workflow minus all sandbox jobs. - ci: (ci:react, ci:vue3, ci:angular, ci:svelte, ci:html, ci:preact, ci:web-components, ci:solid) runs ALL sandboxes for that framework, drawn from the complete (daily) template pool. - ci: (ci:vite, ci:webpack, ci:rsbuild) runs ALL sandboxes for that builder. Split labels compose: the trigger workflow joins them into a single `+`-separated workflow value (e.g. core+react) so one pipeline - and one completion status check - covers the union of the selected atoms. The legacy cadence labels (ci:normal/merged/daily/docs) still work unchanged and take precedence over split labels when both are present; the dangerfile now rejects that mix and otherwise accepts split labels in place of a cadence label. The CircleCI `workflow` pipeline parameter becomes a plain string (validated in scripts/ci/main.ts, which fails setup loudly on unknown atoms). Framework/builder atoms are derived from each sandbox template's expected renderer/builder metadata, with both Angular packages mapping to the single `angular` atom. Legacy workflows generate byte-identical configs apart from the parameter type change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JDwFN1kYSqVTZah1BvKQKu --- .agents/skills/open-pr/SKILL.md | 2 +- .agents/skills/pr/SKILL.md | 10 ++- .circleci/config.yml | 13 ++-- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .../workflows/trigger-circle-ci-workflow.yml | 24 +++++++- scripts/ci/main.ts | 61 ++++++++++++++++--- scripts/ci/sandboxes.ts | 54 ++++++++++++++++ scripts/ci/utils/parameters.ts | 6 +- scripts/ci/utils/types.ts | 24 +++++++- scripts/dangerfile.js | 30 +++++++-- 10 files changed, 192 insertions(+), 34 deletions(-) diff --git a/.agents/skills/open-pr/SKILL.md b/.agents/skills/open-pr/SKILL.md index 8cf1abafbc70..d35a7b428a87 100644 --- a/.agents/skills/open-pr/SKILL.md +++ b/.agents/skills/open-pr/SKILL.md @@ -31,7 +31,7 @@ Use **AskQuestion** for three questions. Options from `.github/PULL_REQUEST_TEMP | Question | Options | | -------- | ------- | -| CI label | `ci:normal`, `ci:merged`, `ci:daily` | +| CI label | `ci:normal`, `ci:merged`, `ci:daily`, or split labels: `ci:core` and/or framework/builder labels (`ci:react`, `ci:vue3`, `ci:angular`, `ci:vite`, `ci:webpack`, ...) | | QA label | `qa:needed`, `qa:skip` | | Type label | `bug`, `maintenance`, `dependencies`, `build`, `cleanup`, `documentation`, `feature request`, `BREAKING CHANGE`, `other` | diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index b5795b21b9d2..8bc9e97877b3 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -34,13 +34,21 @@ Add these labels to the PR: - `BREAKING CHANGE` - breaks compatibility - `other` - doesn't fit above -**CI (required, pick one):** +**CI (required, pick one cadence label OR one or more split labels):** + +Cadence labels (pick at most one, do not combine with split labels): - `ci:normal` - standard sandbox set; default for most code changes - `ci:merged` - merged sandbox set - `ci:daily` - daily sandbox set; use this when changes affect prerelease sandboxes or sandboxes pinned to a framework or React version other than latest - `ci:docs` - documentation-only changes (use with `documentation` category) +Split labels (combinable with each other; faster and more targeted than cadence labels): + +- `ci:core` - internal e2e tests and all non-sandbox-specific tests; use for changes that don't affect sandbox behavior +- `ci:` (e.g. `ci:react`, `ci:vue3`, `ci:angular`, `ci:svelte`, `ci:html`, `ci:preact`, `ci:web-components`, `ci:solid`) - all sandboxes for that framework +- `ci:` (`ci:vite`, `ci:webpack`, `ci:rsbuild`) - all sandboxes for that builder + **QA (required, pick one):** Tells the release team whether manual QA is needed before the next minor release. diff --git a/.circleci/config.yml b/.circleci/config.yml index 72a40b1c00b1..d221bab0d862 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,14 +23,11 @@ parameters: type: string workflow: default: skipped - description: Which workflow to run - enum: - - normal - - merged - - daily - - skipped - - docs - type: enum + description: >- + Which workflow to run: a single workflow (normal, merged, daily, docs, core), or a + `+`-separated list of sandbox-split atoms (a framework such as react/vue3/angular, or a + builder such as vite/webpack), e.g. "core+react". Validated by scripts/ci/main.ts. + type: string jobs: generate-and-run-config: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e50fbad8495c..3dd0081a1a34 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -50,7 +50,7 @@ Do not describe how YOU tested the PR code, but how a separate maintainer should ## 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` +- [ ] When this PR is ready for testing, add a CI label to it: `ci:normal`, `ci:merged` or `ci:daily` to run a specific set of sandboxes (see `code/lib/cli-storybook/src/sandbox-templates.ts`), or the faster split labels โ€” `ci:core` (internal e2e tests and non-sandbox tests) combined with framework labels (`ci:react`, `ci:vue3`, `ci:angular`, ...) and/or builder labels (`ci:vite`, `ci:webpack`) that run all sandboxes for that framework or builder - [ ] 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:
diff --git a/.github/workflows/trigger-circle-ci-workflow.yml b/.github/workflows/trigger-circle-ci-workflow.yml index d2f16bf0ec28..bb3ab90c52b3 100644 --- a/.github/workflows/trigger-circle-ci-workflow.yml +++ b/.github/workflows/trigger-circle-ci-workflow.yml @@ -96,6 +96,28 @@ jobs: - id: daily if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:daily')) run: echo "workflow=daily" >> "$GITHUB_OUTPUT" + - id: split + if: github.event_name == 'pull_request_target' + env: + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + # Combine the split CI labels โ€” ci:core, ci: (e.g. ci:react, + # ci:vue3, ci:angular) and ci: (e.g. ci:vite, ci:webpack) โ€” + # into a single `+`-separated workflow value such as "core+react". + # The cadence labels are handled by the steps above and take + # precedence. The label pattern below intentionally excludes labels + # with spaces ("ci: do not merge"); each atom is validated by the + # config generator (scripts/ci/main.ts), which fails the pipeline + # setup on unknown atoms. + WORKFLOW=$(echo "$LABELS" | jq -r ' + [ .[] + | select(test("^ci:[a-z0-9-]+$")) + | ltrimstr("ci:") + | select(. != "normal" and . != "docs" and . != "merged" and . != "daily") + ] | sort | join("+")') + if [ -n "$WORKFLOW" ]; then + echo "workflow=$WORKFLOW" >> "$GITHUB_OUTPUT" + fi - id: trusted-author env: EVENT_NAME: ${{ github.event_name }} @@ -132,7 +154,7 @@ jobs: echo "result=$IS_TRUSTED" >> "$GITHUB_OUTPUT" fi outputs: - workflow: ${{ steps.normal.outputs.workflow || steps.docs.outputs.workflow || steps.merged.outputs.workflow || steps.daily.outputs.workflow }} + workflow: ${{ steps.normal.outputs.workflow || steps.docs.outputs.workflow || steps.merged.outputs.workflow || steps.daily.outputs.workflow || steps.split.outputs.workflow }} ghBaseBranch: ${{ github.event.pull_request.base.ref }} ghPrNumber: ${{ github.event.pull_request.number }} ghTrustedAuthor: ${{ steps.trusted-author.outputs.result }} diff --git a/scripts/ci/main.ts b/scripts/ci/main.ts index b08cb48db49d..e625c209bb95 100644 --- a/scripts/ci/main.ts +++ b/scripts/ci/main.ts @@ -22,7 +22,12 @@ import { testsUnit_linux, } from './common-jobs.ts'; import { getInitEmpty, initEmptyNoOpJob } from './init-empty.ts'; -import { getSandboxes, sandboxesNoOpJob } from './sandboxes.ts'; +import { + getSandboxSplitAtoms, + getSandboxes, + getSplitSandboxes, + sandboxesNoOpJob, +} from './sandboxes.ts'; import { getTestStorybooks, testStorybooksNoOpJob } from './test-storybooks.ts'; import { executors } from './utils/executors.ts'; import { ensureRequiredJobs } from './utils/helpers.ts'; @@ -34,20 +39,41 @@ import type { JobOrNoOpJob, NoOpJobImplementationObj, } from './utils/types.ts'; -import { type Workflow, isWorkflowOrAbove } from './utils/types.ts'; +import { STATIC_WORKFLOWS, type Workflow, isWorkflowOrAbove } from './utils/types.ts'; const dirname = import.meta.dirname; /** - * Generate the CircleCI config for a given workflow. + * Parse the `workflow` pipeline parameter into its atoms. * - * @param workflow - The workflow to generate the config for. - * @returns The generated config for CircleCI in JS format. + * A single cadence workflow (`normal`, `merged`, `daily`, `docs`) behaves as before. Split atoms + * (`core`, a framework such as `react`, or a builder such as `vite`) can be combined with `+`, + * e.g. `core+react`, and the resulting config is the union of each atom's jobs. */ -function generateConfig(workflow: Workflow) { +function parseWorkflowAtoms(value: string): Workflow[] { + const atoms = value.split('+').filter(Boolean); + // `skipped` never reaches the generator: the setup workflow in .circleci/config.yml does not + // run for it, so it is not accepted here either. + const validAtoms = [ + ...STATIC_WORKFLOWS.filter((workflow) => workflow !== 'skipped'), + ...getSandboxSplitAtoms(), + ]; + const invalidAtoms = atoms.filter((atom) => !validAtoms.includes(atom)); + if (atoms.length === 0 || invalidAtoms.length > 0) { + throw new Error( + `Invalid --workflow value "${value}". Each "+"-separated atom must be one of: ${validAtoms.join(', ')}` + ); + } + return atoms; +} + +/** Collect the jobs for a single workflow atom. */ +function collectJobs(workflow: Workflow): JobOrNoOpJob[] { const jobs: JobOrNoOpJob[] = []; if (isWorkflowOrAbove(workflow, 'docs')) { jobs.push(fmt); + } else if (getSandboxSplitAtoms().includes(workflow)) { + jobs.push(sandboxesNoOpJob, ...getSplitSandboxes(workflow)); } else { const sandboxes = getSandboxes(workflow); const testStorybooks = getTestStorybooks(workflow); @@ -71,8 +97,9 @@ function generateConfig(workflow: Workflow) { internalStorybookBuildE2e, benchmarkPackages, - sandboxesNoOpJob, - ...sandboxes, + // `core` runs no sandboxes at all; leave the group out instead of + // emitting an orphaned no-op job. + ...(sandboxes.length > 0 ? [sandboxesNoOpJob, ...sandboxes] : []), testStorybooksNoOpJob, ...testStorybooks, @@ -81,6 +108,20 @@ function generateConfig(workflow: Workflow) { ...initEmpty ); } + return jobs; +} + +/** + * Generate the CircleCI config for a given workflow. + * + * @param workflowParam - The value of the `workflow` pipeline parameter (one or more atoms). + * @returns The generated config for CircleCI in JS format. + */ +function generateConfig(workflowParam: string) { + const atoms = parseWorkflowAtoms(workflowParam); + // Duplicate jobs across atoms (e.g. `react+vite` both containing react-vite + // sandboxes) are deduplicated by id in `ensureRequiredJobs`. + const jobs: JobOrNoOpJob[] = atoms.flatMap(collectJobs); /** * If you want to filter down to a particular job, e.g.for debugging purposes.. you can do that @@ -134,14 +175,14 @@ function generateConfig(workflow: Workflow) { (acc, job) => { acc[job.id] = typeof job.implementation === 'function' - ? job.implementation(workflow) + ? job.implementation(workflowParam) : job.implementation; return acc; }, {} as Record ), workflows: { - [`${workflow}-generated${isDebugging ? '-debug' : ''}`]: { + [`${atoms.join('-')}-generated${isDebugging ? '-debug' : ''}`]: { jobs: sortedJobs.map((t) => t.requires && t.requires.length > 0 ? { [t.id]: { requires: t.requires.map((r) => r.id) } } diff --git a/scripts/ci/sandboxes.ts b/scripts/ci/sandboxes.ts index 0a3daec085a7..62b2c53e0fce 100644 --- a/scripts/ci/sandboxes.ts +++ b/scripts/ci/sandboxes.ts @@ -518,6 +518,60 @@ const getListOfSandboxes = (workflow: Workflow) => { } }; +/** + * Maps a template's `expected.renderer` package to the framework atom used by the + * `ci:` labels (e.g. `ci:react`, `ci:vue3`, `ci:angular`). Both Angular packages map + * to the single `angular` atom on purpose: `ci:angular` should cover the webpack-based and the + * vite-based framework alike. + */ +const RENDERER_TO_FRAMEWORK_ATOM: Record = { + '@storybook/react': 'react', + '@storybook/vue3': 'vue3', + '@storybook/angular': 'angular', + '@storybook/angular-vite': 'angular', + '@storybook/svelte': 'svelte', + '@storybook/html': 'html', + '@storybook/preact': 'preact', + '@storybook/web-components': 'web-components', + '@storybook/server': 'server', + '@storybook/ember': 'ember', + 'storybook-solidjs-vite': 'solid', +}; + +/** Maps a template's `expected.builder` package to the atom used by the `ci:` labels. */ +const BUILDER_TO_BUILDER_ATOM: Record = { + '@storybook/builder-vite': 'vite', + '@storybook/builder-webpack5': 'webpack', + 'storybook-builder-rsbuild': 'rsbuild', +}; + +/** + * The sandbox pool the split workflows draw from. `daily` is the complete set of templates CI + * knows how to run, so `ci:react` and friends really target ALL sandboxes of a framework or + * builder โ€” including the ones only exercised by `merged`/`daily` โ€” not just the `normal` subset. + */ +const SPLIT_POOL = sandboxTemplates.daily; + +const getTemplateAtoms = (key: TemplateKey): string[] => { + const { expected } = sandboxTemplates.allTemplates[key]; + return [ + RENDERER_TO_FRAMEWORK_ATOM[expected.renderer], + BUILDER_TO_BUILDER_ATOM[expected.builder], + ].filter((atom): atom is string => !!atom); +}; + +/** All valid sandbox-split workflow atoms, derived from the sandbox template metadata. */ +export function getSandboxSplitAtoms(): string[] { + return [...new Set(SPLIT_POOL.flatMap(getTemplateAtoms))]; +} + +/** The sandbox jobs for a single split atom (a framework or a builder). */ +export function getSplitSandboxes(atom: string): JobOrNoOpJob[] { + return SPLIT_POOL.filter((key) => getTemplateAtoms(key).includes(atom)) + .map(defineSandboxFlow) + .flatMap((sandbox) => sandbox.jobs); +} + export function getSandboxes(workflow: Workflow) { const sandboxes = getListOfSandboxes(workflow).map(defineSandboxFlow); diff --git a/scripts/ci/utils/parameters.ts b/scripts/ci/utils/parameters.ts index 63fb13e5028a..13e4d48876ae 100644 --- a/scripts/ci/utils/parameters.ts +++ b/scripts/ci/utils/parameters.ts @@ -18,8 +18,8 @@ export const parameters = { }, workflow: { default: 'skipped', - description: 'Which workflow to run', - enum: ['normal', 'merged', 'daily', 'skipped', 'docs'] as const, - type: 'enum', + description: + 'Which workflow to run: a single workflow (normal, merged, daily, docs, core), or a `+`-separated list of sandbox-split atoms (a framework such as react/vue3/angular, or a builder such as vite/webpack), e.g. "core+react"', + type: 'string', }, }; diff --git a/scripts/ci/utils/types.ts b/scripts/ci/utils/types.ts index 842c353c549c..2462445eadf2 100644 --- a/scripts/ci/utils/types.ts +++ b/scripts/ci/utils/types.ts @@ -1,6 +1,5 @@ import type { executors } from './executors.ts'; import { toId } from './helpers.ts'; -import type { parameters } from './parameters.ts'; export type Job = { id: string; @@ -108,14 +107,31 @@ export function defineNoOpJob(name: K, requires = [] as JobOrN }; } -export type Workflow = (typeof parameters.workflow.enum)[number]; +/** + * The statically-known workflows. `normal`, `merged` and `daily` are the cadence workflows with + * increasing sandbox coverage. `docs` only checks formatting. `core` runs everything `normal` runs + * except the sandbox jobs. + * + * Besides these, a workflow can be a sandbox-split atom derived at runtime from the sandbox + * template metadata: a framework (e.g. `react`, `vue3`, `angular`) or a builder (e.g. `vite`, + * `webpack`). See `getSandboxSplitAtoms` in `../sandboxes.ts`. Multiple atoms are combined with + * `+` in the CircleCI `workflow` pipeline parameter (e.g. `core+react`) and validated in + * `../main.ts`. + */ +export const STATIC_WORKFLOWS = ['normal', 'merged', 'daily', 'skipped', 'docs', 'core'] as const; + +export type Workflow = (typeof STATIC_WORKFLOWS)[number] | (string & {}); /** * Checks if the current workflow is at least the minimum workflow. * * `normal` โ†’ `merged` โ†’ `daily` * - * `docs` is unique, in that it's not considered below of above anything. + * `core` is `normal` minus the sandbox jobs, so it counts as `normal`-level for everything that + * keys off `normal` (the sandbox list itself is resolved separately and is empty for `core`). + * + * `docs` is unique, in that it's not considered below of above anything. Sandbox-split atoms + * (frameworks/builders) are never "at or above" any cadence workflow. * * @example * @@ -132,6 +148,8 @@ export type Workflow = (typeof parameters.workflow.enum)[number]; */ export function isWorkflowOrAbove(current: Workflow, minimum: Workflow): boolean { switch (current) { + case 'core': + return minimum === 'normal' || minimum === 'core'; case 'normal': return minimum === 'normal'; case 'merged': diff --git a/scripts/dangerfile.js b/scripts/dangerfile.js index ec3ef6acf336..8bb5bc2458a6 100644 --- a/scripts/dangerfile.js +++ b/scripts/dangerfile.js @@ -35,7 +35,12 @@ const Versions = { MAJOR: 'MAJOR', }; -const ciLabels = ['ci:normal', 'ci:merged', 'ci:daily', 'ci:docs']; +const ciCadenceLabels = ['ci:normal', 'ci:merged', 'ci:daily', 'ci:docs']; +// Split CI labels compose: `ci:core` plus any number of framework labels (`ci:react`, `ci:vue3`, +// `ci:angular`, ...) and builder labels (`ci:vite`, `ci:webpack`, ...). A loose pattern is enough +// here: the label must exist on the repo to be applied, and the CircleCI config generator +// (scripts/ci/main.ts) rejects unknown atoms. Labels with spaces ("ci: do not merge") don't match. +const ciSplitLabelPattern = /^ci:[a-z0-9-]+$/; const qaLabels = ['qa:needed', 'qa:skip', 'qa:success']; const { labels } = danger.github.issue; @@ -88,11 +93,24 @@ const checkRequiredLabels = (labels) => { fail(`Please choose only one of these labels: ${JSON.stringify(foundRequiredLabels)}`); } - const foundCILabels = intersection(ciLabels, labels); - if (foundCILabels.length === 0) { - fail(`PR is not labeled with one of: ${JSON.stringify(ciLabels)}`); - } else if (foundCILabels.length > 1) { - fail(`Please choose only one of these labels: ${JSON.stringify(foundCILabels)}`); + const foundCadenceLabels = intersection(ciCadenceLabels, labels); + const foundSplitLabels = labels.filter( + (label) => ciSplitLabelPattern.test(label) && !ciCadenceLabels.includes(label) + ); + if (foundCadenceLabels.length === 0 && foundSplitLabels.length === 0) { + fail( + `PR is not labeled with one of: ${JSON.stringify( + ciCadenceLabels + )}, or with split CI labels ("ci:core", a framework label such as "ci:react", or a builder label such as "ci:vite")` + ); + } else if (foundCadenceLabels.length > 1) { + fail(`Please choose only one of these labels: ${JSON.stringify(foundCadenceLabels)}`); + } else if (foundCadenceLabels.length === 1 && foundSplitLabels.length > 0) { + fail( + `Please use either "${foundCadenceLabels[0]}" or the split CI labels ${JSON.stringify( + foundSplitLabels + )}, not both: the cadence label takes precedence and the split labels would be ignored.` + ); } const foundQALabels = intersection(qaLabels, labels);