From d2cbfcf7dbaf9e8e5904ffd28e05a559fbf5df18 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:39:00 +0530 Subject: [PATCH] test(desktop): the React Compiler cache is its own module with focused tests The cache lived inline in vite.config.ts, and the renderer builder tests use a bare fixture config, so nothing exercised it: a broken warm path would still pass every test. Move it to scripts/compiler-cache.mjs (behaviour unchanged; toolchain files and cache root are now parameters, and an existing closeBundle hook is chained instead of replaced) and test hit vs fresh output, toolchain/NODE_ENV generations, corrupt entries, pruning, incremental (watch) rebuilds and the dev-server bypass. A cold and warm production build of the real app stay byte-identical. Reported by Greptile on #135092. --- apps/desktop/scripts/compiler-cache.d.mts | 10 ++ apps/desktop/scripts/compiler-cache.mjs | 79 +++++++++++++++ apps/desktop/scripts/compiler-cache.test.mjs | 94 +++++++++++++++++ apps/desktop/vite.config.ts | 101 ++----------------- 4 files changed, 194 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/scripts/compiler-cache.d.mts create mode 100644 apps/desktop/scripts/compiler-cache.mjs create mode 100644 apps/desktop/scripts/compiler-cache.test.mjs diff --git a/apps/desktop/scripts/compiler-cache.d.mts b/apps/desktop/scripts/compiler-cache.d.mts new file mode 100644 index 0000000000..c169b08602 --- /dev/null +++ b/apps/desktop/scripts/compiler-cache.d.mts @@ -0,0 +1,10 @@ +export function withCompilerCache

