fix(desktop): fail loudly when a built renderer chunk is not valid ESM
- assert-dist-built now parses every emitted dist/assets/*.js chunk as an ES module (node --input-type=module --check via spawnSync) before the build reports success - a silent bundler output loss (observed twice: a 10-byte identifier token missing from the main chunk) previously shipped a syntactically invalid bundle that white-screens Electron with Uncaught SyntaxError - the loud failure feeds the existing update-path honest-retry and stage-and-swap, so a corrupted build retries instead of shipping - vitest coverage: invalid chunk fails with the chunk name in the error, valid chunks (incl. import.meta / template literals) still pass Fixes #105184
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404).
|
||||
|
||||
import { existsSync, readFileSync, statSync, readdirSync } from "fs"
|
||||
import { spawnSync } from "child_process"
|
||||
import { join, resolve } from "path"
|
||||
import { isMain } from "./utils.mjs"
|
||||
|
||||
@@ -77,6 +78,52 @@ export function checkDistBuilt(distDir) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse-validate every emitted chunk as an ES module. Corrupted-silent-fail
|
||||
// bundles (a dropped identifier token mid-file) produce invalid syntax that
|
||||
// only explodes at module-evaluation time in Electron's renderer.
|
||||
const chunkParse = verifyChunksParse(assetsDir)
|
||||
if (!chunkParse.ok) {
|
||||
return chunkParse
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Renderer chunks are emitted as ESM (`<script type="module">` in index.html).
|
||||
// A silent bundler failure can emit syntactically invalid chunks that parse fine
|
||||
// as CJS-ish text but throw on module evaluation in Electron — the app then
|
||||
// white-screens with `Uncaught SyntaxError` in the renderer console (observed
|
||||
// 2026-09: the update-produced bundle was missing a 10-byte identifier token,
|
||||
// `{$:n,}` vs `{categories:n,}`, leaving an invalid destructuring pattern).
|
||||
// Parse each emitted chunk as an ES module before packaging so a corrupted
|
||||
// build fails loudly and the update retry rebuilds instead of shipping it.
|
||||
function verifyChunksParse(assetsDir) {
|
||||
const nodeBin = process.env.NODE ||
|
||||
(process.execPath && process.execPath.endsWith("node") ? process.execPath : "node")
|
||||
const chunks = readdirSync(assetsDir).filter(name => name.endsWith(".js"))
|
||||
for (const name of chunks) {
|
||||
const file = join(assetsDir, name)
|
||||
const probe = spawnSync(nodeBin, ["--input-type=module", "--check"], {
|
||||
input: readFileSync(file),
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
timeout: 60_000,
|
||||
})
|
||||
if (probe.error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `could not run node to syntax-check ${name}: ${probe.error.message}`,
|
||||
}
|
||||
}
|
||||
if (probe.status !== 0) {
|
||||
const detail = String(probe.stderr || "").trim().split("\n").slice(0, 4).join(" / ")
|
||||
return {
|
||||
ok: false,
|
||||
error: `built chunk is not valid ES module syntax: ${name} — ${detail}. ` +
|
||||
`A renderer chunk failed to parse, so packaging would ship an app that ` +
|
||||
`white-screens with "Uncaught SyntaxError" on launch. Re-run the build.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
|
||||
@@ -162,3 +162,36 @@ test('checkDistBuilt fails when the QueryClient context invariant is in multiple
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt fails when a chunk is not valid ES module syntax', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
// Invalid: destructuring pattern with a hole, like a bundle that lost an
|
||||
// identifier token mid-minification (`{categories:n}` minus `categories`).
|
||||
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'let {,:n}=x', 'utf8')
|
||||
fs.writeFileSync(path.join(d, 'assets', 'vendor-def456.js'), 'export const ok = 1', 'utf8')
|
||||
})
|
||||
try {
|
||||
const result = checkDistBuilt(distDir)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /not valid ES module syntax/)
|
||||
assert.match(result.error, /index-abc123\.js/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt passes when every chunk parses as an ES module', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'export const a = `x`', 'utf8')
|
||||
fs.writeFileSync(path.join(d, 'assets', 'vendor-def456.js'), 'import.meta.url', 'utf8')
|
||||
})
|
||||
try {
|
||||
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user