From d9a763e77dc5dbe6bb4de2e0bf8afd22f147b07c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:03:09 +0200 Subject: [PATCH] fix(build): prevent Vite dev transform overflow (#1379) * fix(build): prevent Vite dev transform overflow * fix(build): preserve commented Vite URL imports --- .changes/build-vite-dev-filter.md | 8 ++ .github/workflows/ci.yml | 3 + AGENTS.md | 5 + CLAUDE.md | 5 + docs/architecture/nx-workspace-boundaries.md | 27 +++++ package.json | 4 +- patches/vite@7.3.5.patch | 55 ++++++++++ pnpm-lock.yaml | 13 ++- .../vite-transform-filter.test.mjs | 101 ++++++++++++++++++ 9 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 .changes/build-vite-dev-filter.md create mode 100644 patches/vite@7.3.5.patch create mode 100644 tools/dependencies/vite-transform-filter.test.mjs diff --git a/.changes/build-vite-dev-filter.md b/.changes/build-vite-dev-filter.md new file mode 100644 index 000000000..e1cf2fd2d --- /dev/null +++ b/.changes/build-vite-dev-filter.md @@ -0,0 +1,8 @@ +--- +type: internal +area: build +--- + +Electron development serve no longer crashes while Vite transforms a large +lazy chunk. The pinned Vite release now carries a backtracking-safe transform +filter fix, with a regression check guarding dependency updates. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afab7f93c..43dc45198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,9 @@ jobs: - name: Validate Nx dependency version policy run: pnpm run deps:nx:validate + - name: Validate Vite dev-server transform filter patch + run: pnpm run deps:vite:test + - name: Validate stylesheet Nx inputs run: pnpm run styles:inputs:validate diff --git a/AGENTS.md b/AGENTS.md index 9f3ea4a65..1cd9ef295 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,11 @@ This file provides guidance to coding agents working in this repository. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. - Keep `nx` and every official `@nx/*` package on the same exact version; run `pnpm run deps:nx:validate` after dependency updates. +- Vite `7.3.5`, resolved through Angular's build tooling, is patched with + bounded transform prefilters and the upstream precise matchers in + `patches/vite@7.3.5.patch`. Keep the patch until supported Angular tooling + resolves a Vite version containing the fix, and run `pnpm run deps:vite:test` + after related dependency updates. - A directory holding files consumed by other projects must be an Nx project. Nx builds its graph from TypeScript imports only, so a relative SCSS `@use` across project roots creates no edge and the imported file lands in no task diff --git a/CLAUDE.md b/CLAUDE.md index 3f5db2213..f63d9246c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,11 @@ pnpm nx show projects - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. - Keep `nx` and every official `@nx/*` package on the same exact version; run `pnpm run deps:nx:validate` after dependency updates. +- Vite `7.3.5`, resolved through Angular's build tooling, is patched with + bounded transform prefilters and the upstream precise matchers in + `patches/vite@7.3.5.patch`. Keep the patch until supported Angular tooling + resolves a Vite version containing the fix, and run `pnpm run deps:vite:test` + after related dependency updates. - A directory holding files consumed by other projects must be an Nx project. Nx builds its graph from TypeScript imports only, so a relative SCSS `@use` across project roots creates no edge and the imported file lands in no task diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 8304d69e3..60286913f 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -50,6 +50,33 @@ Omit `pnpm nx migrate --run-migrations` when the first command reports that no migrations exist. Major Nx updates always use this manual workflow and the resulting PR runs the full CI pipeline. +## Vite Dev-Server Patch + +Angular's development builder currently resolves Vite `7.3.5`. That release's +asset and worker transform prefilters can catastrophically backtrack on a large +generated chunk containing unrelated `new URL...` expressions while searching +for a valid `new URL(..., import.meta.url)` construct. The Electron development +server can then fail a lazy chunk request with `Maximum call stack size +exceeded`, even though static builds succeed because they do not pass emitted +chunks through Vite's request-time plugin container. + +`patches/vite@7.3.5.patch` backports Vite's upstream precise-matcher fix from +[vitejs/vite#21800](https://github.com/vitejs/vite/pull/21800). Bounded +prefilters keep the request-time scan linear while allowing comment-bearing +asset and worker expressions to reach the precise matcher after Vite strips +comments. Keep the patch while the supported Angular toolchain resolves Vite +`7.3.5`; remove it only after the resolved Vite contains the upstream fix. Run +the regression check after any related manifest or lockfile update: + +```bash +pnpm run deps:vite:test +``` + +CI runs the same check. It resolves Vite from `@angular/build`, verifies the +patched prefilter/matcher wiring and version pin, stress-tests the false-positive +chunk shape, and preserves ordinary and comment-bearing asset and worker +`new URL(..., import.meta.url)` matches. + ## Placement Decision - `apps/` owns runtime applications, development servers, E2E applications, diff --git a/package.json b/package.json index e220ea4c3..d8693a939 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "deps:nx:test": "node --test tools/dependencies/check-nx-version-sync.test.mjs", "deps:nx:check": "node tools/dependencies/check-nx-version-sync.mjs", "deps:nx:validate": "pnpm run deps:nx:test && pnpm run deps:nx:check", + "deps:vite:test": "node --test tools/dependencies/vite-transform-filter.test.mjs", "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", @@ -250,7 +251,8 @@ "yaml@1.10.2": "1.10.3" }, "patchedDependencies": { - "nx-electron@22.0.0": "patches/nx-electron@22.0.0.patch" + "nx-electron@22.0.0": "patches/nx-electron@22.0.0.patch", + "vite@7.3.5": "patches/vite@7.3.5.patch" } } } diff --git a/patches/vite@7.3.5.patch b/patches/vite@7.3.5.patch new file mode 100644 index 000000000..060f7642d --- /dev/null +++ b/patches/vite@7.3.5.patch @@ -0,0 +1,55 @@ +diff --git a/dist/node/chunks/config.js b/dist/node/chunks/config.js +index 9a45043275249a25101fd7991ffcfc316478840e..5435b17de9ca65b64a8abacb609b75587226b106 100644 +--- a/dist/node/chunks/config.js ++++ b/dist/node/chunks/config.js +@@ -27667,7 +27667,8 @@ async function getWorkerType(raw, clean, i$1) { + if (workerOpts.type && (workerOpts.type === "module" || workerOpts.type === "classic")) return workerOpts.type; + return "classic"; + } +-const workerImportMetaUrlRE = /new\s+(?:Worker|SharedWorker)\s*\(\s*new\s+URL.+?import\.meta\.url/s; ++const workerImportMetaUrlFilterRE = /\bnew\s+(?:Worker|SharedWorker)\b/; ++const workerImportMetaUrlRE = /\bnew\s+(?:Worker|SharedWorker)\s*\(\s*(new\s+URL\s*\(\s*('[^']+'|"[^"]+"|`[^`]+`)\s*,\s*import\.meta\.url\s*(?:,\s*)?\))/dg; + function workerImportMetaUrlPlugin(config$2) { + const isBuild = config$2.command === "build"; + let workerResolver; +@@ -27688,10 +27689,10 @@ function workerImportMetaUrlPlugin(config$2) { +- filter: { code: workerImportMetaUrlRE }, ++ filter: { code: workerImportMetaUrlFilterRE }, + async handler(code, id) { + let s; ++ const re = new RegExp(workerImportMetaUrlRE); + const cleanString = stripLiteral(code); +- const workerImportMetaUrlRE$1 = /\bnew\s+(?:Worker|SharedWorker)\s*\(\s*(new\s+URL\s*\(\s*('[^']+'|"[^"]+"|`[^`]+`)\s*,\s*import\.meta\.url\s*\))/dg; + let match; +- while (match = workerImportMetaUrlRE$1.exec(cleanString)) { ++ while (match = re.exec(cleanString)) { + const [[, endIndex], [expStart, expEnd], [urlStart, urlEnd]] = match.indices; + const rawUrl = code.slice(urlStart, urlEnd); + if (rawUrl[0] === "`" && rawUrl.includes("${")) this.error(`\`new URL(url, import.meta.url)\` is not supported in dynamic template string.`, expStart); +@@ -27733,6 +27734,8 @@ function workerImportMetaUrlPlugin(config$2) { + } + + //#endregion ++const assetImportMetaUrlFilterRE = /\bnew\s+URL\b/; ++const assetImportMetaUrlRE = /\bnew\s+URL\s*\(\s*('[^']+'|"[^"]+"|`[^`]+`)\s*,\s*import\.meta\.url\s*(?:,\s*)?\)/dg; + //#region src/node/plugins/assetImportMetaUrl.ts + /** + * Convert `new URL('./foo.png', import.meta.url)` to its resolved built URL +@@ -27763,14 +27765,14 @@ function assetImportMetaUrlPlugin(config$2) { + transform: { + filter: { + id: { exclude: [exactRegex(preloadHelperId), exactRegex(CLIENT_ENTRY)] }, +- code: /new\s+URL.+import\.meta\.url/s ++ code: assetImportMetaUrlFilterRE + }, + async handler(code, id) { + let s; +- const assetImportMetaUrlRE = /\bnew\s+URL\s*\(\s*('[^']+'|"[^"]+"|`[^`]+`)\s*,\s*import\.meta\.url\s*(?:,\s*)?\)/dg; ++ const re = new RegExp(assetImportMetaUrlRE); + const cleanString = stripLiteral(code); + let match; +- while (match = assetImportMetaUrlRE.exec(cleanString)) { ++ while (match = re.exec(cleanString)) { + const [[startIndex, endIndex], [urlStart, urlEnd]] = match.indices; + if (hasViteIgnoreRE.test(code.slice(startIndex, urlStart))) continue; + const rawUrl = code.slice(urlStart, urlEnd); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca2c0ac2e..ba952a4d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ patchedDependencies: nx-electron@22.0.0: hash: f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002 path: patches/nx-electron@22.0.0.patch + vite@7.3.5: + hash: b3a8f88ab420b6504a61ffb0f8e402f112c7fda7f3f3e802d21272da7969882d + path: patches/vite@7.3.5.patch importers: @@ -11902,7 +11905,7 @@ snapshots: '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 5.1.21(@types/node@20.19.9) - '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(patch_hash=b3a8f88ab420b6504a61ffb0f8e402f112c7fda7f3f3e802d21272da7969882d)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0)) beasties: 0.4.1 browserslist: 4.28.2 esbuild: 0.28.1 @@ -11923,7 +11926,7 @@ snapshots: tslib: 2.8.1 typescript: 5.9.3 undici: 7.24.4 - vite: 7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 7.3.5(patch_hash=b3a8f88ab420b6504a61ffb0f8e402f112c7fda7f3f3e802d21272da7969882d)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0) watchpack: 2.5.1 optionalDependencies: '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) @@ -16577,9 +16580,9 @@ snapshots: global: 4.4.0 is-function: 1.0.2 - '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0))': + '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.5(patch_hash=b3a8f88ab420b6504a61ffb0f8e402f112c7fda7f3f3e802d21272da7969882d)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0))': dependencies: - vite: 7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 7.3.5(patch_hash=b3a8f88ab420b6504a61ffb0f8e402f112c7fda7f3f3e802d21272da7969882d)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0) '@webassemblyjs/ast@1.14.1': dependencies: @@ -24377,7 +24380,7 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 - vite@7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0): + vite@7.3.5(patch_hash=b3a8f88ab420b6504a61ffb0f8e402f112c7fda7f3f3e802d21272da7969882d)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) diff --git a/tools/dependencies/vite-transform-filter.test.mjs b/tools/dependencies/vite-transform-filter.test.mjs new file mode 100644 index 000000000..2737b7893 --- /dev/null +++ b/tools/dependencies/vite-transform-filter.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import test from 'node:test'; +import { runInNewContext } from 'node:vm'; + +const require = createRequire(import.meta.url); +const angularBuildRequire = createRequire( + require.resolve('@angular/build/package.json') +); +const vitePackagePath = angularBuildRequire.resolve('vite/package.json'); +const vitePackage = JSON.parse(await readFile(vitePackagePath, 'utf8')); +const viteConfigPath = join( + dirname(vitePackagePath), + 'dist/node/chunks/config.js' +); +const viteConfig = await readFile(viteConfigPath, 'utf8'); + +function extractRegExp(name) { + const declaration = new RegExp( + `const ${name} = (\\/[^\\n]+\\/[a-z]*);` + ).exec(viteConfig); + assert.ok(declaration, `Unable to find ${name} in ${viteConfigPath}`); + return runInNewContext(declaration[1]); +} + +test('pins the Vite version carrying the local transform-filter backport', () => { + assert.equal(vitePackage.version, '7.3.5'); +}); + +test('uses bounded Vite prefilters with precise handler matchers', () => { + assert.ok( + /filter: \{\s*id: \{[^}]+\},\s*code: assetImportMetaUrlFilterRE\s*\}/s.test( + viteConfig + ), + 'asset import-meta transform must use the bounded prefilter' + ); + assert.ok( + /filter: \{ code: workerImportMetaUrlFilterRE \}/.test(viteConfig), + 'worker import-meta transform must use the bounded prefilter' + ); + assert.ok( + /const re = new RegExp\(assetImportMetaUrlRE\)/.test(viteConfig), + 'asset handler must clone the precise matcher' + ); + assert.ok( + /const re = new RegExp\(workerImportMetaUrlRE\)/.test(viteConfig), + 'worker handler must clone the precise matcher' + ); + assert.ok( + !/code: \/new\\s\+URL\.\+import\\\.meta\\\.url\/s/.test(viteConfig), + 'the backtracking asset transform prefilter must be absent' + ); +}); + +test('rejects a large false-positive chunk without regex backtracking', () => { + const assetFilter = extractRegExp('assetImportMetaUrlFilterRE'); + const workerFilter = extractRegExp('workerImportMetaUrlFilterRE'); + const largeCode = + `new URLSearchParams();\n`.repeat(200) + `var a = 1;\n`.repeat(200_000); + const start = performance.now(); + + assert.equal(assetFilter.test(largeCode), false); + assert.equal(workerFilter.test(largeCode), false); + assert.ok( + performance.now() - start < 250, + 'Vite transform filters must reject the stress input without backtracking' + ); +}); + +test('keeps valid asset and worker import-meta URL patterns eligible', () => { + const assetFilter = extractRegExp('assetImportMetaUrlFilterRE'); + const workerFilter = extractRegExp('workerImportMetaUrlFilterRE'); + const assetMatcher = extractRegExp('assetImportMetaUrlRE'); + const workerMatcher = extractRegExp('workerImportMetaUrlRE'); + const assetExpression = `new URL('./asset.png', import.meta.url)`; + const workerExpression = `new Worker(new URL('./worker.js', import.meta.url))`; + + assert.equal(assetFilter.test(assetExpression), true); + assert.equal(workerFilter.test(workerExpression), true); + assert.equal(assetMatcher.test(assetExpression), true); + assert.equal(workerMatcher.test(workerExpression), true); +}); + +test('keeps comment-bearing import-meta URL patterns eligible', () => { + const assetFilter = extractRegExp('assetImportMetaUrlFilterRE'); + const workerFilter = extractRegExp('workerImportMetaUrlFilterRE'); + + assert.equal( + assetFilter.test(`new URL(/* keep */ './asset.png', import.meta.url)`), + true + ); + assert.equal( + workerFilter.test( + `new Worker(/* keep */ new URL('./worker.js', import.meta.url))` + ), + true + ); +});