unknown } }>( + plugin: P, + options: { + command: string + cacheRoot: string + base: string + toolchain: string[] + env?: Record + }, +): P diff --git a/apps/desktop/scripts/compiler-cache.mjs b/apps/desktop/scripts/compiler-cache.mjs new file mode 100644 index 0000000000..6ec923e45a --- /dev/null +++ b/apps/desktop/scripts/compiler-cache.mjs @@ -0,0 +1,79 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * The React Compiler babel pass is ~85% of a production renderer build (≈13 s over ~770 + * files), and every source update rebuilt all of it for a one-file change. Its output is a + * pure function of the module text, its id and the toolchain, so memoize it on disk by that + * content key: an update that touched three components re-runs babel on three files. A + * missing or corrupt entry is just a miss. + * + * Only production builds use it: the compiler emits HMR cache-reset code when NODE_ENV is + * development, and a dev server would grow it one entry per save with no build to prune it. + * + * @param {any} plugin the babel plugin whose `transform.handler` is memoized + * @param {{ command: string, cacheRoot: string, base: string, toolchain: string[], env?: Record }} options + * `toolchain` lists the files that pin everything the pass runs (the lockfile, the config + * holding the preset); `base` makes cache keys independent of the checkout location. + */ +export function withCompilerCache(plugin, { command, cacheRoot, base, toolchain, env = process.env }) { + if (command !== 'build') return plugin + + // The toolchain files, this module, and NODE_ENV (which selects the compiler's dev mode) + // are together the whole identity of the pass. + const identity = crypto.createHash('sha256') + for (const file of [...toolchain, fileURLToPath(import.meta.url)]) identity.update(fs.readFileSync(file)).update('\0') + const generation = identity.update(env.NODE_ENV ?? '').digest('hex').slice(0, 16) + const dir = path.join(cacheRoot, generation) + const used = new Set() + const handler = plugin.transform.handler + + plugin.transform.handler = async function (code, id, opts) { + const key = crypto.createHash('sha256').update(`${path.relative(base, id)}\0${code}`).digest('hex') + const file = path.join(dir, `${key}.json`) + used.add(file) + try { + const hit = JSON.parse(fs.readFileSync(file, 'utf8')) + // The stored map drops sourcesContent: it is the module text the key already hashed. + if (hit.map) hit.map.sourcesContent = [code] + return hit + } catch { + // miss (or a torn entry): compile and store below + } + const result = await handler.call(this, code, id, opts) + if (result) { + try { + fs.mkdirSync(dir, { recursive: true }) + const temp = `${file}.${process.pid}.tmp` + const map = result.map ? { ...result.map, sourcesContent: undefined } : result.map + fs.writeFileSync(temp, JSON.stringify({ code: result.code, map })) + fs.renameSync(temp, file) + } catch { + // a read-only or full disk only costs the next build this file's compile + } + } + return result + } + + // Keep exactly what this build used: entries for since-edited files and other toolchains would + // otherwise accumulate one generation per update. A watch build never prunes (partial graph). + const closeBundle = plugin.closeBundle + plugin.closeBundle = async function (...args) { + if (!this?.meta?.watchMode) { + try { + for (const name of fs.readdirSync(cacheRoot)) { + if (name !== generation) fs.rmSync(path.join(cacheRoot, name), { recursive: true, force: true }) + } + for (const name of fs.readdirSync(dir)) { + if (!used.has(path.join(dir, name))) fs.rmSync(path.join(dir, name), { force: true }) + } + } catch { + // nothing cached yet, or a read-only tree + } + } + return closeBundle?.apply(this, args) + } + return plugin +} diff --git a/apps/desktop/scripts/compiler-cache.test.mjs b/apps/desktop/scripts/compiler-cache.test.mjs new file mode 100644 index 0000000000..a123829f88 --- /dev/null +++ b/apps/desktop/scripts/compiler-cache.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test } from 'vitest' + +import { withCompilerCache } from './compiler-cache.mjs' + +function setup() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'compiler-cache-')) + const lock = path.join(root, 'package-lock.json') + fs.writeFileSync(lock, '{"v":1}') + const calls = [] + const plugin = () => ({ + transform: { + handler: async (code, id) => { + calls.push(id) + return { code: `compiled(${code})`, map: { version: 3, sources: [id], sourcesContent: [code], mappings: 'AAAA' } } + } + } + }) + const cached = (options = {}) => + withCompilerCache(plugin(), { + command: 'build', + cacheRoot: path.join(root, 'cache'), + base: root, + toolchain: [lock], + env: { NODE_ENV: 'production' }, + ...options + }) + const entries = () => + fs.readdirSync(path.join(root, 'cache'), { recursive: true }).filter(name => String(name).endsWith('.json')) + return { root, lock, calls, cached, entries, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) } +} + +test('a warm build serves the stored output, identical to a fresh compile, without recompiling', async () => { + const t = setup() + try { + const id = path.join(t.root, 'src/App.tsx') + const cold = await t.cached().transform.handler('', id) + const warm = await t.cached().transform.handler('', id) + assert.deepEqual(warm, cold) + assert.deepEqual(warm.map.sourcesContent, ['']) // rebuilt from the key's own text + assert.equal(t.calls.length, 1) + await t.cached().transform.handler('', id) + assert.equal(t.calls.length, 2) // new module text is a new key + } finally { t.cleanup() } +}) + +test('a toolchain or NODE_ENV change starts a new generation; a corrupt entry is a miss', async () => { + const t = setup() + try { + const id = path.join(t.root, 'src/App.tsx') + await t.cached().transform.handler('', id) + await t.cached({ env: { NODE_ENV: 'development' } }).transform.handler('', id) + assert.equal(t.calls.length, 2) + fs.writeFileSync(t.lock, '{"v":2}') + await t.cached().transform.handler('', id) + assert.equal(t.calls.length, 3) + for (const name of t.entries()) fs.writeFileSync(path.join(t.root, 'cache', String(name)), '{torn') + const result = await t.cached().transform.handler('', id) + assert.equal(result.code, 'compiled()') + assert.equal(t.calls.length, 4) + } finally { t.cleanup() } +}) + +test('closing a build keeps only the entries and generation it used; watch builds and dev servers never prune or cache', async () => { + const t = setup() + try { + const a = path.join(t.root, 'src/A.tsx') + const b = path.join(t.root, 'src/B.tsx') + const first = t.cached() + await first.transform.handler('', a) + await first.transform.handler('', b) + await t.cached({ env: { NODE_ENV: 'development' } }).transform.handler('', a) // a stale generation + assert.equal(fs.readdirSync(path.join(t.root, 'cache')).length, 2) + + const watch = t.cached() + await watch.transform.handler('', a) + await watch.closeBundle.call({ meta: { watchMode: true } }) + assert.equal(t.entries().length, 3) // a partial watch graph prunes nothing + + const second = t.cached() + await second.transform.handler('', a) + await second.closeBundle.call({ meta: {} }) + assert.equal(fs.readdirSync(path.join(t.root, 'cache')).length, 1) + assert.equal(t.entries().length, 1) // B (unused this build) and the dev generation are gone + + const plain = { transform: { handler: async () => ({ code: 'x' }) } } + assert.equal(withCompilerCache(plain, { command: 'serve', cacheRoot: path.join(t.root, 'never'), base: t.root, toolchain: [t.lock] }), plain) + assert.equal(plain.closeBundle, undefined) + assert.equal(fs.existsSync(path.join(t.root, 'never')), false) + } finally { t.cleanup() } +}) diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 21ef8064fb..68cb9510dc 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -14,99 +14,18 @@ function compilerPreset() { return preset } -/** The React Compiler babel pass is ~85% of a production renderer build (≈13 s over ~770 - * files), and every source update rebuilt all of it for a one-file change. Its output is a - * pure function of the module text, its id and the compiler/babel versions, so memoize it on - * disk by that content key: an update that touched three components re-runs babel on three - * files. A missing or corrupt entry is just a miss. The cache lives under node_modules - * (ignored, outside every freshness hash). Only production builds use it: the compiler emits - * HMR cache-reset code when NODE_ENV is development, and a dev server would grow it one entry - * per save with no build to prune it. */ +/** The production renderer build memoizes this pass on disk (scripts/compiler-cache.mjs); the + * lockfile and this config (the preset and its filter) pin the toolchain it keys on. The + * cache lives under node_modules: ignored, and outside every freshness hash. */ async function cachedCompilerPass(command: string) { - const plugin = await babel({ presets: [compilerPreset()] }) - - if (command !== 'build') { - return plugin - } - - // The lockfile pins every babel/compiler/plugin version this pass runs, this config file holds - // the preset and its filter, and NODE_ENV selects the compiler's dev mode: together they are - // the whole toolchain identity. - const toolchain = crypto.createHash('sha256') - .update(fs.readFileSync(path.resolve(__dirname, '../../package-lock.json'))) - .update(fs.readFileSync(fileURLToPath(import.meta.url))) - .update(`\0${process.env.NODE_ENV ?? ''}`) - .digest('hex') - .slice(0, 16) - - const root = path.join(__dirname, 'node_modules/.cache/hermes-react-compiler') - const dir = path.join(root, toolchain) - const used = new Set() - const handler = plugin.transform.handler - - plugin.transform.handler = async function (code: string, id: string, opts: unknown) { - const key = crypto.createHash('sha256').update(`${path.relative(__dirname, id)}\0${code}`).digest('hex') - const file = path.join(dir, `${key}.json`) - used.add(file) - - try { - const hit = JSON.parse(fs.readFileSync(file, 'utf8')) - - // The stored map drops sourcesContent: it is the module text the key already hashed. - if (hit.map) { - hit.map.sourcesContent = [code] - } - - return hit - } catch { - // miss (or a torn entry): compile and store below - } - - const result = await handler.call(this, code, id, opts) - - if (result) { - try { - fs.mkdirSync(dir, { recursive: true }) - const temp = `${file}.${process.pid}.tmp` - const map = result.map ? { ...result.map, sourcesContent: undefined } : result.map - fs.writeFileSync(temp, JSON.stringify({ code: result.code, map })) - fs.renameSync(temp, file) - } catch { - // a read-only or full disk only costs the next build this file's compile - } - } - - return result - } - - // Keep exactly what this build used: entries for since-edited files and other toolchains would - // otherwise accumulate one generation per update. A watch build never prunes (partial graph). - plugin.closeBundle = function () { - if (this.meta?.watchMode) { - return - } - - try { - for (const name of fs.readdirSync(root)) { - if (name !== toolchain) { - fs.rmSync(path.join(root, name), { recursive: true, force: true }) - } - } - - for (const name of fs.readdirSync(dir)) { - if (!used.has(path.join(dir, name))) { - fs.rmSync(path.join(dir, name), { force: true }) - } - } - } catch { - // nothing cached yet, or a read-only tree - } - } - - return plugin + return withCompilerCache(await babel({ presets: [compilerPreset()] }), { + command, + cacheRoot: path.join(__dirname, 'node_modules/.cache/hermes-react-compiler'), + base: __dirname, + toolchain: [path.resolve(__dirname, '../../package-lock.json'), fileURLToPath(import.meta.url)] + }) } -import crypto from 'crypto' import fs from 'fs' import { createRequire } from 'module' import path from 'path' @@ -114,6 +33,8 @@ import { fileURLToPath } from 'url' import tailwindcss from '@tailwindcss/vite' +import { withCompilerCache } from './scripts/compiler-cache.mjs' + // The runner loads this as ESM without the default bundler's CJS globals. const __dirname: string = path.dirname(fileURLToPath(import.meta.url))