Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"devDependencies": {
"@eggjs/bin": "workspace:*",
"@eggjs/tsconfig": "workspace:*",
"@oxc-node/core": "catalog:",
"@types/content-type": "catalog:",
"@types/js-yaml": "catalog:",
"@types/koa-compose": "catalog:",
Expand Down
29 changes: 28 additions & 1 deletion packages/utils/src/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@ export function setBundleModuleLoader(loader: BundleModuleLoader | undefined): v
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = loader;
}

// Shared promises for ESM imports that are currently in flight, keyed by file URL.
// See the usage site in `importModule` for why this is needed.
const _inflightImports = new Map<string, Promise<any>>();

export async function importModule(filepath: string, options?: ImportModuleOptions): Promise<any> {
const _bundleModuleLoader = globalThis.__EGG_BUNDLE_MODULE_LOADER__;
if (_bundleModuleLoader) {
Expand Down Expand Up @@ -525,7 +529,30 @@ export async function importModule(filepath: string, options?: ImportModuleOptio
if (_bundleModuleLoader) {
obj = await getNativeDynamicImport()(fileUrl);
} else {
obj = await import(fileUrl);
// Dedupe concurrent in-flight imports of the same URL. The runtime TS
// transpile loaders (tsx, @oxc-node/core) recompile a module on every
// `import()` (tsx appends a cache-busting query), so when several apps boot
// concurrently in one process (e.g. tegg multi-app isolation) two loaders can
// trigger two simultaneous compiles of the SAME module and one may observe a
// partially-initialized namespace (an `undefined` default export) — surfacing
// downstream as `Cannot convert undefined or null to object` in `loadExtend`
// or a plugin that lost its `path`. Sharing a single `import()` per URL
// serializes those concurrent first-loads.
let pending = _inflightImports.get(fileUrl);
if (pending === undefined) {
pending = import(fileUrl);
_inflightImports.set(fileUrl, pending);
const clearInflight = () => {
if (_inflightImports.get(fileUrl) === pending) {
_inflightImports.delete(fileUrl);
}
};
// `then(clear, clear)` (not `finally`) so a failed import settles the
// cleanup chain without leaving an unhandled rejection — the awaiting
// caller below still observes and propagates the original error.
pending.then(clearInflight, clearInflight);
}
obj = await pending;
}
debug('[importModule:success] await import %o', fileUrl);
// {
Expand Down
2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ catalog:
'@eggjs/scripts': ^4.0.0
'@fengmk2/ps-tree': ^2.0.1
'@oclif/core': ^4.2.0
'@oxc-node/core': ^0.0.35
'@oxc-node/core': ^0.1.0
'@swc-node/register': ^1.11.1
'@swc/core': ^1.15.1
'@types/accepts': ^1.3.7
Expand Down
8 changes: 6 additions & 2 deletions tegg/core/vitest/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ if (!process.env.EGG_TYPESCRIPT) {
process.env.EGG_TYPESCRIPT = 'true';
}

// Ensure child processes spawned by these tests inherit a runtime TS loader so
// Egg can load .ts via `import()`. The root vitest config already injects
// `--import=@oxc-node/core/register`; only inject it ourselves when neither it
// nor the legacy `tsx/esm` hook is present, to avoid enabling both at once.
const nodeOptions = process.env.NODE_OPTIONS ?? '';
if (!nodeOptions.includes('tsx/esm')) {
process.env.NODE_OPTIONS = `${nodeOptions} --import=tsx/esm`.trim();
if (!nodeOptions.includes('@oxc-node/core/register') && !nodeOptions.includes('tsx/esm')) {
process.env.NODE_OPTIONS = `${nodeOptions} --import=@oxc-node/core/register`.trim();
}
10 changes: 6 additions & 4 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@ const config: UserWorkspaceConfig = defineConfig({
env: {
// disable tegg plugins by default on unittest, make test speed up
DISABLE_TEGG_PLUGINS: 'true',
// TODO: aop plugin required this flag, otherwise there will be a SyntaxError: Invalid or unexpected token
NODE_OPTIONS: '--import=tsx/esm',
// FIXME: TypeError: Cannot read properties of undefined (reading 'mode')
// NODE_OPTIONS: '--import=@oxc-node/core/register',
// Transpile runtime `import()` of .ts files (egg loader resolving
// fixtures/plugins/app code, and the workspace `src` exports under
// node_modules) with oxc-node — noticeably faster than tsx and it handles
// decorators correctly. Requires @oxc-node/core >= 0.1.0, which fixes the
// earlier "Cannot read properties of undefined (reading 'mode')" crash.
NODE_OPTIONS: '--import=@oxc-node/core/register',
Comment on lines +48 to +52

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 6c34277. tegg/core/vitest/test/setup.ts now skips injecting a loader when either @oxc-node/core/register or tsx/esm is already present in NODE_OPTIONS (and falls back to oxc-node otherwise), so the two hooks are no longer enabled together. Verified the tegg/core/* project tests still pass.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
// poolOptions: {
// forks: {
Expand Down
4 changes: 2 additions & 2 deletions wiki/concepts/vitest-isolate-false-state-leaks.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ signature of this class of bug, not flaky tests per se.
flipped the module-level `isESM` to `false`, with no way to unset it.
`snapshot-import.test.ts` had a no-op `afterEach`, so after it ran, every
later file in the worker resolved modules in CJS + snapshot mode and failed
with `Can not find plugin @eggjs/<x>` / `Cannot find module
'@eggjs/<x>/package.json'`. This single leak caused most of the cross-project
with `Can not find plugin @eggjs/<x>` / `Cannot find module '@eggjs/<x>/package.json'`.
This single leak caused most of the cross-project
failures (ajv-plugin, typebox-validate, view-nunjucks, standalone, …).
**Fix:** `setSnapshotModuleLoader(undefined)` now clears the loader and
restores the auto-detected `isESM`; the test clears it in `afterEach`.
Expand Down
2 changes: 1 addition & 1 deletion wiki/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Full **isolate:false suite validated GREEN** under CI-faithful parallelism (`--m

- sources touched: `packages/utils/src/import.ts`, `packages/utils/test/snapshot-import.test.ts`, `plugins/mock/src/app/extend/application.ts`
- pages updated: `wiki/index.md`, `wiki/log.md`, `wiki/concepts/vitest-isolate-false-state-leaks.md`
- note: Under root `pool:threads` + `isolate:false`, two realm-global leaks caused nondeterministic cross-file/cross-project failures. (1) `setSnapshotModuleLoader` left module-level `_snapshotModuleLoader`/`isESM=false` set (no-op test teardown), poisoning module resolution for later files (`Can not find plugin …`). (2) `mock.mockContext()` reused `currentContext` from a different app, binding helpers to the wrong app config (surl/csrf failures). Fixed both at the source. Full Node-22 suite: 15 → 3 failing files (remaining 2 environmental MySQL/DNS; `multipart/file-mode` is a pre-existing load flake that also fails under `isolate:true`). Reproduce on Node 22/24 with a utoo install — not Node 26 / bare pnpm.
- note: Under root `pool:threads` + `isolate:false`, two realm-global leaks caused nondeterministic cross-file/cross-project failures. (1) `setSnapshotModuleLoader` left module-level `_snapshotModuleLoader`/`isESM=false` set (no-op test teardown), poisoning module resolution for later files (`Can not find plugin …`). (2) `mockContext()` reused `currentContext` from a different app, binding helpers to the wrong app config (surl/csrf failures). Fixed both at the source. Full Node-22 suite: 15 → 3 failing files (remaining 2 environmental MySQL/DNS; `multipart/file-mode` is a pre-existing load flake that also fails under `isolate:true`). Reproduce on Node 22/24 with a utoo install — not Node 26 / bare pnpm.

## [2026-05-10] package | extract shared LoaderFS package

Expand Down
Loading