From 3a2811f874a8dc12ed844607f0ff0ff0def59858 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 3 Jul 2026 22:05:50 +0800 Subject: [PATCH] perf(lint): load Sucrase lazily in validateReactPages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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 --- .changeset/lint-lazy-sucrase.md | 9 ++ packages/lint/src/lazy-deps.test.ts | 133 ++++++++++++++++++ packages/lint/src/lazy-typescript.test.ts | 98 ------------- .../lint/src/validate-react-page-props.ts | 2 +- packages/lint/src/validate-react-pages.ts | 38 ++++- 5 files changed, 180 insertions(+), 100 deletions(-) create mode 100644 .changeset/lint-lazy-sucrase.md create mode 100644 packages/lint/src/lazy-deps.test.ts delete mode 100644 packages/lint/src/lazy-typescript.test.ts diff --git a/.changeset/lint-lazy-sucrase.md b/.changeset/lint-lazy-sucrase.md new file mode 100644 index 0000000000..dd62efac7a --- /dev/null +++ b/.changeset/lint-lazy-sucrase.md @@ -0,0 +1,9 @@ +--- +'@objectstack/lint': patch +--- + +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). + +`@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. + +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. diff --git a/packages/lint/src/lazy-deps.test.ts b/packages/lint/src/lazy-deps.test.ts new file mode 100644 index 0000000000..4f51bf47de --- /dev/null +++ b/packages/lint/src/lazy-deps.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Boot-path contract: importing @objectstack/lint must NOT load its heavy, +// gate-only dependencies. The package sits on the kernel boot path, while +// each dep below serves a gate that only runs when a `kind:'react'` page is +// actually validated — so each must load lazily, on first use: +// - `typescript` (~9 MB, and has been pruned from production images before +// — see validate-react-page-props.ts), loaded by the react-props gate; +// - `sucrase` (~1.5 MB), loaded by the react syntax gate +// (validate-react-pages.ts). +// +// Guarded at three levels because vitest inlines static imports through its +// transform (they never hit the native require cache), so an in-worker +// require.cache probe alone CANNOT catch a reintroduced eager import: +// 1. structural — no src file may eagerly `import ... from` a lazy dep +// (only `import type`, which erases at build); +// 2. built dist — child `node` processes import both dist formats and prove +// each dep is absent until a react page is validated (skipped when dist +// has not been built); +// 3. behavioral — each lazy path really loads its dep on demand and the +// gate still produces findings. +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { describe, it, expect } from 'vitest'; + +const srcDir = dirname(fileURLToPath(import.meta.url)); +const distDir = join(srcDir, '..', 'dist'); + +// Deps that must never load at import time. Extend this list when another +// heavy, rarely-hit dependency joins the package. +const LAZY_DEPS = ['typescript', 'sucrase']; + +const depLoaded = (cache: Record | undefined, dep: string) => + Object.keys(cache ?? {}).some((p) => p.split(/[/\\]/).join('/').includes(`/node_modules/${dep}/`)); + +describe('lazy dependency loading (kernel boot-path contract)', () => { + it('no src file eagerly imports a lazy dep (import type only)', () => { + // Static `import`/`export ... from ''` executes at module init in + // both dist formats; `import type` erases. Dynamic import()/createRequire + // inside functions are the sanctioned lazy forms and are not matched. + const eagerImport = (dep: string) => + new RegExp( + String.raw`^\s*(?:import\s+['"]${dep}['"]|(?:import|export)\s+(?!type[\s{])[^;]*?from\s*['"]${dep}['"]|import\s+\w+\s*=\s*require\(\s*['"]${dep}['"]\s*\))`, + 'm', + ); + const offenders = readdirSync(srcDir) + .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')) + .flatMap((f) => { + const body = readFileSync(join(srcDir, f), 'utf8'); + return LAZY_DEPS.filter((dep) => eagerImport(dep).test(body)).map((dep) => `${f} eagerly imports ${dep}`); + }); + expect(offenders).toEqual([]); + }); + + // Reused by both dist probes: import the dist entry, prove no lazy dep came + // with it, then run each react-page gate and prove exactly its own dep loads + // — and that the gate still produces its finding. + const reactStack = (source: string) => `{ pages: [{ name: 'r', kind: 'react', source: ${JSON.stringify(source)} }] }`; + const childBody = ` + const probe = require('node:module').createRequire(process.cwd() + '/probe.js'); + const loaded = (dep) => Object.keys(probe.cache ?? {}).some((p) => p.split(/[/\\\\]/).join('/').includes('/node_modules/' + dep + '/')); + const fail = (msg) => { console.error(msg); process.exit(1); }; + const check = (mod) => { + for (const dep of ${JSON.stringify(LAZY_DEPS)}) { + if (loaded(dep)) fail(dep + ' was loaded eagerly, at import time'); + } + const syntax = mod.validateReactPages(${reactStack('function Page(){ return
oops; }')}); + if (!loaded('sucrase')) fail('sucrase was not loaded by a react-page syntax validation'); + if (loaded('typescript')) fail('the syntax gate must not load typescript'); + if (!syntax.some((f) => f.rule === 'react-page-syntax')) fail('syntax gate produced no finding'); + const props = mod.validateReactPageProps(${reactStack('function Page(){ return ; }')}); + if (!loaded('typescript')) fail('typescript was not loaded by a react-page props validation'); + if (!props.some((f) => f.rule === 'react-prop-missing-required')) fail('props gate produced no finding'); + console.log('OK'); + }; + `; + + it.skipIf(!existsSync(join(distDir, 'index.cjs')))('built CJS dist does not load a lazy dep until a react page is validated', () => { + const out = execFileSync( + process.execPath, + ['-e', `${childBody}; check(require(${JSON.stringify(join(distDir, 'index.cjs'))}));`], + { encoding: 'utf8' }, + ); + expect(out).toContain('OK'); + }); + + it.skipIf(!existsSync(join(distDir, 'index.js')))('built ESM dist does not load a lazy dep until a react page is validated', () => { + const out = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { createRequire } from 'node:module'; + const require = createRequire(process.cwd() + '/probe.js'); + ${childBody}; + check(await import(${JSON.stringify(pathToFileURL(join(distDir, 'index.js')).href)}));`, + ], + { encoding: 'utf8' }, + ); + expect(out).toContain('OK'); + }); + + it('loads each dep lazily in-process and the gates still work', async () => { + const req = createRequire(import.meta.url); + const { validateReactPages, validateReactPageProps } = await import('./index.js'); + + // Stacks without a react-source page never touch either dep. + expect(validateReactPages({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]); + expect(validateReactPageProps({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]); + expect(validateReactPageProps({ pages: [{ name: 'r', kind: 'react', source: ' ' }] })).toEqual([]); + for (const dep of LAZY_DEPS) { + expect(depLoaded(req.cache, dep), `${dep} loaded before any react-source validation`).toBe(false); + } + + // The first react page with source pays the cost of exactly its own gate's + // dep — and the gates still work. + const syntax = validateReactPages({ + pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return
oops; }' }], + }); + expect(depLoaded(req.cache, 'sucrase')).toBe(true); + expect(depLoaded(req.cache, 'typescript'), 'the syntax gate must not load typescript').toBe(false); + expect(syntax.some((f) => f.rule === 'react-page-syntax' && f.severity === 'error')).toBe(true); + + const props = validateReactPageProps({ + pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return ; }' }], + }); + expect(depLoaded(req.cache, 'typescript')).toBe(true); + expect(props.some((f) => f.rule === 'react-prop-missing-required' && /objectName/.test(f.message))).toBe(true); + }); +}); diff --git a/packages/lint/src/lazy-typescript.test.ts b/packages/lint/src/lazy-typescript.test.ts deleted file mode 100644 index dfeb75335a..0000000000 --- a/packages/lint/src/lazy-typescript.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Boot-path contract: importing @objectstack/lint must NOT load the TypeScript -// compiler. The package sits on the kernel boot path, and `typescript` is -// ~9 MB (and has been pruned from production images before — see -// validate-react-page-props.ts); it must load lazily, on the first validated -// `kind:'react'` page. -// -// Guarded at three levels because vitest inlines static imports through its -// transform (they never hit the native require cache), so an in-worker -// require.cache probe alone CANNOT catch a reintroduced eager import: -// 1. structural — no src file may eagerly `import ... from 'typescript'` -// (only `import type`, which erases at build); -// 2. built dist — child `node` processes import both dist formats and prove -// the compiler is absent until a react page is validated (skipped when -// dist has not been built); -// 3. behavioral — the lazy path really loads the compiler on demand and the -// gate still produces findings. -import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { dirname, join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { describe, it, expect } from 'vitest'; - -const srcDir = dirname(fileURLToPath(import.meta.url)); -const distDir = join(srcDir, '..', 'dist'); - -describe('lazy typescript loading (kernel boot-path contract)', () => { - it('no src file eagerly imports typescript (import type only)', () => { - // Static `import`/`export ... from 'typescript'` executes at module init in - // both dist formats; `import type` erases. Dynamic import()/createRequire - // inside functions are the sanctioned lazy forms and are not matched. - const eagerTsImport = - /^\s*(?:import\s+['"]typescript['"]|(?:import|export)\s+(?!type[\s{])[^;]*?from\s*['"]typescript['"]|import\s+\w+\s*=\s*require\(\s*['"]typescript['"]\s*\))/m; - const offenders = readdirSync(srcDir) - .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')) - .filter((f) => eagerTsImport.test(readFileSync(join(srcDir, f), 'utf8'))); - expect(offenders).toEqual([]); - }); - - const reactStack = `{ pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return ; }' }] }`; - const childBody = ` - const probe = require('node:module').createRequire(process.cwd() + '/probe.js'); - const tsLoaded = () => Object.keys(probe.cache ?? {}).some((p) => /[/\\\\]node_modules[/\\\\]typescript[/\\\\]/.test(p)); - const fail = (msg) => { console.error(msg); process.exit(1); }; - const check = (mod) => { - if (tsLoaded()) fail('typescript was loaded eagerly, at import time'); - const findings = mod.validateReactPageProps(${reactStack}); - if (!tsLoaded()) fail('typescript was not loaded by a react-page validation'); - if (!findings.some((f) => f.rule === 'react-prop-missing-required')) fail('gate produced no finding'); - console.log('OK'); - }; - `; - - it.skipIf(!existsSync(join(distDir, 'index.cjs')))('built CJS dist does not load typescript until a react page is validated', () => { - const out = execFileSync( - process.execPath, - ['-e', `${childBody}; check(require(${JSON.stringify(join(distDir, 'index.cjs'))}));`], - { encoding: 'utf8' }, - ); - expect(out).toContain('OK'); - }); - - it.skipIf(!existsSync(join(distDir, 'index.js')))('built ESM dist does not load typescript until a react page is validated', () => { - const out = execFileSync( - process.execPath, - [ - '--input-type=module', - '-e', - `import { createRequire } from 'node:module'; - const require = createRequire(process.cwd() + '/probe.js'); - ${childBody}; - check(await import(${JSON.stringify(pathToFileURL(join(distDir, 'index.js')).href)}));`, - ], - { encoding: 'utf8' }, - ); - expect(out).toContain('OK'); - }); - - it('loads the compiler lazily in-process and the gate still works', async () => { - const req = createRequire(import.meta.url); - const tsLoaded = () => Object.keys(req.cache ?? {}).some((p) => /[/\\]node_modules[/\\]typescript[/\\]/.test(p)); - const { validateReactPageProps } = await import('./index.js'); - - // Stacks without a react-source page never touch the compiler. - expect(validateReactPageProps({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]); - expect(validateReactPageProps({ pages: [{ name: 'r', kind: 'react', source: ' ' }] })).toEqual([]); - expect(tsLoaded()).toBe(false); - - // The first react page with source pays the cost — and the gate still works. - const findings = validateReactPageProps({ - pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return ; }' }], - }); - expect(tsLoaded()).toBe(true); - expect(findings.some((f) => f.rule === 'react-prop-missing-required' && /objectName/.test(f.message))).toBe(true); - }); -}); diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 3683898e96..577a127978 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -26,7 +26,7 @@ import { REACT_BLOCKS } from '@objectstack/spec/ui'; // eager import also hard-crashes boot in deployments that prune the package // from the image (cloud's Docker pruner did exactly that). So the compiler is // loaded lazily, on first use, and stays a regular dependency in package.json. -// Guarded by lazy-typescript.test.ts. +// Guarded by lazy-deps.test.ts. // // `node:module` is a Node builtin, untouched by esbuild/tsup, so the static // `createRequire` import survives bundling; the `createRequire(...)` call is diff --git a/packages/lint/src/validate-react-pages.ts b/packages/lint/src/validate-react-pages.ts index 67b4ffa527..dc77204574 100644 --- a/packages/lint/src/validate-react-pages.ts +++ b/packages/lint/src/validate-react-pages.ts @@ -10,7 +10,40 @@ // validate runtime behaviour (a transpiling page can still throw at render); // the render-time error boundary owns that. -import { transform } from 'sucrase'; +import { createRequire } from 'node:module'; +import type { transform as sucraseTransform } from 'sucrase'; + +// Sucrase must NOT be imported at module top level: it is ~1.5 MB of CJS +// (~16 ms cold require), and @objectstack/lint sits on the kernel boot path — +// while this gate only runs when a `kind:'react'` page is actually validated +// (rare, trusted tier). Same boot-path contract as the TypeScript compiler in +// validate-react-page-props.ts: loaded lazily, on first use, staying a regular +// dependency in package.json. Guarded by lazy-deps.test.ts. +// +// `node:module` is a Node builtin, untouched by esbuild/tsup, so the static +// `createRequire` import survives bundling; the `createRequire(...)` call is +// deferred because `import.meta.url` is rewritten to an empty stub in the CJS +// build (same pattern as driver-sqlite-wasm's knex-wasm-dialect). +let cachedTransform: typeof sucraseTransform | null = null; +function loadSucraseTransform(): typeof sucraseTransform { + if (cachedTransform) return cachedTransform; + const anchor = + typeof import.meta !== 'undefined' && import.meta.url + ? import.meta.url + : typeof __filename !== 'undefined' + ? __filename + : process.cwd() + '/'; + try { + cachedTransform = (createRequire(anchor)('sucrase') as { transform: typeof sucraseTransform }).transform; + } catch (err) { + throw new Error( + `@objectstack/lint: validating a kind:'react' page requires the "sucrase" package, which could not be loaded ` + + `(${err instanceof Error ? err.message : String(err)}). It is a declared dependency of @objectstack/lint — ` + + `if this deployment prunes packages, keep "sucrase" in the image; it is only loaded when a react-source page is validated.`, + ); + } + return cachedTransform; +} export type ReactPageSeverity = 'error' | 'warning'; @@ -45,6 +78,9 @@ export function validateReactPages(stack: AnyRec): ReactPageFinding[] { }); continue; } + // Outside the try below on purpose: a missing transpiler must surface as + // an error, not be swallowed as a syntax finding. + const transform = loadSucraseTransform(); try { // transpile-only (no eval) — catches syntax errors, unterminated JSX, etc. transform(source, { transforms: ['jsx', 'typescript'], production: true });