perf(dev): avoid pairing web rebuilds after test file changes (#29838)

* perf(dev): keep pairing web bundle after test file changes

* fix(dev): detect web input replacement within one timestamp tick

* test(dev): use real files for web replacement coverage

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-16 14:35:06 -07:00
committed by GitHub
co-authored by OrcaWin
parent ea451aa50a
commit f151bd965b
3 changed files with 205 additions and 22 deletions
+66 -19
View File
@@ -1,20 +1,31 @@
import { readdirSync, statSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const SNAPSHOT_FILENAME = '.orca-dev-web-inputs.json'
const INPUT_PATHS = ['vite.web.config.ts', 'src/renderer', 'src/shared', 'src/preload/api-types.ts']
const TEST_SOURCE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/
function latestSourceMtimeMs(targetPath) {
function collectInputs(targetPath, relativePath, files) {
let stat
try {
stat = statSync(targetPath)
stat = statSync(targetPath, { bigint: true })
} catch {
return 0
}
const mtimeMs = Number(stat.mtimeNs) / 1e6
if (!stat.isDirectory()) {
return stat.mtimeMs
files.push([
relativePath,
...[stat.mtimeNs, stat.ctimeNs, stat.dev, stat.ino, stat.size].map(String)
])
return mtimeMs
}
let latest = stat.mtimeMs
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
let latest = mtimeMs
for (const entry of readdirSync(targetPath, { withFileTypes: true }).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
)) {
if (
entry.name === 'node_modules' ||
entry.name.startsWith('.') ||
@@ -22,27 +33,63 @@ function latestSourceMtimeMs(targetPath) {
) {
continue
}
latest = Math.max(latest, latestSourceMtimeMs(join(targetPath, entry.name)))
latest = Math.max(
latest,
collectInputs(join(targetPath, entry.name), join(relativePath, entry.name), files)
)
}
return latest
}
export function isDevWebClientFresh(root) {
let outputMtime
export function captureDevWebClientInputs(root) {
const files = []
const latestMtime = Math.max(
...INPUT_PATHS.map((input) => collectInputs(join(root, input), input, files))
)
return {
signature: createHash('sha256').update(JSON.stringify(files)).digest('hex'),
latestMtime
}
}
function outputIdentity(root) {
const stat = statSync(join(root, 'out', 'web', 'web-index.html'))
return [stat.mtimeMs, stat.size]
}
export function recordDevWebClientInputs(root, inputs) {
try {
outputMtime = statSync(join(root, 'out', 'web', 'web-index.html')).mtimeMs
writeFileSync(
join(root, 'out', 'web', SNAPSHOT_FILENAME),
JSON.stringify({ version: 1, output: outputIdentity(root), signature: inputs.signature })
)
} catch {
// A missing cache must never prevent dev startup.
}
}
export function isDevWebClientFresh(root) {
let output
try {
output = outputIdentity(root)
} catch {
return false
}
if (outputMtime === 0) {
if (output[0] === 0) {
return false
}
// Directory timestamps still invalidate added/deleted sources conservatively.
const sourceMtime = Math.max(
latestSourceMtimeMs(join(root, 'vite.web.config.ts')),
latestSourceMtimeMs(join(root, 'src', 'renderer')),
latestSourceMtimeMs(join(root, 'src', 'shared')),
latestSourceMtimeMs(join(root, 'src', 'preload', 'api-types.ts'))
)
return sourceMtime <= outputMtime
const inputs = captureDevWebClientInputs(root)
try {
const previous = JSON.parse(readFileSync(join(root, 'out', 'web', SNAPSHOT_FILENAME), 'utf8'))
if (previous.version === 1 && JSON.stringify(previous.output) === JSON.stringify(output)) {
return previous.signature === inputs.signature
}
} catch {
// Existing standalone and projected builds can be adopted without rebuilding.
}
if (inputs.latestMtime > output[0]) {
return false
}
recordDevWebClientInputs(root, inputs)
return true
}
@@ -1,8 +1,20 @@
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
import {
mkdirSync,
mkdtempSync,
renameSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { isDevWebClientFresh } from './dev-web-client-freshness.mjs'
import {
captureDevWebClientInputs,
isDevWebClientFresh,
recordDevWebClientInputs
} from './dev-web-client-freshness.mjs'
const roots = []
const OLD = new Date(Date.now() - 120_000)
@@ -74,6 +86,7 @@ it.each([
'vite.web.config.ts'
])('rebuilds when %s changes', (file) => {
const root = fixture()
expect(isDevWebClientFresh(root)).toBe(true)
utimesSync(join(root, file), EDITED, EDITED)
expect(isDevWebClientFresh(root)).toBe(false)
})
@@ -89,3 +102,120 @@ it.each(['add', 'delete'])('rebuilds after a production source %s', (operation)
utimesSync(dirname(file), EDITED, EDITED)
expect(isDevWebClientFresh(root)).toBe(false)
})
it.each(['test.ts', 'spec.tsx', 'test.mjs', 'spec.cts'])(
'keeps a tracked bundle fresh after adding and deleting a %s file',
(suffix) => {
const root = fixture()
expect(isDevWebClientFresh(root)).toBe(true)
const file = join(root, `src/renderer/src/new.${suffix}`)
writeFileSync(file, '')
utimesSync(dirname(file), EDITED, EDITED)
expect(isDevWebClientFresh(root)).toBe(true)
rmSync(file)
expect(isDevWebClientFresh(root)).toBe(true)
}
)
it('ignores adding and removing a directory containing only tests', () => {
const root = fixture()
expect(isDevWebClientFresh(root)).toBe(true)
const directory = join(root, 'src/shared/new-tests')
mkdirSync(directory)
writeFileSync(join(directory, 'value.test.ts'), '')
expect(isDevWebClientFresh(root)).toBe(true)
rmSync(directory, { recursive: true })
expect(isDevWebClientFresh(root)).toBe(true)
})
it.each(['add', 'delete', 'rename'])(
'invalidates a tracked bundle after a production source %s with old timestamps',
(operation) => {
const root = fixture()
expect(isDevWebClientFresh(root)).toBe(true)
if (operation !== 'add') {
rmSync(join(root, 'src/renderer/src/app.tsx'))
}
if (operation !== 'delete') {
const file = join(root, 'src/renderer/src/added.tsx')
writeFileSync(file, '')
utimesSync(file, OLD, OLD)
}
utimesSync(join(root, 'src/renderer/src'), OLD, OLD)
expect(isDevWebClientFresh(root)).toBe(false)
}
)
it('invalidates a tracked bundle when a source is removed in a nested directory', () => {
const root = fixture()
const directory = join(root, 'src/shared/nested')
mkdirSync(directory)
writeFileSync(join(directory, 'value.ts'), '')
utimesSync(join(directory, 'value.ts'), OLD, OLD)
utimesSync(directory, OLD, OLD)
utimesSync(dirname(directory), OLD, OLD)
expect(isDevWebClientFresh(root)).toBe(true)
rmSync(directory, { recursive: true })
utimesSync(dirname(directory), OLD, OLD)
expect(isDevWebClientFresh(root)).toBe(false)
})
it.each(['missing', 'corrupt', 'version', 'output'])(
'falls back conservatively when the inventory is %s',
(state) => {
const root = fixture()
expect(isDevWebClientFresh(root)).toBe(true)
const marker = join(root, 'out/web/.orca-dev-web-inputs.json')
if (state === 'missing') {
rmSync(marker)
}
if (state === 'corrupt') {
writeFileSync(marker, '{')
}
if (state === 'version') {
writeFileSync(marker, JSON.stringify({ version: 0 }))
}
if (state === 'output') {
utimesSync(join(root, 'out/web/web-index.html'), OLD, OLD)
}
rmSync(join(root, 'src/renderer/src/app.tsx'))
utimesSync(join(root, 'src/renderer/src'), EDITED, EDITED)
expect(isDevWebClientFresh(root)).toBe(false)
}
)
it('adopts a fresh replacement output without reusing its previous inventory', () => {
const root = fixture()
expect(isDevWebClientFresh(root)).toBe(true)
writeFileSync(join(root, 'src/renderer/src/app.tsx'), 'updated')
utimesSync(join(root, 'src/renderer/src/app.tsx'), BUILT, BUILT)
utimesSync(join(root, 'out/web/web-index.html'), EDITED, EDITED)
expect(isDevWebClientFresh(root)).toBe(true)
})
it('retains the inputs from before a build so concurrent edits invalidate its output', () => {
const root = fixture()
const inputs = captureDevWebClientInputs(root)
writeFileSync(join(root, 'src/shared/value.ts'), 'changed while building')
utimesSync(join(root, 'out/web/web-index.html'), EDITED, EDITED)
recordDevWebClientInputs(root, inputs)
expect(isDevWebClientFresh(root)).toBe(false)
})
it('detects same-size atomic replacement with a restored mtime', () => {
const root = fixture()
const file = join(root, 'src/shared/value.ts')
const replacement = join(root, 'replacement.ts')
expect(isDevWebClientFresh(root)).toBe(true)
writeFileSync(replacement, '')
utimesSync(replacement, OLD, OLD)
const originalIdentity = statSync(file, { bigint: true })
const replacementIdentity = statSync(replacement, { bigint: true })
expect([replacementIdentity.dev, replacementIdentity.ino]).not.toEqual([
originalIdentity.dev,
originalIdentity.ino
])
renameSync(replacement, file)
utimesSync(dirname(file), OLD, OLD)
expect(isDevWebClientFresh(root)).toBe(false)
})
+7 -1
View File
@@ -24,7 +24,11 @@ import {
selectStaleDevBundleDirs
} from './dev-electron-bundle-cache.mjs'
import { startDevStorageReclaim } from './dev-storage-reclaim.mjs'
import { isDevWebClientFresh } from './dev-web-client-freshness.mjs'
import {
captureDevWebClientInputs,
isDevWebClientFresh,
recordDevWebClientInputs
} from './dev-web-client-freshness.mjs'
import { copyPrivateTree } from './space-sharing-copy.mjs'
import {
DEV_BUNDLE_ID,
@@ -510,6 +514,7 @@ function prepareDevWebClient() {
if (isDevWebClientFresh(repoRoot)) {
return
}
const inputs = captureDevWebClientInputs(repoRoot)
console.error('[orca-dev] Building web client for pairing...')
execFileSync(
process.execPath,
@@ -520,6 +525,7 @@ function prepareDevWebClient() {
env: process.env
}
)
recordDevWebClientInputs(repoRoot, inputs)
}
// Why: every `pn dev` should be attachable from agent-browser/playwright-cli