Skip to content

Commit dbbdcd3

Browse files
yangshunclaudeBrooooooklyn
authored
perf(void): reuse unchanged source analysis during dev rescans (#543)
## Issue Saving a page currently re-reads and analyzes unchanged source files, then regenerates routing output even when only the page body changed. This adds unnecessary work as the app grows. ## Fix This change caches file-derived analysis, invalidates edited files through the watcher, and skips route-entry regeneration and route-module invalidation when routing inputs remain unchanged. Recent writes are conservatively re-analyzed to preserve freshness. Layout inventories are deterministic, and returned Markdown metadata cannot mutate the cached analysis. Startup and file changes serialize regeneration so the generated files and successful-input checkpoint stay consistent. Watcher failures are logged and retried on a later save. Auth provider/dialect settings participate in change detection, and missing generated routing files are repaired on the next source save. The implementation is shared across React, Vue, Svelte, and Solid Pages. **The benchmarks below measure React only.** ## Results Measured from file write to the updated heading appearing in Chrome, using Cloudflare dev with local workerd on an Apple M5: | Generated pages | Update | Before | After | Time saved | Change | |---:|---|---:|---:|---:|---:| | 1 | Component HMR | 97.0 ms | 96.4 ms | 0.6 ms | −0.7% | | 1 | Server-page reload | 132.9 ms | 142.9 ms | −10.0 ms | +7.5% | | 100 | Component HMR | 95.2 ms | 96.8 ms | −1.6 ms | +1.7% | | 100 | Server-page reload | 119.5 ms | 141.6 ms | −22.2 ms | +18.5% | | 1,000 | Component HMR | 350.1 ms | 98.6 ms | **251.5 ms** | **−71.8%** | | 1,000 | Server-page reload | 329.8 ms | 167.2 ms | **162.6 ms** | **−49.3%** | Each fixture includes two additional fixed pages. Results average session medians across ten paired sessions per size, with 360 timed saves overall and seeded, sequential variant ordering after warmup. Both 1,000-page improvements are statistically significant after Holm correction across six comparisons (`p = 0.0117`). The 95% savings intervals are **235–268 ms** for HMR and **123–197 ms** for reloads. Smaller fixtures show no statistically clear improvement or regression. Validation: - Regression coverage includes overlapping startup/saves; retry after write, auth-generation, and invalidation failures; changed auth settings; deleted outputs; stable layout ordering; and metadata isolation. Body-only saves return to the fast path after recovery. - Latest-base verification (`36a3697f`): core build, 465 routing/cache/navigation and email unit/integration tests, and all 33 workspace typecheck tasks passed. The targeted run skipped 13 tests. - With preceding base `cd480dde`: affected suite passed 10,936 tests; full suite passed 14,314 tests, with 31 skipped. - Formatting and lint passed. - Real Cloudflare dev with Chrome: loader and middleware body edits ran fresh code without restarting the isolate. A forced generated-file write failure was logged without terminating the server; the next save regenerated the POST route and its types. A body-only save also repaired deleted route types. No browser runtime errors. - Node production Pages coverage is required. Optional Bun/Deno availability probes retain the 10-second timeout. The performance figures above are from the original benchmark run; this follow-up was verified for correctness. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: LongYinan <github@lyn.one>
1 parent 3d8f848 commit dbbdcd3

8 files changed

Lines changed: 822 additions & 146 deletions

File tree

‎packages/void/src/pages/scan.ts‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, readFileSync } from 'node:fs';
1+
import { existsSync } from 'node:fs';
22
import { join, dirname, basename, extname } from 'pathslash';
33
import { glob } from 'tinyglobby';
44
import { parseSync } from 'vite';
@@ -16,6 +16,7 @@ import {
1616
type AstProgram,
1717
type AstVariableDeclaration,
1818
} from '../shared/ast.ts';
19+
import { deriveFromSource } from '../shared/source-analysis-cache.ts';
1920
import { staticExportInitializers, staticLiteral } from '../shared/static-exports.ts';
2021
import { scriptBlocks } from './island-imports.ts';
2122
import { resolveNamedLayout } from './layout-resolution.ts';
@@ -222,7 +223,8 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis
222223
const layouts: Array<LayoutDefinition> = [];
223224
const namedLayouts: Array<NamedLayoutDefinition> = [];
224225

225-
for (const file of files) {
226+
// Glob traversal order can vary between identical scans. Keep layout inventories stable too.
227+
for (const file of files.sort()) {
226228
const segments = file.split('/');
227229
// Skip files starting with _ in any segment (except _layouts/)
228230
if (segments.some((s) => s.startsWith('_') && s !== '_layouts')) {
@@ -298,8 +300,11 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis
298300
const candidate = `${serverBase}.server.${serverExt}`;
299301
if (existsSync(join(pagesDir, candidate))) {
300302
serverPath = candidate;
301-
const content = readFileSync(join(pagesDir, candidate), 'utf-8');
302-
const metadata = detectPageServerMetadata(content, candidate);
303+
304+
const metadata = deriveFromSource(join(pagesDir, candidate), 'page-server', (source) =>
305+
detectPageServerMetadata(source, candidate),
306+
);
307+
303308
methods.push(...metadata.methods);
304309
revalidate = metadata.revalidate;
305310
prerender = metadata.prerender;
@@ -331,13 +336,16 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis
331336
// Extract frontmatter metadata and layout from .md files, or layout from component files
332337
let metadata: { title?: string; description?: string } | undefined;
333338
let layout: string | false | undefined;
339+
334340
if (ext === '.md') {
335-
const mdContent = readFileSync(join(pagesDir, file), 'utf-8');
336-
metadata = extractFrontmatterMeta(mdContent);
337-
layout = extractFrontmatterLayout(mdContent);
341+
({ metadata, layout } = deriveFromSource(join(pagesDir, file), 'page-markdown', (source) => ({
342+
metadata: extractFrontmatterMeta(source),
343+
layout: extractFrontmatterLayout(source),
344+
})));
338345
} else {
339-
const componentContent = readFileSync(join(pagesDir, file), 'utf-8');
340-
layout = componentLayout(componentContent, file);
346+
layout = deriveFromSource(join(pagesDir, file), 'page-layout', (source) =>
347+
componentLayout(source, file),
348+
);
341349
}
342350

343351
pages.push({
@@ -354,7 +362,7 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis
354362
...rendering,
355363
hasGetPrerenderPaths,
356364
island,
357-
metadata,
365+
metadata: metadata ? { ...metadata } : undefined,
358366
layout,
359367
});
360368
}

‎packages/void/src/plugin-inference.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type RouteEnvironment,
1212
} from './router/parse-filename.ts';
1313
import { isWebSocketPatternFile } from './router/websocket.ts';
14+
import { deriveFromSource } from './shared/source-analysis-cache.ts';
1415
import { getSsrServerEntryPath } from './ssr/entry.ts';
1516

1617
const SOURCE_GLOB = '**/*.{ts,tsx,mts,js,jsx,mjs,astro}';
@@ -168,8 +169,10 @@ export function inferProjectBindings(
168169
return;
169170
}
170171
}
171-
const code = readFileSync(file, 'utf-8');
172-
allBindings.push(inferBindingsFromFile(file, code));
172+
173+
allBindings.push(
174+
deriveFromSource(file, 'bindings', (code) => inferBindingsFromFile(file, code)),
175+
);
173176
};
174177

175178
for (const dir of dirs) {

0 commit comments

Comments
 (0)