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.
This commit is contained in:
10
apps/desktop/scripts/compiler-cache.d.mts
Normal file
10
apps/desktop/scripts/compiler-cache.d.mts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function withCompilerCache<P extends { transform: { handler: (...args: any[]) => unknown } }>(
|
||||
plugin: P,
|
||||
options: {
|
||||
command: string
|
||||
cacheRoot: string
|
||||
base: string
|
||||
toolchain: string[]
|
||||
env?: Record<string, string | undefined>
|
||||
},
|
||||
): P
|
||||
79
apps/desktop/scripts/compiler-cache.mjs
Normal file
79
apps/desktop/scripts/compiler-cache.mjs
Normal file
@@ -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<string, string | undefined> }} 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
|
||||
}
|
||||
94
apps/desktop/scripts/compiler-cache.test.mjs
Normal file
94
apps/desktop/scripts/compiler-cache.test.mjs
Normal file
@@ -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('<App/>', id)
|
||||
const warm = await t.cached().transform.handler('<App/>', id)
|
||||
assert.deepEqual(warm, cold)
|
||||
assert.deepEqual(warm.map.sourcesContent, ['<App/>']) // rebuilt from the key's own text
|
||||
assert.equal(t.calls.length, 1)
|
||||
await t.cached().transform.handler('<App changed/>', 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('<App/>', id)
|
||||
await t.cached({ env: { NODE_ENV: 'development' } }).transform.handler('<App/>', id)
|
||||
assert.equal(t.calls.length, 2)
|
||||
fs.writeFileSync(t.lock, '{"v":2}')
|
||||
await t.cached().transform.handler('<App/>', 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('<App/>', id)
|
||||
assert.equal(result.code, 'compiled(<App/>)')
|
||||
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/>', a)
|
||||
await first.transform.handler('<B/>', b)
|
||||
await t.cached({ env: { NODE_ENV: 'development' } }).transform.handler('<A/>', a) // a stale generation
|
||||
assert.equal(fs.readdirSync(path.join(t.root, 'cache')).length, 2)
|
||||
|
||||
const watch = t.cached()
|
||||
await watch.transform.handler('<A/>', 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/>', 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() }
|
||||
})
|
||||
@@ -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<string>()
|
||||
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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user