Skip to content

Commit 996c548

Browse files
os-zhuangclaude
andauthored
perf(lint): load Sucrase lazily in validateReactPages (#2547)
@objectstack/lint sits on the kernel boot path, but the react syntax gate (ADR-0081) only runs when a kind:'react' page is actually validated. The top-level `import { transform } from 'sucrase'` made every boot parse ~1.5 MB of transpiler (~16 ms cold require) — the same boot-path problem as the TypeScript compiler in validateReactPageProps, fixed in #2544. Sucrase now loads on the first validated react-source page via the same deferred createRequire (anchor chain import.meta.url → __filename → cwd); the public API stays synchronous and unchanged, `sucrase` stays a regular dependency, and a missing package at call time fails validation with an actionable error instead of killing boot. The guard test is generalized from lazy-typescript.test.ts to lazy-deps.test.ts, covering both deps at all three levels (structural no-eager-import scan over src, child-process probes of both dist formats, in-process lazy-load behavior) — verified to go red for each dep separately when its eager import is reintroduced. An in-worker require.cache probe alone cannot catch it: vitest inlines static imports through its transform. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent dcdb3df commit 996c548

5 files changed

Lines changed: 180 additions & 100 deletions

File tree

.changeset/lint-lazy-sucrase.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
Load Sucrase lazily in `validateReactPages` instead of at module top level — the same kernel boot-path contract applied to the TypeScript compiler in `validateReactPageProps` (framework#2544).
6+
7+
`@objectstack/lint` sits on the kernel boot path, so the eager `import { transform } from 'sucrase'` made every boot parse ~1.5 MB of transpiler (~16 ms cold require) for a syntax gate that only runs when a `kind:'react'` page is actually validated — a rare, trusted-tier case. Sucrase now loads on the first validated react-source page via the same deferred-createRequire pattern; the public API stays synchronous and unchanged, `sucrase` stays a regular dependency, and if the package is missing at call time validation fails with an actionable error instead of killing boot.
8+
9+
The boot-path guard test is generalized from `lazy-typescript.test.ts` to `lazy-deps.test.ts` and now covers both deps at all three levels (structural no-eager-import scan over src, child-process probes of both built dist formats, in-process lazy-load behavior) — verified to go red for each dep when its eager import is reintroduced.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Boot-path contract: importing @objectstack/lint must NOT load its heavy,
4+
// gate-only dependencies. The package sits on the kernel boot path, while
5+
// each dep below serves a gate that only runs when a `kind:'react'` page is
6+
// actually validated — so each must load lazily, on first use:
7+
// - `typescript` (~9 MB, and has been pruned from production images before
8+
// — see validate-react-page-props.ts), loaded by the react-props gate;
9+
// - `sucrase` (~1.5 MB), loaded by the react syntax gate
10+
// (validate-react-pages.ts).
11+
//
12+
// Guarded at three levels because vitest inlines static imports through its
13+
// transform (they never hit the native require cache), so an in-worker
14+
// require.cache probe alone CANNOT catch a reintroduced eager import:
15+
// 1. structural — no src file may eagerly `import ... from` a lazy dep
16+
// (only `import type`, which erases at build);
17+
// 2. built dist — child `node` processes import both dist formats and prove
18+
// each dep is absent until a react page is validated (skipped when dist
19+
// has not been built);
20+
// 3. behavioral — each lazy path really loads its dep on demand and the
21+
// gate still produces findings.
22+
import { execFileSync } from 'node:child_process';
23+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
24+
import { createRequire } from 'node:module';
25+
import { dirname, join } from 'node:path';
26+
import { fileURLToPath, pathToFileURL } from 'node:url';
27+
import { describe, it, expect } from 'vitest';
28+
29+
const srcDir = dirname(fileURLToPath(import.meta.url));
30+
const distDir = join(srcDir, '..', 'dist');
31+
32+
// Deps that must never load at import time. Extend this list when another
33+
// heavy, rarely-hit dependency joins the package.
34+
const LAZY_DEPS = ['typescript', 'sucrase'];
35+
36+
const depLoaded = (cache: Record<string, unknown> | undefined, dep: string) =>
37+
Object.keys(cache ?? {}).some((p) => p.split(/[/\\]/).join('/').includes(`/node_modules/${dep}/`));
38+
39+
describe('lazy dependency loading (kernel boot-path contract)', () => {
40+
it('no src file eagerly imports a lazy dep (import type only)', () => {
41+
// Static `import`/`export ... from '<dep>'` executes at module init in
42+
// both dist formats; `import type` erases. Dynamic import()/createRequire
43+
// inside functions are the sanctioned lazy forms and are not matched.
44+
const eagerImport = (dep: string) =>
45+
new RegExp(
46+
String.raw`^\s*(?:import\s+['"]${dep}['"]|(?:import|export)\s+(?!type[\s{])[^;]*?from\s*['"]${dep}['"]|import\s+\w+\s*=\s*require\(\s*['"]${dep}['"]\s*\))`,
47+
'm',
48+
);
49+
const offenders = readdirSync(srcDir)
50+
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
51+
.flatMap((f) => {
52+
const body = readFileSync(join(srcDir, f), 'utf8');
53+
return LAZY_DEPS.filter((dep) => eagerImport(dep).test(body)).map((dep) => `${f} eagerly imports ${dep}`);
54+
});
55+
expect(offenders).toEqual([]);
56+
});
57+
58+
// Reused by both dist probes: import the dist entry, prove no lazy dep came
59+
// with it, then run each react-page gate and prove exactly its own dep loads
60+
// — and that the gate still produces its finding.
61+
const reactStack = (source: string) => `{ pages: [{ name: 'r', kind: 'react', source: ${JSON.stringify(source)} }] }`;
62+
const childBody = `
63+
const probe = require('node:module').createRequire(process.cwd() + '/probe.js');
64+
const loaded = (dep) => Object.keys(probe.cache ?? {}).some((p) => p.split(/[/\\\\]/).join('/').includes('/node_modules/' + dep + '/'));
65+
const fail = (msg) => { console.error(msg); process.exit(1); };
66+
const check = (mod) => {
67+
for (const dep of ${JSON.stringify(LAZY_DEPS)}) {
68+
if (loaded(dep)) fail(dep + ' was loaded eagerly, at import time');
69+
}
70+
const syntax = mod.validateReactPages(${reactStack('function Page(){ return <div>oops; }')});
71+
if (!loaded('sucrase')) fail('sucrase was not loaded by a react-page syntax validation');
72+
if (loaded('typescript')) fail('the syntax gate must not load typescript');
73+
if (!syntax.some((f) => f.rule === 'react-page-syntax')) fail('syntax gate produced no finding');
74+
const props = mod.validateReactPageProps(${reactStack('function Page(){ return <ObjectForm mode="edit" />; }')});
75+
if (!loaded('typescript')) fail('typescript was not loaded by a react-page props validation');
76+
if (!props.some((f) => f.rule === 'react-prop-missing-required')) fail('props gate produced no finding');
77+
console.log('OK');
78+
};
79+
`;
80+
81+
it.skipIf(!existsSync(join(distDir, 'index.cjs')))('built CJS dist does not load a lazy dep until a react page is validated', () => {
82+
const out = execFileSync(
83+
process.execPath,
84+
['-e', `${childBody}; check(require(${JSON.stringify(join(distDir, 'index.cjs'))}));`],
85+
{ encoding: 'utf8' },
86+
);
87+
expect(out).toContain('OK');
88+
});
89+
90+
it.skipIf(!existsSync(join(distDir, 'index.js')))('built ESM dist does not load a lazy dep until a react page is validated', () => {
91+
const out = execFileSync(
92+
process.execPath,
93+
[
94+
'--input-type=module',
95+
'-e',
96+
`import { createRequire } from 'node:module';
97+
const require = createRequire(process.cwd() + '/probe.js');
98+
${childBody};
99+
check(await import(${JSON.stringify(pathToFileURL(join(distDir, 'index.js')).href)}));`,
100+
],
101+
{ encoding: 'utf8' },
102+
);
103+
expect(out).toContain('OK');
104+
});
105+
106+
it('loads each dep lazily in-process and the gates still work', async () => {
107+
const req = createRequire(import.meta.url);
108+
const { validateReactPages, validateReactPageProps } = await import('./index.js');
109+
110+
// Stacks without a react-source page never touch either dep.
111+
expect(validateReactPages({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]);
112+
expect(validateReactPageProps({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]);
113+
expect(validateReactPageProps({ pages: [{ name: 'r', kind: 'react', source: ' ' }] })).toEqual([]);
114+
for (const dep of LAZY_DEPS) {
115+
expect(depLoaded(req.cache, dep), `${dep} loaded before any react-source validation`).toBe(false);
116+
}
117+
118+
// The first react page with source pays the cost of exactly its own gate's
119+
// dep — and the gates still work.
120+
const syntax = validateReactPages({
121+
pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return <div>oops; }' }],
122+
});
123+
expect(depLoaded(req.cache, 'sucrase')).toBe(true);
124+
expect(depLoaded(req.cache, 'typescript'), 'the syntax gate must not load typescript').toBe(false);
125+
expect(syntax.some((f) => f.rule === 'react-page-syntax' && f.severity === 'error')).toBe(true);
126+
127+
const props = validateReactPageProps({
128+
pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return <ObjectForm mode="edit" />; }' }],
129+
});
130+
expect(depLoaded(req.cache, 'typescript')).toBe(true);
131+
expect(props.some((f) => f.rule === 'react-prop-missing-required' && /objectName/.test(f.message))).toBe(true);
132+
});
133+
});

packages/lint/src/lazy-typescript.test.ts

Lines changed: 0 additions & 98 deletions
This file was deleted.

packages/lint/src/validate-react-page-props.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { REACT_BLOCKS } from '@objectstack/spec/ui';
2626
// eager import also hard-crashes boot in deployments that prune the package
2727
// from the image (cloud's Docker pruner did exactly that). So the compiler is
2828
// loaded lazily, on first use, and stays a regular dependency in package.json.
29-
// Guarded by lazy-typescript.test.ts.
29+
// Guarded by lazy-deps.test.ts.
3030
//
3131
// `node:module` is a Node builtin, untouched by esbuild/tsup, so the static
3232
// `createRequire` import survives bundling; the `createRequire(...)` call is

packages/lint/src/validate-react-pages.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,40 @@
1010
// validate runtime behaviour (a transpiling page can still throw at render);
1111
// the render-time error boundary owns that.
1212

13-
import { transform } from 'sucrase';
13+
import { createRequire } from 'node:module';
14+
import type { transform as sucraseTransform } from 'sucrase';
15+
16+
// Sucrase must NOT be imported at module top level: it is ~1.5 MB of CJS
17+
// (~16 ms cold require), and @objectstack/lint sits on the kernel boot path —
18+
// while this gate only runs when a `kind:'react'` page is actually validated
19+
// (rare, trusted tier). Same boot-path contract as the TypeScript compiler in
20+
// validate-react-page-props.ts: loaded lazily, on first use, staying a regular
21+
// dependency in package.json. Guarded by lazy-deps.test.ts.
22+
//
23+
// `node:module` is a Node builtin, untouched by esbuild/tsup, so the static
24+
// `createRequire` import survives bundling; the `createRequire(...)` call is
25+
// deferred because `import.meta.url` is rewritten to an empty stub in the CJS
26+
// build (same pattern as driver-sqlite-wasm's knex-wasm-dialect).
27+
let cachedTransform: typeof sucraseTransform | null = null;
28+
function loadSucraseTransform(): typeof sucraseTransform {
29+
if (cachedTransform) return cachedTransform;
30+
const anchor =
31+
typeof import.meta !== 'undefined' && import.meta.url
32+
? import.meta.url
33+
: typeof __filename !== 'undefined'
34+
? __filename
35+
: process.cwd() + '/';
36+
try {
37+
cachedTransform = (createRequire(anchor)('sucrase') as { transform: typeof sucraseTransform }).transform;
38+
} catch (err) {
39+
throw new Error(
40+
`@objectstack/lint: validating a kind:'react' page requires the "sucrase" package, which could not be loaded ` +
41+
`(${err instanceof Error ? err.message : String(err)}). It is a declared dependency of @objectstack/lint — ` +
42+
`if this deployment prunes packages, keep "sucrase" in the image; it is only loaded when a react-source page is validated.`,
43+
);
44+
}
45+
return cachedTransform;
46+
}
1447

1548
export type ReactPageSeverity = 'error' | 'warning';
1649

@@ -45,6 +78,9 @@ export function validateReactPages(stack: AnyRec): ReactPageFinding[] {
4578
});
4679
continue;
4780
}
81+
// Outside the try below on purpose: a missing transpiler must surface as
82+
// an error, not be swallowed as a syntax finding.
83+
const transform = loadSucraseTransform();
4884
try {
4985
// transpile-only (no eval) — catches syntax errors, unterminated JSX, etc.
5086
transform(source, { transforms: ['jsx', 'typescript'], production: true });

0 commit comments

Comments
 (0)