Skip to content

Commit e82a495

Browse files
os-zhuangclaude
andauthored
perf(lint): load the TypeScript compiler lazily in validateReactPageProps (#2544)
@objectstack/lint sits on the kernel boot path, but the react-props gate (ADR-0081 Phase 2, #2482) only runs when a kind:'react' page is actually validated. The top-level `import ts from 'typescript'` made every boot parse the ~9 MB compiler (~70 ms+ warm, worse on container cold starts) and hard-crashed boot when a deployment pruned the package from the image (cloud's Docker pruner did; worked around in cloud#728). The compiler now loads on the first validated react-source page via a deferred createRequire (the bundling-safe pattern from driver-sqlite-wasm's knex-wasm-dialect); the public API stays synchronous and unchanged, and `typescript` stays a regular dependency. If the package is missing at call time, validation fails with an actionable error instead of killing boot. Guarded by lazy-typescript.test.ts at 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 when the 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 c0efe5d commit e82a495

4 files changed

Lines changed: 157 additions & 7 deletions

File tree

.changeset/lint-lazy-typescript.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
Load the TypeScript compiler lazily in `validateReactPageProps` instead of at module top level (ADR-0081 Phase 2 follow-up).
6+
7+
`@objectstack/lint` sits on the kernel boot path, so the eager `import ts from 'typescript'` (framework#2482) made every boot parse the ~9 MB compiler (~70 ms+ on a warm laptop, worse on container cold starts) for a gate that only runs when a `kind:'react'` page is actually validated — a rare, trusted-tier case. It also hard-crashed boot in deployments that prune the package from the image (cloud's Docker pruner did exactly that; worked around in cloud#728).
8+
9+
- The compiler now loads on the first validated react-source page, via a deferred `createRequire` (same bundling-safe pattern as driver-sqlite-wasm's knex-wasm-dialect); the public API stays synchronous and unchanged.
10+
- Importing the package, and validating stacks with no react pages, no longer touches `typescript` at all — so images that prune it boot fine and only fail (with an actionable error naming the package and the fix) if a react-source page is actually validated.
11+
- `typescript` remains a regular dependency of `@objectstack/lint`.
12+
- Guarded by a three-level regression test (structural no-eager-import scan, child-process probes of both dist formats, in-process lazy-load behavior), verified to go red if the eager import is reintroduced.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Boot-path contract: importing @objectstack/lint must NOT load the TypeScript
4+
// compiler. The package sits on the kernel boot path, and `typescript` is
5+
// ~9 MB (and has been pruned from production images before — see
6+
// validate-react-page-props.ts); it must load lazily, on the first validated
7+
// `kind:'react'` page.
8+
//
9+
// Guarded at three levels because vitest inlines static imports through its
10+
// transform (they never hit the native require cache), so an in-worker
11+
// require.cache probe alone CANNOT catch a reintroduced eager import:
12+
// 1. structural — no src file may eagerly `import ... from 'typescript'`
13+
// (only `import type`, which erases at build);
14+
// 2. built dist — child `node` processes import both dist formats and prove
15+
// the compiler is absent until a react page is validated (skipped when
16+
// dist has not been built);
17+
// 3. behavioral — the lazy path really loads the compiler on demand and the
18+
// gate still produces findings.
19+
import { execFileSync } from 'node:child_process';
20+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
21+
import { createRequire } from 'node:module';
22+
import { dirname, join } from 'node:path';
23+
import { fileURLToPath, pathToFileURL } from 'node:url';
24+
import { describe, it, expect } from 'vitest';
25+
26+
const srcDir = dirname(fileURLToPath(import.meta.url));
27+
const distDir = join(srcDir, '..', 'dist');
28+
29+
describe('lazy typescript loading (kernel boot-path contract)', () => {
30+
it('no src file eagerly imports typescript (import type only)', () => {
31+
// Static `import`/`export ... from 'typescript'` executes at module init in
32+
// both dist formats; `import type` erases. Dynamic import()/createRequire
33+
// inside functions are the sanctioned lazy forms and are not matched.
34+
const eagerTsImport =
35+
/^\s*(?:import\s+['"]typescript['"]|(?:import|export)\s+(?!type[\s{])[^;]*?from\s*['"]typescript['"]|import\s+\w+\s*=\s*require\(\s*['"]typescript['"]\s*\))/m;
36+
const offenders = readdirSync(srcDir)
37+
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
38+
.filter((f) => eagerTsImport.test(readFileSync(join(srcDir, f), 'utf8')));
39+
expect(offenders).toEqual([]);
40+
});
41+
42+
const reactStack = `{ pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return <ObjectForm mode="edit" />; }' }] }`;
43+
const childBody = `
44+
const probe = require('node:module').createRequire(process.cwd() + '/probe.js');
45+
const tsLoaded = () => Object.keys(probe.cache ?? {}).some((p) => /[/\\\\]node_modules[/\\\\]typescript[/\\\\]/.test(p));
46+
const fail = (msg) => { console.error(msg); process.exit(1); };
47+
const check = (mod) => {
48+
if (tsLoaded()) fail('typescript was loaded eagerly, at import time');
49+
const findings = mod.validateReactPageProps(${reactStack});
50+
if (!tsLoaded()) fail('typescript was not loaded by a react-page validation');
51+
if (!findings.some((f) => f.rule === 'react-prop-missing-required')) fail('gate produced no finding');
52+
console.log('OK');
53+
};
54+
`;
55+
56+
it.skipIf(!existsSync(join(distDir, 'index.cjs')))('built CJS dist does not load typescript until a react page is validated', () => {
57+
const out = execFileSync(
58+
process.execPath,
59+
['-e', `${childBody}; check(require(${JSON.stringify(join(distDir, 'index.cjs'))}));`],
60+
{ encoding: 'utf8' },
61+
);
62+
expect(out).toContain('OK');
63+
});
64+
65+
it.skipIf(!existsSync(join(distDir, 'index.js')))('built ESM dist does not load typescript until a react page is validated', () => {
66+
const out = execFileSync(
67+
process.execPath,
68+
[
69+
'--input-type=module',
70+
'-e',
71+
`import { createRequire } from 'node:module';
72+
const require = createRequire(process.cwd() + '/probe.js');
73+
${childBody};
74+
check(await import(${JSON.stringify(pathToFileURL(join(distDir, 'index.js')).href)}));`,
75+
],
76+
{ encoding: 'utf8' },
77+
);
78+
expect(out).toContain('OK');
79+
});
80+
81+
it('loads the compiler lazily in-process and the gate still works', async () => {
82+
const req = createRequire(import.meta.url);
83+
const tsLoaded = () => Object.keys(req.cache ?? {}).some((p) => /[/\\]node_modules[/\\]typescript[/\\]/.test(p));
84+
const { validateReactPageProps } = await import('./index.js');
85+
86+
// Stacks without a react-source page never touch the compiler.
87+
expect(validateReactPageProps({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]);
88+
expect(validateReactPageProps({ pages: [{ name: 'r', kind: 'react', source: ' ' }] })).toEqual([]);
89+
expect(tsLoaded()).toBe(false);
90+
91+
// The first react page with source pays the cost — and the gate still works.
92+
const findings = validateReactPageProps({
93+
pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return <ObjectForm mode="edit" />; }' }],
94+
});
95+
expect(tsLoaded()).toBe(true);
96+
expect(findings.some((f) => f.rule === 'react-prop-missing-required' && /objectName/.test(f.message))).toBe(true);
97+
});
98+
});

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

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,44 @@
1515
// unknown props (the contract's data props are a curated subset) — only the
1616
// likely typos, to keep false positives near zero.
1717

18-
import ts from 'typescript';
18+
import { createRequire } from 'node:module';
19+
import type ts from 'typescript';
1920
import { REACT_BLOCKS } from '@objectstack/spec/ui';
2021

22+
// The TypeScript compiler must NOT be imported at module top level: it is
23+
// ~9 MB of CJS (~70 ms+ to parse, worse on container cold starts), and
24+
// @objectstack/lint sits on the kernel boot path — while this gate only runs
25+
// when a `kind:'react'` page is actually validated (rare, trusted tier). An
26+
// eager import also hard-crashes boot in deployments that prune the package
27+
// from the image (cloud's Docker pruner did exactly that). So the compiler is
28+
// loaded lazily, on first use, and stays a regular dependency in package.json.
29+
// Guarded by lazy-typescript.test.ts.
30+
//
31+
// `node:module` is a Node builtin, untouched by esbuild/tsup, so the static
32+
// `createRequire` import survives bundling; the `createRequire(...)` call is
33+
// deferred because `import.meta.url` is rewritten to an empty stub in the CJS
34+
// build (same pattern as driver-sqlite-wasm's knex-wasm-dialect).
35+
let cachedTs: typeof ts | null = null;
36+
function loadTypeScript(): typeof ts {
37+
if (cachedTs) return cachedTs;
38+
const anchor =
39+
typeof import.meta !== 'undefined' && import.meta.url
40+
? import.meta.url
41+
: typeof __filename !== 'undefined'
42+
? __filename
43+
: process.cwd() + '/';
44+
try {
45+
cachedTs = createRequire(anchor)('typescript') as typeof ts;
46+
} catch (err) {
47+
throw new Error(
48+
`@objectstack/lint: validating a kind:'react' page requires the "typescript" package, which could not be loaded ` +
49+
`(${err instanceof Error ? err.message : String(err)}). It is a declared dependency of @objectstack/lint — ` +
50+
`if this deployment prunes packages, keep "typescript" in the image; it is only loaded when a react-source page is validated.`,
51+
);
52+
}
53+
return cachedTs;
54+
}
55+
2156
export type ReactPropSeverity = 'error' | 'warning';
2257

2358
export interface ReactPropFinding {
@@ -82,23 +117,27 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] {
82117
if (typeof source !== 'string' || source.trim() === '') continue;
83118
const name = String(page.name ?? `#${p}`);
84119

120+
// Outside the try below on purpose: a missing compiler must surface as an
121+
// error, not be swallowed as "unparseable source".
122+
const tsc = loadTypeScript();
123+
85124
let sf: ts.SourceFile;
86125
try {
87-
sf = ts.createSourceFile('page.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
126+
sf = tsc.createSourceFile('page.tsx', source, tsc.ScriptTarget.Latest, true, tsc.ScriptKind.TSX);
88127
} catch {
89128
continue; // the syntax gate reports unparseable sources
90129
}
91130

92131
const visit = (node: ts.Node): void => {
93-
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
132+
if (tsc.isJsxOpeningElement(node) || tsc.isJsxSelfClosingElement(node)) {
94133
const tag = node.tagName.getText(sf);
95134
const block = BLOCKS.get(tag);
96135
if (block) {
97136
let hasSpread = false;
98137
const used = new Set<string>();
99138
for (const a of node.attributes.properties) {
100-
if (ts.isJsxSpreadAttribute(a)) { hasSpread = true; continue; }
101-
if (ts.isJsxAttribute(a)) used.add(a.name.getText(sf));
139+
if (tsc.isJsxSpreadAttribute(a)) { hasSpread = true; continue; }
140+
if (tsc.isJsxAttribute(a)) used.add(a.name.getText(sf));
102141
}
103142
const where = `page "${name}" › <${tag}>`;
104143
const path = `pages[${p}].source`;
@@ -129,7 +168,7 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] {
129168
}
130169
}
131170
}
132-
ts.forEachChild(node, visit);
171+
tsc.forEachChild(node, visit);
133172
};
134173
visit(sf);
135174
}

packages/lint/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"extends": "../../tsconfig.json",
33
"compilerOptions": {
44
"outDir": "./dist",
5-
"rootDir": "./src"
5+
"rootDir": "./src",
6+
"types": ["node"]
67
},
78
"include": ["src/**/*"],
89
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]

0 commit comments

Comments
 (0)