From 215e090f6ee6e2592829303c7d7a6a17afb71c9a Mon Sep 17 00:00:00 2001 From: DArcy Rush Date: Fri, 6 Nov 2026 12:46:13 +0100 Subject: [PATCH 01/23] feat(core): compile ES module syntax to CommonJS where tsc would (#905) --- README.md | 61 +- packages/core/esm.mjs | 3 + packages/core/register.mjs | 109 ++- packages/integrate-commonjs/package.json | 9 + packages/integrate-commonjs/src/app.ts | 7 + .../integrate-commonjs/src/commonjs.test.ts | 110 ++++ packages/integrate-commonjs/src/cycle-a.ts | 12 + packages/integrate-commonjs/src/cycle-b.ts | 6 + .../integrate-commonjs/src/esm-importer.mts | 6 + packages/integrate-commonjs/src/esm-lib.mts | 1 + .../integrate-commonjs/src/helpers.test.ts | 22 + packages/integrate-commonjs/src/legacy.cjs | 1 + packages/integrate-commonjs/src/legacy.d.cts | 2 + packages/integrate-commonjs/src/lib.ts | 25 + packages/integrate-commonjs/src/meta.ts | 4 + packages/integrate-commonjs/src/more.ts | 1 + .../integrate-commonjs/src/output-names.ts | 4 + packages/integrate-commonjs/src/script.ts | 3 + packages/integrate-commonjs/src/shadowed.js | 4 + packages/integrate-commonjs/src/shadowed.ts | 1 + packages/integrate-commonjs/src/throws.ts | 6 + packages/integrate-commonjs/src/tla.ts | 4 + .../integrate-commonjs/src/untyped/module.ts | 3 + .../src/untyped/package.json | 3 + .../integrate-commonjs/src/untyped/script.ts | 2 + packages/integrate-commonjs/tsconfig.json | 10 + src/lib.rs | 622 +++++++++++++++++- 27 files changed, 1002 insertions(+), 39 deletions(-) create mode 100644 packages/integrate-commonjs/package.json create mode 100644 packages/integrate-commonjs/src/app.ts create mode 100644 packages/integrate-commonjs/src/commonjs.test.ts create mode 100644 packages/integrate-commonjs/src/cycle-a.ts create mode 100644 packages/integrate-commonjs/src/cycle-b.ts create mode 100644 packages/integrate-commonjs/src/esm-importer.mts create mode 100644 packages/integrate-commonjs/src/esm-lib.mts create mode 100644 packages/integrate-commonjs/src/helpers.test.ts create mode 100644 packages/integrate-commonjs/src/legacy.cjs create mode 100644 packages/integrate-commonjs/src/legacy.d.cts create mode 100644 packages/integrate-commonjs/src/lib.ts create mode 100644 packages/integrate-commonjs/src/meta.ts create mode 100644 packages/integrate-commonjs/src/more.ts create mode 100644 packages/integrate-commonjs/src/output-names.ts create mode 100644 packages/integrate-commonjs/src/script.ts create mode 100644 packages/integrate-commonjs/src/shadowed.js create mode 100644 packages/integrate-commonjs/src/shadowed.ts create mode 100644 packages/integrate-commonjs/src/throws.ts create mode 100644 packages/integrate-commonjs/src/tla.ts create mode 100644 packages/integrate-commonjs/src/untyped/module.ts create mode 100644 packages/integrate-commonjs/src/untyped/package.json create mode 100644 packages/integrate-commonjs/src/untyped/script.ts create mode 100644 packages/integrate-commonjs/tsconfig.json diff --git a/README.md b/README.md index 9fd2febb..66d8658d 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ type checker, and it does not write build output to disk. - ESM and CommonJS, including `.mts`, `.cts`, `.mjs`, and `.cjs` - JSX and selected TypeScript compiler options - `tsconfig.json` path aliases and TypeScript-aware import resolution -- Imports such as `./file.js` resolving to `./file.ts` or `./file.tsx`. From - JavaScript files and the entry point, `./file.js` itself runs when it exists +- Imports and `require()` calls such as `./file.js` resolving to `./file.ts` or + `./file.tsx`. From JavaScript files and the entry point, `./file.js` itself runs + when it exists - Source-mapped stack traces - Importing TypeScript from a tool, without hooks (`@oxc-node/core/importer`) - Node features and flags such as watch mode, the test runner, and the @@ -359,6 +360,62 @@ transformation. These include path aliases, module and JSX settings, legacy decorators and decorator metadata, class-field semantics, and relative import extension rewriting. +### ES module syntax in a CommonJS package + +A `.ts`, `.tsx`, `.js` or `.jsx` file with `import`/`export` syntax in a +`"type": "commonjs"` package is compiled to CommonJS when its tsconfig's `module` +is `commonjs`, `node16`, `node18`, `node20` or `nodenext`, as `tsc` compiles it. +The output takes `tsc`'s shape: an export of a local that is never reassigned is a +plain, writable property of `exports`, so `mock.method(namespace, "name")` replaces +it for every importer, as it does under `tsc`. Re-exports and reassigned locals +stay live through getters. + +The package must say `"type": "commonjs"`: `tsc` also emits CommonJS for a package +without a `"type"`, but there such a file runs as an ES module, as before, so a +project relying on Node.js' syntax detection keeps working. A file also stays an +ES module when it uses `import.meta` or top-level await, which have no CommonJS +form, or declares a name the CommonJS wrapper takes (`exports`, `require`, +`module`, `__filename`, `__dirname`). Outside that combination — no `"type"`, +`"type": "module"`, or an ES `module` setting — a file with module syntax runs as +an ES module, as before. A `.cts` file is not compiled this way; see +[Programmatic transformation](#programmatic-transformation). + +With `node16`, `node18`, `node20` or `nodenext`, the package's `"type"` decides the +format of a `.ts` or `.tsx` file without module syntax, as it does for `tsc`: in a +`"type": "commonjs"` package, or one without a `"type"`, such a file is CommonJS, +also when an ES module imports it. Earlier releases ran it as an ES module. Only an +ES `module` setting, such as `esnext`, makes every `.ts` and `.tsx` file an ES +module. + +An ES module that imports the compiled file gets what Node.js gives it for any +CommonJS module: + +- Its named imports are the names Node.js' `cjs-module-lexer` finds: the file's + own exports, but not those it re-exports with `export *` from another TypeScript + file, because Node.js only follows re-exports into `.js` and `.cjs` files. +- Its default import is `module.exports`, whose `default` property is the file's + default export: after `import lib from "./lib.js"`, the class `lib.ts` exports + as default is `lib.default`. TypeScript types the import the same way under + `nodenext`. Earlier releases ran the file as an ES module, whose default import + was its default export. + +The compiled file's relative `require()` calls resolve as its imports did: +TypeScript first, by the file's own name or by its output's, so `./lib.js` and +`./lib` find `lib.ts`, also when a compiled `lib.js` sits next to it, and +`./lib.mjs` finds `lib.mts`. The relative `require()` calls of every TypeScript +file outside `node_modules` resolve this way. + +Importing an ES module — an `.mts` or `.mjs` file, or a `"type": "module"` +package — from the compiled file is `require()` of an ES module. Node.js loads the +entry point, test files and files an ES module imports through the module hooks, +together with the compiled files they `require()`, and `require()` of an ES module +from a file loaded that way needs +[nodejs/node#59679](https://github.com/nodejs/node/pull/59679), which the 22.x line +has from 22.22.3. Earlier releases fail with `TypeError: Cannot read properties of +undefined (reading 'exports')`. + +### Dependencies + The loader does not transform files in `node_modules` by default. Set `OXC_TRANSFORM_ALL=typescript` to transform only the TypeScript dependencies — `.ts`, `.mts`, `.cts` and `.tsx`; JavaScript dependencies are still left to diff --git a/packages/core/esm.mjs b/packages/core/esm.mjs index 107ac9c0..c1bbe700 100644 --- a/packages/core/esm.mjs +++ b/packages/core/esm.mjs @@ -33,6 +33,9 @@ if (!isMainThread) { // `oxnode` adds another), so each copy tags the module name it passes to the // transformer and registers a resolver for that tag: a tagged specifier only ever // resolves against the copy that emitted it. +// +// `register.mjs` computes this module's tag too: a file compiled to CommonJS here +// `require()`s its helpers on the main thread, which has to resolve them. const HELPER_SPECIFIER_PREFIX = "@oxc-node/core/helpers/"; const HELPER_TAGGED_PREFIX = "@oxc-node/core@"; const HELPER_TAG = diff --git a/packages/core/register.mjs b/packages/core/register.mjs index 7d1c6c52..529e5585 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { statSync } from "node:fs"; import * as NodeModule from "node:module"; import { pathToFileURL } from "node:url"; import { isMainThread } from "node:worker_threads"; @@ -58,15 +59,21 @@ const DEFAULT_EXTENSIONS = new Set([ // resolves against the copy that emitted it. const HELPER_SPECIFIER_PREFIX = "@oxc-node/core/helpers/"; const HELPER_TAGGED_PREFIX = "@oxc-node/core@"; -const HELPER_TAG = - "c" + - createHash("sha256") - .update(import.meta.url) - .digest("hex") - .slice(0, 12); +/** + * The tag of the module at `url`, as `esm.mjs` computes its own. + * + * @param {string} url + * @returns {string} + */ +function helperTag(url) { + return "c" + createHash("sha256").update(url).digest("hex").slice(0, 12); +} + +const HELPER_TAG = helperTag(import.meta.url); const HELPER_MODULE_NAME = `${HELPER_TAGGED_PREFIX}${HELPER_TAG}`; const requireHelper = createRequire(import.meta.url); +const resolveHelper = (subpath) => requireHelper.resolve(`@oxc-node/core/${subpath}`); // Each copy's resolver lives in a realm-wide registry keyed by its tag. Tagged // specifiers take exactly their owner's resolver; untagged ones — emitted by older @@ -74,7 +81,7 @@ const requireHelper = createRequire(import.meta.url); // newest registration first. const HELPER_RESOLVERS = Symbol.for("@oxc-node/core:helperResolvers"); const helperResolvers = (globalThis[HELPER_RESOLVERS] ??= {}); -helperResolvers[HELPER_TAG] = (subpath) => requireHelper.resolve(`@oxc-node/core/${subpath}`); +helperResolvers[HELPER_TAG] = resolveHelper; function isHelperSpecifier(specifier) { return ( @@ -150,9 +157,88 @@ Module._resolveFilename = function (request, parent, isMain, options) { } } } - return resolveFilename.call(this, request, parent, isMain, options); + let resolved; + try { + resolved = resolveFilename.call(this, request, parent, isMain, options); + } catch (error) { + // A TypeScript file is imported by its output's name — `./lib.js` for `lib.ts`, + // `./lib.mjs` for `lib.mts` — and an ES module compiled to CommonJS keeps that + // name in its `require()`. Node.js' CommonJS resolution knows only the output, so + // when it is missing the source is tried instead, as the ES module resolver does. + if (error?.code === "MODULE_NOT_FOUND") { + for (const candidate of typeScriptSources(request)) { + try { + return resolveFilename.call(this, candidate, parent, isMain, options); + } catch { + // Not this one either; the original error is the one to report. + } + } + } + throw error; + } + // Where the output exists too — compiled in place, or stale — Node.js' CommonJS + // resolution finds it first: `./lib.js` and `./lib` both name `lib.js`. A TypeScript + // importer resolves TypeScript first, as the ES module resolver does for it (#840). + return (resolvesSourceFirst(request, parent) && sourceBeside(resolved)) || resolved; +}; + +// The TypeScript files compiled output may come from, in the order the ES module +// resolver tries them for a TypeScript importer. +const TYPESCRIPT_SOURCES = { + ".js": [".ts", ".tsx"], + ".jsx": [".tsx", ".ts"], + ".mjs": [".mts"], + ".cjs": [".cts"], }; +const PATH_REQUEST = /^(?:\.{1,2}[\\/]|[\\/]|[a-zA-Z]:[\\/])/; + +/** + * Whether `parent` resolves `request` TypeScript first, as the ES module resolver + * decides: a relative or absolute request from a TypeScript or JSX file outside + * `node_modules`. The entry point has no parent and runs the file it names (#878). + * + * @param {string} request + * @param {{ filename?: string | null } | null | undefined} parent + * @returns {boolean} + */ +function resolvesSourceFirst(request, parent) { + const filename = parent?.filename; + return ( + typeof filename === "string" && + /\.(?:[cm]?ts|tsx|jsx)$/.test(filename) && + !/[\\/]node_modules[\\/]/.test(filename) && + PATH_REQUEST.test(request) + ); +} + +/** + * The TypeScript file a resolved JavaScript file may be compiled from, when it exists. + * + * @param {string} filename + * @returns {string | undefined} + */ +function sourceBeside(filename) { + return typeScriptSources(filename).find((source) => + statSync(source, { throwIfNoEntry: false })?.isFile(), + ); +} + +/** + * The TypeScript files a relative or absolute request for compiled output may name. + * + * @param {string} request + * @returns {string[]} + */ +function typeScriptSources(request) { + if (!PATH_REQUEST.test(request)) { + return []; + } + const extension = /\.[cm]?jsx?$/.exec(request)?.[0]; + const sources = extension && TYPESCRIPT_SOURCES[extension]; + return sources ? sources.map((source) => request.slice(0, -extension.length) + source) : []; +} + if (typeof setSourceMapsSupport === "function") { setSourceMapsSupport(true, { nodeModules: true, generatedCode: true }); } else if (typeof process.setSourceMapsEnabled === "function") { @@ -375,7 +461,12 @@ if (canRegisterSyncHooks()) { // environment can be an older snapshot than the registering worker's. // That thread has no `process.send`, so its watch reports use `watchPort`. const watchPort = watch ? createRelay(watch, takeWatchDependencies) : undefined; - register("@oxc-node/core/esm", { + // The hook thread tags the helpers it emits with its own module's URL. A file it + // compiles to CommonJS `require()`s them on this thread, so that tag resolves here + // too, against this same copy. + const esmHooks = import.meta.resolve("@oxc-node/core/esm"); + helperResolvers[helperTag(esmHooks)] = resolveHelper; + register(esmHooks, { parentURL: import.meta.url, data: { tsconfig: freezeTsconfig(process.cwd()), diff --git a/packages/integrate-commonjs/package.json b/packages/integrate-commonjs/package.json new file mode 100644 index 00000000..b645c826 --- /dev/null +++ b/packages/integrate-commonjs/package.json @@ -0,0 +1,9 @@ +{ + "name": "integrate-commonjs", + "version": "0.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "test": "node ../cli/dist/index.js --test \"src/*.test.ts\"" + } +} diff --git a/packages/integrate-commonjs/src/app.ts b/packages/integrate-commonjs/src/app.ts new file mode 100644 index 00000000..6a413106 --- /dev/null +++ b/packages/integrate-commonjs/src/app.ts @@ -0,0 +1,7 @@ +import { Clock, compute, twice } from "./lib"; + +export const answer = (): number => compute(41); + +export const doubled = (): number => twice(21); + +export const time = (): number => new Clock().now(); diff --git a/packages/integrate-commonjs/src/commonjs.test.ts b/packages/integrate-commonjs/src/commonjs.test.ts new file mode 100644 index 00000000..123c24d2 --- /dev/null +++ b/packages/integrate-commonjs/src/commonjs.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import legacyDefault, { legacy } from "./legacy.cjs"; +import Greeter, * as lib from "./lib"; +import { answer, doubled, time } from "./app"; +import { readB, seenDuringLoad } from "./cycle-a"; +import { compute, esmValue } from "./output-names"; +import { where as shadowedByOutputName } from "./shadowed.js"; +import { where as shadowedExtensionless } from "./shadowed"; +import { fail } from "./throws"; + +// `tsc` compiles ES module syntax in a `"type": "commonjs"` package to CommonJS when +// tsconfig's `module` is `nodenext`, and so does oxc-node. +void test("runs as CommonJS", () => { + assert.equal(typeof module, "object"); + assert.equal(typeof require, "function"); + assert.equal(require("./lib"), lib); +}); + +void test("a namespace export can be mocked, and importers see the mock", (t) => { + t.mock.method(lib, "compute", (n: number) => n * 2); + assert.equal(answer(), 82); +}); + +// A function declaration is exported before the module body runs, a class after its +// declaration; both are plain properties all the same. +void test("a function declaration export can be mocked, and importers see the mock", (t) => { + t.mock.method(lib, "twice", (n: number) => n * 3); + assert.equal(doubled(), 63); +}); + +void test("a class export can be replaced, and importers see the replacement", (t) => { + class FakeClock extends lib.Clock { + override now(): number { + return 2; + } + } + t.mock.property(lib, "Clock", FakeClock); + assert.equal(time(), 2); +}); + +void test("a reassigned export stays live", () => { + assert.equal(lib.counter, 0); + lib.increment(); + assert.equal(lib.counter, 1); +}); + +void test("default, star and CommonJS imports", () => { + assert.equal(new Greeter().greet(), "hello"); + assert.equal(lib.more, "more"); + assert.deepEqual(legacyDefault, { legacy: "legacy" }); + assert.equal(legacy, "legacy"); +}); + +void test("imports named by their output find the TypeScript file", () => { + assert.equal(compute, lib.compute); + assert.equal(esmValue, "esm"); +}); + +// `shadowed.js` is stale output of `shadowed.ts`. A TypeScript importer resolves +// TypeScript first (#840), whether it runs as an ES module or compiled to CommonJS. +void test("a TypeScript file is found before compiled output next to it", () => { + assert.equal(shadowedByOutputName, "shadowed.ts"); + assert.equal(shadowedExtensionless, "shadowed.ts"); +}); + +void test("an ES module importer gets the named exports", async () => { + const esm = await import("./esm-importer.mjs"); + assert.equal(esm.compute(1), 2); + assert.equal(esm.answer(), 42); +}); + +// Node.js gives an ES module `module.exports` as a CommonJS module's default export, +// `__esModule` or not, so the default export of the source is a property of it. +void test("an ES module importer's default import is the exports object", async () => { + const esm = await import("./esm-importer.mjs"); + assert.equal(esm.libDefault, lib); + assert.equal(esm.libDefault.default, Greeter); +}); + +// The tsconfig's `module` is `nodenext`, which leaves the format to `package.json`. +void test("a file without module syntax is CommonJS, also when an ES module asks for it", async () => { + // Not literals: a file without module syntax gives TypeScript no module to type. + for (const specifier of ["./script.js", "./untyped/script.js"]) { + const script = await import(specifier); + assert.deepEqual(script.default, { hasRequire: "function" }, specifier); + } + // Module syntax in a package without `"type"` runs as an ES module, as before. + const untyped = await import("./untyped/module.js"); + assert.equal(untyped.hasRequire, "undefined"); +}); + +// `cycle-b` runs while `cycle-a` is loading: as under `tsc`, the function declarations +// of `cycle-a` are exported already, and its other exports not yet. +void test("an import cycle sees what tsc's output sees", () => { + assert.deepEqual(seenDuringLoad, { hoisted: "hoisted", late: undefined }); + assert.equal(readB(), "b"); +}); + +void test("a stack trace reports the TypeScript file's own lines", () => { + assert.throws(fail, (error: Error) => /throws\.ts:5:9\)/.test(error.stack ?? "")); +}); + +void test("a module using import.meta or top-level await stays an ES module", async () => { + const meta = await import("./meta.js"); + assert.match(meta.url, /^file:.*meta\.ts$/); + const tla = await import("./tla.js"); + assert.equal(tla.answer, 42); +}); diff --git a/packages/integrate-commonjs/src/cycle-a.ts b/packages/integrate-commonjs/src/cycle-a.ts new file mode 100644 index 00000000..7ed55407 --- /dev/null +++ b/packages/integrate-commonjs/src/cycle-a.ts @@ -0,0 +1,12 @@ +// `cycle-b` imports this module back while it is still loading. +import { fromB } from "./cycle-b"; + +export { seenDuringLoad } from "./cycle-b"; + +export function hoisted(): string { + return "hoisted"; +} + +export const late = "late"; + +export const readB = (): string => fromB; diff --git a/packages/integrate-commonjs/src/cycle-b.ts b/packages/integrate-commonjs/src/cycle-b.ts new file mode 100644 index 00000000..933fc8f7 --- /dev/null +++ b/packages/integrate-commonjs/src/cycle-b.ts @@ -0,0 +1,6 @@ +import * as a from "./cycle-a"; + +// Read while `cycle-a`, which is imported first, is still loading. +export const seenDuringLoad = { hoisted: a.hoisted(), late: a.late }; + +export const fromB = "b"; diff --git a/packages/integrate-commonjs/src/esm-importer.mts b/packages/integrate-commonjs/src/esm-importer.mts new file mode 100644 index 00000000..1dce19b9 --- /dev/null +++ b/packages/integrate-commonjs/src/esm-importer.mts @@ -0,0 +1,6 @@ +// An ES module importing the compiled CommonJS: its named exports come from +// `cjs-module-lexer`, which reads them from the compiled source. +export { compute } from "./lib.js"; +export { answer } from "./app.js"; +// Its default export is `module.exports`, as for any CommonJS module. +export { default as libDefault } from "./lib.js"; diff --git a/packages/integrate-commonjs/src/esm-lib.mts b/packages/integrate-commonjs/src/esm-lib.mts new file mode 100644 index 00000000..cc99a08e --- /dev/null +++ b/packages/integrate-commonjs/src/esm-lib.mts @@ -0,0 +1 @@ +export const esmValue = "esm"; diff --git a/packages/integrate-commonjs/src/helpers.test.ts b/packages/integrate-commonjs/src/helpers.test.ts new file mode 100644 index 00000000..77fb2fd2 --- /dev/null +++ b/packages/integrate-commonjs/src/helpers.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Legacy decorators compile to calls of oxc-node's runtime helpers, which this file, +// an entry point compiled to CommonJS, has to `require()`. +const decorated: string[] = []; + +function record(_target: object, key: string): void { + decorated.push(key); +} + +class Service { + @record + run(): string { + return "ran"; + } +} + +void test("a file compiled to CommonJS can use the runtime helpers", () => { + assert.deepEqual(decorated, ["run"]); + assert.equal(new Service().run(), "ran"); +}); diff --git a/packages/integrate-commonjs/src/legacy.cjs b/packages/integrate-commonjs/src/legacy.cjs new file mode 100644 index 00000000..2331caaf --- /dev/null +++ b/packages/integrate-commonjs/src/legacy.cjs @@ -0,0 +1 @@ +module.exports = { legacy: "legacy" }; diff --git a/packages/integrate-commonjs/src/legacy.d.cts b/packages/integrate-commonjs/src/legacy.d.cts new file mode 100644 index 00000000..dc9604f9 --- /dev/null +++ b/packages/integrate-commonjs/src/legacy.d.cts @@ -0,0 +1,2 @@ +declare const legacy: { legacy: string }; +export = legacy; diff --git a/packages/integrate-commonjs/src/lib.ts b/packages/integrate-commonjs/src/lib.ts new file mode 100644 index 00000000..3391f795 --- /dev/null +++ b/packages/integrate-commonjs/src/lib.ts @@ -0,0 +1,25 @@ +export * from "./more"; + +export const compute = (n: number): number => n + 1; + +export function twice(n: number): number { + return n * 2; +} + +export class Clock { + now(): number { + return 1; + } +} + +export let counter = 0; + +export function increment(): void { + counter++; +} + +export default class Greeter { + greet(): string { + return "hello"; + } +} diff --git a/packages/integrate-commonjs/src/meta.ts b/packages/integrate-commonjs/src/meta.ts new file mode 100644 index 00000000..f37bda55 --- /dev/null +++ b/packages/integrate-commonjs/src/meta.ts @@ -0,0 +1,4 @@ +// `import.meta` has no CommonJS form, so this file stays an ES module where `tsc` +// would reject it. +// @ts-expect-error TS1470 +export const url: string = import.meta.url; diff --git a/packages/integrate-commonjs/src/more.ts b/packages/integrate-commonjs/src/more.ts new file mode 100644 index 00000000..41139c01 --- /dev/null +++ b/packages/integrate-commonjs/src/more.ts @@ -0,0 +1 @@ +export const more = "more"; diff --git a/packages/integrate-commonjs/src/output-names.ts b/packages/integrate-commonjs/src/output-names.ts new file mode 100644 index 00000000..fd0521af --- /dev/null +++ b/packages/integrate-commonjs/src/output-names.ts @@ -0,0 +1,4 @@ +// Imports named by their output, as `nodenext` has them written: compiled to +// CommonJS, each is a `require()` of a file that only exists as TypeScript. +export { compute } from "./lib.js"; +export { esmValue } from "./esm-lib.mjs"; diff --git a/packages/integrate-commonjs/src/script.ts b/packages/integrate-commonjs/src/script.ts new file mode 100644 index 00000000..62dc9d39 --- /dev/null +++ b/packages/integrate-commonjs/src/script.ts @@ -0,0 +1,3 @@ +// Without `import` or `export` this file is CommonJS, as `tsc` compiles it, also when +// an ES module asks for it. +module.exports = { hasRequire: typeof require }; diff --git a/packages/integrate-commonjs/src/shadowed.js b/packages/integrate-commonjs/src/shadowed.js new file mode 100644 index 00000000..cf7869fd --- /dev/null +++ b/packages/integrate-commonjs/src/shadowed.js @@ -0,0 +1,4 @@ +// Stale output of an older `shadowed.ts`, as compiling in place leaves it behind. +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.where = "shadowed.js"; diff --git a/packages/integrate-commonjs/src/shadowed.ts b/packages/integrate-commonjs/src/shadowed.ts new file mode 100644 index 00000000..67086df3 --- /dev/null +++ b/packages/integrate-commonjs/src/shadowed.ts @@ -0,0 +1 @@ +export const where = "shadowed.ts"; diff --git a/packages/integrate-commonjs/src/throws.ts b/packages/integrate-commonjs/src/throws.ts new file mode 100644 index 00000000..90bff670 --- /dev/null +++ b/packages/integrate-commonjs/src/throws.ts @@ -0,0 +1,6 @@ +import { join } from "node:path"; + +// A stack trace must report the throw at line 5, column 9. +export function fail(): never { + throw new Error(join("thrown", "here")); +} diff --git a/packages/integrate-commonjs/src/tla.ts b/packages/integrate-commonjs/src/tla.ts new file mode 100644 index 00000000..0320ffd8 --- /dev/null +++ b/packages/integrate-commonjs/src/tla.ts @@ -0,0 +1,4 @@ +// Top-level await has no CommonJS form, so this file stays an ES module where `tsc` +// would reject it. +// @ts-expect-error TS1309 +export const answer: number = await Promise.resolve(42); diff --git a/packages/integrate-commonjs/src/untyped/module.ts b/packages/integrate-commonjs/src/untyped/module.ts new file mode 100644 index 00000000..e31a2416 --- /dev/null +++ b/packages/integrate-commonjs/src/untyped/module.ts @@ -0,0 +1,3 @@ +// A package without `"type"`: `tsc` compiles this file to CommonJS, but Node.js' syntax +// detection runs it as an ES module, and so does oxc-node. +export const hasRequire = typeof require; diff --git a/packages/integrate-commonjs/src/untyped/package.json b/packages/integrate-commonjs/src/untyped/package.json new file mode 100644 index 00000000..352055cd --- /dev/null +++ b/packages/integrate-commonjs/src/untyped/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/packages/integrate-commonjs/src/untyped/script.ts b/packages/integrate-commonjs/src/untyped/script.ts new file mode 100644 index 00000000..68142cbb --- /dev/null +++ b/packages/integrate-commonjs/src/untyped/script.ts @@ -0,0 +1,2 @@ +// Without `import` or `export` this file is CommonJS, in a package without `"type"` too. +module.exports = { hasRequire: typeof require }; diff --git a/packages/integrate-commonjs/tsconfig.json b/packages/integrate-commonjs/tsconfig.json new file mode 100644 index 00000000..85241bef --- /dev/null +++ b/packages/integrate-commonjs/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "nodenext", + "strict": true, + "experimentalDecorators": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/src/lib.rs b/src/lib.rs index f7cfb684..0a7422a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,13 +16,17 @@ use napi::bindgen_prelude::*; use napi_derive::napi; use oxc::{ allocator::{Allocator, CloneIn, TakeIn}, - ast::ast::{ - Argument, ArrowFunctionExpression, AwaitExpression, Class, Declaration, Decorator, - ExportDefaultDeclarationKind, Expression, ForOfStatement, ImportDeclarationSpecifier, - Program, Statement, StringLiteral, TSExternalModuleDeclaration, TSGlobalDeclaration, - TSModuleReference, TSNamespaceDeclaration, TemplateLiteral, VariableDeclaration, + ast::{ + ast::{ + Argument, ArrowFunctionExpression, AwaitExpression, Class, Declaration, Decorator, + Directive, ExportDefaultDeclarationKind, Expression, ForOfStatement, + IdentifierReference, ImportDeclarationSpecifier, ObjectPropertyKind, Program, + Statement, StringLiteral, TSExternalModuleDeclaration, TSGlobalDeclaration, + TSModuleReference, TSNamespaceDeclaration, TemplateLiteral, VariableDeclaration, + }, + builder::AstBuilder, }, - ast_visit::{Visit, VisitMut, walk}, + ast_visit::{Visit, VisitMut, walk, walk_mut}, codegen::{Codegen, CodegenOptions, CodegenReturn}, diagnostics::{ Diagnostics, GraphicalReportHandler, GraphicalTheme, NamedSource, OxcDiagnostic, @@ -2410,6 +2414,18 @@ fn oxc_transform( { program.source_type = program.source_type.with_commonjs(true); } + // Where `tsc` would compile a file with ES module syntax to CommonJS, it is compiled to + // CommonJS here too (see `lowers_to_commonjs`) and stays one, on every path that targets + // CommonJS. The module runner keeps its own decision. A file the type-only switch above + // made CommonJS has no ES module syntax left, so it is never lowered. + let lower_to_commonjs = module_runner.is_none() + && !is_es_module + && !only_module_parse + && source_type.is_unambiguous() + && matches!(module_target, Some(Module::Preserve | Module::CommonJS)) + && parsed_as_esm(&program, &module_record) + && lowers_to_commonjs(src_path, compiler_options, &program, &module_record); + let flip_to_module = flip_to_module && !lower_to_commonjs; // `Module::Esm` and `Module::Preserve` emit identically for ES module input; the only // difference is that `Esm` makes the `import =` / `export =` lowerings report a @@ -2417,8 +2433,15 @@ fn oxc_transform( // crash at run time in a file Node.js runs as ESM. tsc rejects the same constructs in // ES module output (TS1202/TS1203), and Node's own type stripping rejects them at load // time. A `.cts` file keeps `Preserve` — its `import =` needs `require()`. - let effective_module = - if is_es_module || flip_to_module { Some(Module::Esm) } else { module_target }; + let effective_module = if lower_to_commonjs { + Some(Module::CommonJS) + } else if is_es_module || flip_to_module { + Some(Module::Esm) + } else { + module_target + }; + let mut commonjs_runner = ModuleRunner::commonjs(); + let module_runner = if lower_to_commonjs { Some(&mut commonjs_runner) } else { module_runner }; let output = transform_program( &allocator, @@ -2631,6 +2654,9 @@ fn transform_program<'a>( if runner.sync && !runner.top_level_await { drop_import_awaits(allocator, program); } + if runner.commonjs { + lower_runner_to_commonjs(allocator, program); + } } let CodegenReturn { code, map, .. } = Codegen::new() @@ -2658,6 +2684,16 @@ struct ModuleRunner { esm: bool, /// The ES module awaits at its top level. top_level_await: bool, + /// Compile the runner's output on to CommonJS (see [`lower_runner_to_commonjs`]). + /// Needs `sync`. + commonjs: bool, +} + +impl ModuleRunner { + /// What compiling an ES module to CommonJS asks of the runner transform. + fn commonjs() -> Self { + Self { sync: true, commonjs: true, ..Default::default() } + } } const SSR_EXPORTS: &str = "__vite_ssr_exports__"; @@ -2668,32 +2704,414 @@ const SSR_IMPORT: &str = "__vite_ssr_import__"; /// `export { a, b }` at the end of the file. Mapped, they would push the whole file /// down in [`retain_lines`]. They never throw, so they lose their mappings. fn unmap_hoisted_exports(program: &mut Program<'_>) { - struct Unmap; + for statement in &mut program.body { + if hoisted_export(statement).is_none() { + break; + } + Unmap.visit_statement(statement); + } +} + +/// Clears every span it visits, so that generated code carries no mappings. +struct Unmap; + +impl<'a> VisitMut<'a> for Unmap { + fn visit_span(&mut self, span: &mut Span) { + *span = SPAN; + } +} + +/// The name and getter result of an export the runner transform hoists: +/// `Object.defineProperty(__vite_ssr_exports__, "name", { ..., get() { return value; } })`. +fn hoisted_export<'s, 'a>(statement: &'s Statement<'a>) -> Option<(&'s str, &'s Expression<'a>)> { + let Statement::ExpressionStatement(expression) = statement else { return None }; + let Expression::CallExpression(call) = &expression.expression else { return None }; + let defines_property = call.callee.as_member_expression().is_some_and(|member| { + member.object().is_specific_id("Object") + && member.static_property_name().is_some_and(|name| name == "defineProperty") + }); + let [target, name, descriptor] = call.arguments.as_slice() else { return None }; + if !defines_property || !target.as_expression().is_some_and(|t| t.is_specific_id(SSR_EXPORTS)) { + return None; + } + let Some(Expression::StringLiteral(name)) = name.as_expression() else { return None }; + let Some(Expression::ObjectExpression(descriptor)) = descriptor.as_expression() else { + return None; + }; + let value = descriptor.properties.iter().find_map(|property| { + let ObjectPropertyKind::ObjectProperty(property) = property else { return None }; + if property.key.static_name().is_none_or(|key| key != "get") { + return None; + } + let Expression::FunctionExpression(getter) = &property.value else { return None }; + match getter.body.as_ref()?.statements.as_slice() { + [Statement::ReturnStatement(ret)] => ret.argument.as_ref(), + _ => None, + } + })?; + Some((name.value.as_str()?, value)) +} + +const SSR_EXPORT_ALL: &str = "__vite_ssr_exportAll__"; +const SSR_DYNAMIC_IMPORT: &str = "__vite_ssr_dynamic_import__"; - impl<'a> VisitMut<'a> for Unmap { - fn visit_span(&mut self, span: &mut Span) { - *span = SPAN; +/// The CommonJS wrapper's parameters, and the helpers [`lower_runner_to_commonjs`] +/// declares. An ES module may declare any of them at its top level; as CommonJS that +/// would redeclare a parameter or a helper, so such a module stays an ES module. +const COMMONJS_RESERVED: [&str; 8] = [ + "exports", + "require", + "module", + "__filename", + "__dirname", + "__importDefault", + "__importStar", + "__exportStar", +]; + +/// `tsc`'s helpers, as it emits them without `importHelpers`. A module is taken as-is +/// when it is a transpiled ES module (`__esModule`) or a real one (`require()` of an ES +/// module returns its namespace). +const IMPORT_DEFAULT_HELPER: &str = "var __importDefault = function (mod) { return mod && \ + (mod.__esModule || mod[Symbol.toStringTag] === \"Module\") ? mod : { default: mod }; };"; +const IMPORT_STAR_HELPER: &str = "var __importStar = function (mod) { if (mod && (mod.__esModule \ + || mod[Symbol.toStringTag] === \"Module\")) return mod; var result = {}; if (mod != null) \ + for (const k in mod) if (k !== \"default\" && Object.prototype.hasOwnProperty.call(mod, k)) \ + Object.defineProperty(result, k, { enumerable: true, get: function () { return mod[k]; } }); \ + result.default = mod; return result; };"; +const EXPORT_STAR_HELPER: &str = "var __exportStar = function (m, exports) { for (const p in m) \ + if (p !== \"default\" && !Object.prototype.hasOwnProperty.call(exports, p)) \ + Object.defineProperty(exports, p, { enumerable: true, get: function () { return m[p]; } }); };"; + +/// Whether this ES module is compiled to CommonJS, as `tsc` compiles it, instead of +/// running as an ES module: a file whose package is `"type": "commonjs"` and whose +/// tsconfig `module` emits CommonJS for such a package. `tsc` also emits CommonJS for a +/// package without a `"type"`, but there the file keeps running as an ES module, as +/// Node.js' syntax detection runs it. The caller checks the extension is an ambiguous one. A module that cannot be CommonJS stays an +/// ES module: one using `import.meta` or top-level await, or declaring a name the +/// CommonJS wrapper or its helpers take (see [`COMMONJS_RESERVED`]). +fn lowers_to_commonjs( + path: &Path, + compiler_options: Option<&CompilerOptions>, + program: &Program<'_>, + module_record: &ModuleRecord<'_>, +) -> bool { + let emits_commonjs = + compiler_options.and_then(|options| options.module.as_deref()).is_some_and(|module| { + matches!( + module.to_ascii_lowercase().as_str(), + "commonjs" | "node16" | "node18" | "node20" | "nodenext" + ) + }); + emits_commonjs + && module_record.import_metas.is_empty() + && !has_top_level_await(program) + && matches!(package_type(path), Some(PackageType::CommonJs)) + && { + let scoping = SemanticBuilder::new().build(program).semantic.into_scoping(); + COMMONJS_RESERVED.iter().all(|&name| scoping.get_root_binding(name.into()).is_none()) } +} + +/// Parses generated CommonJS into `allocator`, without mappings. +fn parse_generated<'a>( + allocator: &'a Allocator, + source: &str, +) -> (oxc::allocator::Vec<'a, Directive<'a>>, oxc::allocator::Vec<'a, Statement<'a>>) { + let source = allocator.alloc_str(source); + let ParserReturn { mut program, diagnostics, .. } = + Parser::new(allocator, source, SourceType::cjs()).parse(); + debug_assert!(diagnostics.is_empty(), "{diagnostics:?} in {source}"); + Unmap.visit_program(&mut program); + (program.directives, program.body) +} + +/// `exports.name` for an export name, or `exports["name"]` when it is not an identifier. +fn exports_member(name: &str) -> String { + if oxc::syntax::identifier::is_identifier_name(name) { + format!("exports.{name}") + } else { + format!("exports[{}]", serde_json::Value::from(name)) } +} - for statement in &mut program.body { - let Statement::ExpressionStatement(expression) = statement else { break }; - let Expression::CallExpression(call) = &expression.expression else { break }; - let defines_export = call.callee.as_member_expression().is_some_and(|member| { - member.object().is_specific_id("Object") - && member.static_property_name().is_some_and(|name| name == "defineProperty") - }) && call - .arguments - .first() - .and_then(Argument::as_expression) - .is_some_and(|target| target.is_specific_id(SSR_EXPORTS)); - if !defines_export { - break; +/// The source of a re-exported binding the runner reads through a getter: a local, or a +/// property of an imported module. +fn export_getter_source(expression: &Expression<'_>) -> Option { + match expression { + Expression::Identifier(ident) => Some(ident.name.to_string()), + Expression::StaticMemberExpression(member) => { + let Expression::Identifier(object) = &member.object else { return None }; + Some(format!("{}.{}", object.name, member.property.name)) } - Unmap.visit_statement(statement); + Expression::ComputedMemberExpression(member) => { + let (Expression::Identifier(object), Expression::StringLiteral(property)) = + (&member.object, &member.expression) + else { + return None; + }; + Some(format!("{}[{}]", object.name, serde_json::Value::from(property.value.as_str()?))) + } + _ => None, } } +/// The specifier and imported names of a runner import, `__vite_ssr_import__("x", { +/// importedNames: [...] })`; no names for a namespace import or a side effect. +fn runner_import<'s>(expression: &'s Expression<'_>) -> Option<(&'s str, Option>)> { + let Expression::CallExpression(call) = expression else { return None }; + if !call.callee.is_specific_id(SSR_IMPORT) { + return None; + } + let Some(Expression::StringLiteral(specifier)) = call.arguments.first()?.as_expression() else { + return None; + }; + let names = call.arguments.get(1).and_then(Argument::as_expression).and_then(|meta| { + let Expression::ObjectExpression(meta) = meta else { return None }; + meta.properties.iter().find_map(|property| { + let ObjectPropertyKind::ObjectProperty(property) = property else { return None }; + if property.key.static_name().is_none_or(|key| key != "importedNames") { + return None; + } + let Expression::ArrayExpression(names) = &property.value else { return None }; + Some( + names + .elements + .iter() + .filter_map(|name| match name.as_expression() { + Some(Expression::StringLiteral(name)) => name.value.as_str(), + _ => None, + }) + .collect(), + ) + }) + }); + Some((specifier.value.as_str()?, names)) +} + +/// The top-level bindings a statement declares, and whether it is a hoisted function. +fn top_level_bindings<'s>(statement: &'s Statement<'_>) -> (Vec<&'s str>, bool) { + match statement { + Statement::VariableDeclaration(declaration) => ( + declaration + .declarations + .iter() + .flat_map(|declarator| declarator.id.get_binding_identifiers()) + .map(|ident| ident.name.as_str()) + .collect(), + false, + ), + Statement::FunctionDeclaration(function) => { + (function.id.iter().map(|ident| ident.name.as_str()).collect(), true) + } + Statement::ClassDeclaration(class) => { + (class.id.iter().map(|ident| ident.name.as_str()).collect(), false) + } + _ => (Vec::new(), false), + } +} + +/// Compiles the module runner's output for an ES module to CommonJS in the shape `tsc` +/// emits, so that a `"type": "commonjs"` project runs as it does when built with `tsc`: +/// +/// * An export of a local that is never reassigned is a plain data property, assigned +/// after its declaration (`exports.a = void 0; ...; exports.a = a;`). It is writable and +/// configurable, so `mock.method(namespace, "a")` can replace it and every importer, +/// which reads it as `lib.a` at the call, sees the mock. A reassigned local and a +/// re-export keep a getter, which is what keeps them live. +/// * A named import is `require()`'s result itself; a default or namespace import goes +/// through `__importDefault` / `__importStar`, `export *` through `__exportStar`. +/// * Every export is spelled the way `cjs-module-lexer` detects, so an ES module +/// importing this one gets its named exports. +/// +/// Runs after the runner transform has hoisted the imports and exports and dropped the +/// imports' awaits. The caller has ruled out `import.meta` and top-level await. +fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program<'a>) { + struct Rewrite<'a> { + ast: AstBuilder<'a>, + } + + impl<'a> VisitMut<'a> for Rewrite<'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + walk_mut::walk_expression(self, expression); + if let Expression::CallExpression(call) = expression + && call.callee.is_specific_id(SSR_DYNAMIC_IMPORT) + && let Some(source) = + call.arguments.first_mut().and_then(Argument::as_expression_mut) + { + let source = source.take_in(&self.ast); + let options = call + .arguments + .get_mut(1) + .and_then(Argument::as_expression_mut) + .map(|options| options.take_in(&self.ast)); + *expression = + Expression::new_import_expression(call.span, source, options, None, &self.ast); + } + } + + fn visit_identifier_reference(&mut self, ident: &mut IdentifierReference<'a>) { + if ident.name == SSR_EXPORTS { + ident.name = "exports".into(); + } + } + } + + let scoping = SemanticBuilder::new().build(program).semantic.into_scoping(); + let hoisted = program.body.iter().take_while(|s| hoisted_export(s).is_some()).count(); + + // Where each top-level binding is declared, and the imports by binding. + let mut declared_at = HashMap::new(); + let mut imports = HashMap::new(); + for (index, statement) in program.body.iter().enumerate().skip(hoisted) { + let (names, hoisted_function) = top_level_bindings(statement); + for name in names { + declared_at.insert(name, (index, hoisted_function)); + } + if let Statement::VariableDeclaration(declaration) = statement + && let [declarator] = declaration.declarations.as_slice() + && let Some(name) = declarator.id.get_binding_identifier() + && let Some(import) = declarator.init.as_ref().and_then(runner_import) + { + imports.insert(name.name.as_str(), import); + } + } + let reference_count = |name: &str| { + scoping + .get_root_binding(name.into()) + .map_or(0, |symbol| scoping.get_resolved_references(symbol).count()) + }; + + let mut prelude = String::from("\"use strict\";\n"); + prelude.push_str("Object.defineProperty(exports, \"__esModule\", { value: true });\n"); + let mut cleared = Vec::new(); + let mut assigned_after: HashMap = HashMap::new(); + let mut getters = String::new(); + let mut kept = Vec::new(); + for (index, statement) in program.body[..hoisted].iter().enumerate() { + let (name, value) = hoisted_export(statement).expect("counted above"); + let member = exports_member(name); + let settled = match value { + Expression::Identifier(local) => scoping + .get_root_binding(local.name.as_str().into()) + .filter(|&symbol| { + !scoping.get_resolved_references(symbol).any(|reference| reference.is_write()) + }) + .and(declared_at.get(local.name.as_str())) + .map(|&(at, hoisted_function)| (local.name.as_str(), at, hoisted_function)), + _ => None, + }; + match (settled, export_getter_source(value)) { + (Some((local, _, true)), _) => { + prelude.push_str(&format!("{member} = {local};\n")); + } + (Some((local, at, false)), _) => { + cleared.push(member.clone()); + assigned_after.entry(at).or_default().push_str(&format!("{member} = {local};\n")); + } + (None, Some(getter)) => getters.push_str(&format!( + "Object.defineProperty(exports, {}, {{ enumerable: true, get: function () {{ \ + return {getter}; }} }});\n", + serde_json::Value::from(name) + )), + // Nothing the runner emits today; keep its own definition. + (None, None) => kept.push(index), + } + } + if !cleared.is_empty() { + prelude.push_str(&cleared.join(" = ")); + prelude.push_str(" = void 0;\n"); + } + prelude.push_str(&getters); + + // Each import, and each `export *` whose module is not imported otherwise, becomes a + // `require()` the helpers above wrap as `tsc` would. + let (mut import_default, mut import_star, mut export_star) = (false, false, false); + let mut require = |specifier: &str, names: Option<&[&str]>| { + let call = format!("require({})", serde_json::Value::from(specifier)); + match names { + Some(names) if !names.contains(&"default") => call, + Some(["default"]) => { + import_default = true; + format!("__importDefault({call})") + } + _ => { + import_star = true; + format!("__importStar({call})") + } + } + }; + let mut replacements: HashMap = HashMap::new(); + let mut inlined = HashSet::new(); + for (index, statement) in program.body.iter().enumerate().skip(hoisted) { + if let Statement::ExpressionStatement(expression) = statement + && let Expression::CallExpression(call) = &expression.expression + && call.callee.is_specific_id(SSR_EXPORT_ALL) + && let Some(Expression::Identifier(source)) = + call.arguments.first().and_then(Argument::as_expression) + { + export_star = true; + let source = source.name.as_str(); + let module = match imports.get(source) { + Some(&(specifier, _)) if reference_count(source) == 1 => { + inlined.insert(source); + format!("require({})", serde_json::Value::from(specifier)) + } + _ => source.to_owned(), + }; + replacements.insert(index, format!("__exportStar({module}, exports);\n")); + } + } + for (index, statement) in program.body.iter().enumerate().skip(hoisted) { + let Statement::VariableDeclaration(declaration) = statement else { continue }; + let [declarator] = declaration.declarations.as_slice() else { continue }; + let Some(name) = declarator.id.get_binding_identifier() else { continue }; + let Some(&(specifier, ref names)) = imports.get(name.name.as_str()) else { continue }; + let replacement = if inlined.contains(name.name.as_str()) { + String::new() + } else { + format!("const {} = {};\n", name.name, require(specifier, names.as_deref())) + }; + replacements.insert(index, replacement); + } + + let mut helpers = String::new(); + for (used, helper) in [ + (import_default, IMPORT_DEFAULT_HELPER), + (import_star, IMPORT_STAR_HELPER), + (export_star, EXPORT_STAR_HELPER), + ] { + if used { + helpers.push_str(helper); + helpers.push('\n'); + } + } + prelude.insert_str(prelude.find("Object.defineProperty").unwrap_or(0), &helpers); + + let (directives, prelude) = parse_generated(allocator, &prelude); + let mut body = + oxc::allocator::Vec::with_capacity_in(program.body.len() + prelude.len(), &allocator); + body.extend(prelude); + let original = program.body.take_in(&allocator); + for (index, statement) in original.into_iter().enumerate() { + if index < hoisted && !kept.contains(&index) { + continue; + } + match replacements.remove(&index) { + Some(replacement) => body.extend(parse_generated(allocator, &replacement).1), + None => body.push(statement), + } + if let Some(assignments) = assigned_after.remove(&index) { + body.extend(parse_generated(allocator, &assignments).1); + } + } + program.body = body; + if !program.directives.iter().any(|directive| directive.directive == "use strict") { + program.directives.extend(directives); + } + Rewrite { ast: AstBuilder::new(allocator) }.visit_program(program); +} + /// The runner transform drops the default binding of `import a, * as b from "x"`, so /// the declaration is split first: `import a from "x"; import * as b from "x";`. fn split_namespace_imports<'a>(allocator: &'a Allocator, program: &mut Program<'a>) { @@ -3486,8 +3904,8 @@ fn load_commonjs_esm( if !(only_module_parse || parsed_as_esm(&program, &module_record)) { return Ok(None); } - // From here on the file is handed back as an ES module, so surface parse errors - // exactly as the source-bearing path would. + // From here on the file is handed back transformed, so surface parse errors exactly + // as the source-bearing path would. if !diagnostics.is_empty() { let msg = join_errors(diagnostics.into_vec(), source, &path); return Err(Error::new( @@ -3495,6 +3913,30 @@ fn load_commonjs_esm( format!("Failed to parse {}:\n{}", path.display(), msg), )); } + // Where `tsc` would compile it to CommonJS, it stays CommonJS, compiled here and + // handed back with its source: `cjs-module-lexer` reads an ES module importer's named + // exports from that source, where the file on disk has none to find. + if !only_module_parse + && lowers_to_commonjs(&path, resolved_compiler_options, &program, &module_record) + { + let transformed = transform_program( + &allocator, + &path, + &mut program, + source, + resolved_compiler_options, + Some(Module::CommonJS), + false, + helper_module_name.as_deref(), + Some(&mut ModuleRunner::commonjs()), + )?; + tracing::debug!("loaded {} format: commonjs", url); + return Ok(Some(LoadFnOutput { + format: "commonjs".to_owned(), + source: Some(Either4::B(Uint8Array::from_string(code_with_inline_map(transformed)))), + response_url: Some(url.to_owned()), + })); + } // A module keeps its top-level awaits, hence the last `false`. `Module::Esm` emits // identically to `Preserve` for ES module input but reports the `import =` / // `export =` lowerings as transform errors instead of emitting bare `require()` / @@ -5071,6 +5513,130 @@ mod tests { assert_eq!(file_url_to_path("file:///x/d%5Ca.ts"), Some(PathBuf::from("/x/d\\a.ts"))); } + // An ES module that `tsc` compiles to CommonJS is compiled to CommonJS here too, in + // `tsc`'s shape; one that cannot be CommonJS, that `tsc` keeps an ES module, or whose + // package has no `"type"`, stays. + #[test] + fn commonjs_lowering() { + // Resolving a tsconfig records it for the watch report. + let _lock = watch_lock(); + let root = env::temp_dir().join(format!("oxc-node-commonjs-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + for (dir, package, module) in [ + ("cjs", r#"{ "type": "commonjs" }"#, "nodenext"), + ("commonjs", r#"{ "type": "commonjs" }"#, "commonjs"), + ("node16", r#"{ "type": "commonjs" }"#, "node16"), + ("node18", r#"{ "type": "commonjs" }"#, "node18"), + ("node20", r#"{ "type": "commonjs" }"#, "node20"), + // tsconfig option values are case-insensitive. + ("cased", r#"{ "type": "commonjs" }"#, "NodeNext"), + ("esnext", r#"{ "type": "commonjs" }"#, "esnext"), + ("preserve", r#"{ "type": "commonjs" }"#, "preserve"), + ("untyped", "{}", "nodenext"), + ] { + fs::create_dir_all(root.join(dir)).unwrap(); + fs::write(root.join(dir).join("package.json"), package).unwrap(); + fs::write( + root.join(dir).join("tsconfig.json"), + format!(r#"{{ "compilerOptions": {{ "module": "{module}" }} }}"#), + ) + .unwrap(); + } + let transformer = OxcTransformer::new(Some(root.to_string_lossy().into_owned()), None); + let run = |name: &str, source: &str| { + let path = root.join(name); + fs::write(&path, source).unwrap(); + match transformer + .transform(path.to_string_lossy().into_owned(), Either::A(source.to_owned())) + { + Ok(output) => output.code, + // Dropping a napi `Error` calls into Node-API, which a test binary lacks. + Err(error) => panic!("{}", mem::ManuallyDrop::new(error).reason), + } + }; + // The runner's bindings keep their names (`__vite_ssr_import_0__`, + // `__vite_ssr_export_default__`); none of its calls or objects may be left. + let lowered = |code: &str| { + code.contains("exports, \"__esModule\"") + && code.match_indices("__vite_ssr_").all(|(at, prefix)| { + let rest = &code[at + prefix.len()..]; + rest.starts_with("export_default__") + || rest + .strip_prefix("import_") + .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())) + }) + }; + + // A local that is never reassigned is a data property, assigned after it is declared. + let code = run("cjs/lib.ts", "export const compute = (n: number): number => n + 1;\n"); + assert!(lowered(&code), "{code}"); + assert!(code.contains("exports.compute = void 0;"), "{code}"); + assert!(code.contains("exports.compute = compute;"), "{code}"); + assert!(!code.contains("get"), "{code}"); + + let code = run( + "cjs/forms.ts", + "import def from './a';\nimport * as ns from './b';\nimport { named } from './c';\n\ + export * from './e';\nexport { r } from './g';\nexport let counter = 0;\n\ + export function bump() { counter++; return import('./dyn'); }\n\ + export default def + ns.x + named;\n", + ); + assert!(lowered(&code), "{code}"); + // A named import is `require()`'s result itself; default and namespace imports + // get `tsc`'s interop, and `export *` the shape `cjs-module-lexer` follows. + assert!(code.contains("= __importDefault(require(\"./a\"))"), "{code}"); + assert!(code.contains("= __importStar(require(\"./b\"))"), "{code}"); + assert!(code.contains("= require(\"./c\")"), "{code}"); + assert!(code.contains("__exportStar(require(\"./e\"), exports)"), "{code}"); + // Re-exports and reassigned locals stay live through getters, without + // `configurable`, which `cjs-module-lexer` would not detect. + for export in ["\"r\"", "\"counter\""] { + assert!( + code.contains(&format!( + "Object.defineProperty(exports, {export}, {{ enumerable: true, get: function() {{" + )), + "{code}" + ); + } + // A hoisted function is exported up front; `import()` is CommonJS' own. + assert!(code.contains("exports.bump = bump;"), "{code}"); + assert!(code.contains("import(\"./dyn\")"), "{code}"); + assert!(code.contains("exports.default = "), "{code}"); + + // Every `module` that `tsc` compiles to CommonJS for a `"type": "commonjs"` package. + for dir in ["commonjs", "node16", "node18", "node20", "cased"] { + let code = run(&format!("{dir}/lib.ts"), "export const n = 1;\n"); + assert!(lowered(&code) && code.contains("exports.n = n;"), "{dir}: {code}"); + } + + // What has no CommonJS form, or would clash with the CommonJS wrapper or its + // helpers, stays. + for (name, source) in [ + ("cjs/meta.ts", "export const url = import.meta.url;\n"), + ("cjs/require.ts", "const require = 1;\nexport { require };\n"), + ("cjs/module.ts", "export const module = 1;\n"), + ("cjs/exports.ts", "const exports = {};\nexport default exports;\n"), + ("cjs/filename.ts", "export function __filename() {}\n"), + ("cjs/dirname.ts", "export const __dirname = '.';\n"), + ("cjs/helper.ts", "export class __importStar {}\n"), + // `tsc` itself emits an ES module for these. + ("esnext/lib.ts", "export const n = 1;\n"), + ("preserve/lib.ts", "export const n = 1;\n"), + ("cjs/lib.mts", "export const n = 1;\n"), + // `tsc` emits CommonJS for this one, but without a `"type"` the file runs as + // an ES module, as before. + ("untyped/lib.ts", "export const n = 1;\n"), + ] { + let code = run(name, source); + assert!(!lowered(&code) && code.contains("export "), "{name}: {code}"); + } + // A file without module syntax is untouched CommonJS, as before. + let code = run("cjs/plain.ts", "module.exports = 1;\n"); + assert!(!code.contains("__esModule"), "{code}"); + + let _ = fs::remove_dir_all(&root); + } + // `@oxc-node/core/importer`'s transform: which files are ES modules, the form // `require()` of one runs, and lines that stay where the source has them. #[test] From f4a08d9f1e9be23f28e25f49c68be8fb7aba0149 Mon Sep 17 00:00:00 2001 From: DArcy Rush Date: Mon, 9 Nov 2026 13:55:16 +0100 Subject: [PATCH 02/23] fix(core): keep compiled CommonJS importing what the ES module imported An ES module compiled to CommonJS resolves its imports through `require()`, so it lost behaviour the ES module resolver gave it: - try tsconfig `paths` and `baseUrl` before Node.js' own resolution, through the new `tsconfigPathCandidates` binding - prefer `config.ts` over `config.json` for an extensionless request - keep a package's compiled `.js` when the resolved file is inside `node_modules` - fall back from `./x.js` to `x.jsx`, and from `./x.jsx` to `x.js`, without letting either win over a file that exists - copy non-enumerable exports in `__importStar`, as tsc does Co-Authored-By: Claude Opus 5.5 --- packages/core/index.d.ts | 17 ++ packages/core/index.js | 1 + packages/core/register.mjs | 149 ++++++++++++-- .../__tests__/commonjs-lowering.spec.ts | 188 ++++++++++++++++++ src/lib.rs | 70 ++++++- 5 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 6d30b27e..edf710a0 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -220,3 +220,20 @@ export declare function transformModuleRunner(path: string, source: string | Uin * it uses the frozen policy. */ export declare function transformsDep(path: string): boolean + +/** + * The files a tsconfig `paths` alias or `baseUrl` maps a bare `require()` request to, + * in the order to try them. Not checked on disk: `register.mjs` resolves each one with + * Node.js' CommonJS resolution, ahead of the request itself. + * + * An import resolves through [`create_resolve`], which applies the importer's tsconfig + * itself, but `require()` stays on Node.js' own CommonJS resolution, which knows + * nothing of tsconfig. Without these, an ES module compiled to CommonJS loses the + * aliases it resolved as an ES module (#905). + * + * The config is the one [`create_resolve`] applies to `parent` + * ([`TsconfigSource::for_importer`]), and as there, nothing maps a request that is not + * bare ([`is_bare_specifier`]) or a request from inside `node_modules`. `cwd` is only + * read when this is the loader context's first resolution. + */ +export declare function tsconfigPathCandidates(cwd: string, parent: string, specifier: string): Array diff --git a/packages/core/index.js b/packages/core/index.js index 44683fee..08567bd9 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -796,3 +796,4 @@ module.exports.transform = nativeBinding.transform module.exports.transformAsync = nativeBinding.transformAsync module.exports.transformModuleRunner = nativeBinding.transformModuleRunner module.exports.transformsDep = nativeBinding.transformsDep +module.exports.tsconfigPathCandidates = nativeBinding.tsconfigPathCandidates diff --git a/packages/core/register.mjs b/packages/core/register.mjs index 529e5585..cbac9610 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { statSync } from "node:fs"; import * as NodeModule from "node:module"; +import { basename } from "node:path"; import { pathToFileURL } from "node:url"; import { isMainThread } from "node:worker_threads"; @@ -16,6 +17,7 @@ import { load as oxcLoad, takeWatchDependencies, transformsDep, + tsconfigPathCandidates, } from "./index.js"; import { installSignalAck } from "./signal-ack.mjs"; import { createRelay, flush, watchSink, withFlush } from "./watch-report.mjs"; @@ -35,6 +37,9 @@ if (watch) { // Destructure from NodeModule namespace to support older Node.js versions const { Module, createRequire, register, registerHooks, setSourceMapsSupport } = NodeModule; +const isBuiltin = + NodeModule.isBuiltin ?? + ((request) => NodeModule.builtinModules.includes(request.replace(/^node:/, ""))); const DEFAULT_EXTENSIONS = new Set([ ".js", @@ -121,7 +126,7 @@ const resolveFilename = Module._resolveFilename; // process-wide via `Symbol.for`: with several copies registered, each one wraps the // previous function, and the inner call has to pass them all. const HELPER_RESOLVING = Symbol.for("@oxc-node/core:resolvingHelper"); -Module._resolveFilename = function (request, parent, isMain, options) { +Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, options) { if (!globalThis[HELPER_RESOLVING] && isHelperSpecifier(request)) { if (request.startsWith(HELPER_TAGGED_PREFIX)) { globalThis[HELPER_RESOLVING] = true; @@ -157,6 +162,28 @@ Module._resolveFilename = function (request, parent, isMain, options) { } } } + // Node.js' CommonJS resolution knows nothing of tsconfig `paths` and `baseUrl`. The + // ES module resolver, `tsc` and tsx try them before `node_modules`, so a bare + // request does here too: an ES module compiled to CommonJS finds what it imported as + // an ES module (#905). Each file an alias maps to resolves through this function, so + // the rules below apply to it as well. + if (mapsThroughTsconfig(request, parent)) { + const candidates = tsconfigPathCandidates(process.cwd(), parent.filename, request); + // Finding the importer's tsconfig reads it. + if (watch) { + flush(watch, takeWatchDependencies); + } + for (const candidate of candidates) { + try { + return oxcResolveFilename.call(this, candidate, parent, isMain, options); + } catch (error) { + // The next candidate, then the request itself, may still resolve. + if (error?.code !== "MODULE_NOT_FOUND") { + throw error; + } + } + } + } let resolved; try { resolved = resolveFilename.call(this, request, parent, isMain, options); @@ -166,7 +193,7 @@ Module._resolveFilename = function (request, parent, isMain, options) { // name in its `require()`. Node.js' CommonJS resolution knows only the output, so // when it is missing the source is tried instead, as the ES module resolver does. if (error?.code === "MODULE_NOT_FOUND") { - for (const candidate of typeScriptSources(request)) { + for (const candidate of sourcesFor(request, MISSING_OUTPUT_SOURCES)) { try { return resolveFilename.call(this, candidate, parent, isMain, options); } catch { @@ -176,23 +203,88 @@ Module._resolveFilename = function (request, parent, isMain, options) { } throw error; } - // Where the output exists too — compiled in place, or stale — Node.js' CommonJS - // resolution finds it first: `./lib.js` and `./lib` both name `lib.js`. A TypeScript - // importer resolves TypeScript first, as the ES module resolver does for it (#840). - return (resolvesSourceFirst(request, parent) && sourceBeside(resolved)) || resolved; + // A TypeScript importer resolves TypeScript first, as the ES module resolver does for + // it (#840), where Node.js' CommonJS resolution finds another file first: the output + // beside its source — compiled in place, or stale — that `./lib.js` and `./lib` both + // name, or `config.json` beside `config.ts` for `./config`. A package's files stay + // what it compiled, as they do for the ES module resolver. + if (resolvesSourceFirst(request, parent) && !inNodeModules(resolved)) { + return sourceBeside(resolved) ?? extensionlessSource(request, resolved) ?? resolved; + } + return resolved; }; -// The TypeScript files compiled output may come from, in the order the ES module -// resolver tries them for a TypeScript importer. -const TYPESCRIPT_SOURCES = { +// The files the ES module resolver tries, for a TypeScript importer, ahead of the +// compiled output a request names (`source_order` in `src/lib.rs`). +const SOURCES_BEFORE_OUTPUT = { ".js": [".ts", ".tsx"], ".jsx": [".tsx", ".ts"], ".mjs": [".mts"], ".cjs": [".cts"], }; +// Every other file it tries for that request, which stand in for the output when it is +// missing: those, then `.jsx` for `./x.js` and `.js` for `./x.jsx`, which it tries +// after the named file. +const MISSING_OUTPUT_SOURCES = { + ".js": [".ts", ".tsx", ".jsx"], + ".jsx": [".tsx", ".ts", ".js"], + ".mjs": [".mts"], + ".cjs": [".cts"], +}; + +// The extensions the ES module resolver tries for an extensionless request from a +// TypeScript importer, in its order. Node.js' CommonJS resolution tries `.js`, `.json` +// and `.node` first, then the extensions the `pirates` hook registers. +const SOURCE_EXTENSIONS = [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mts", + ".mjs", + ".cts", + ".cjs", + ".json", + ".node", +]; + const PATH_REQUEST = /^(?:\.{1,2}[\\/]|[\\/]|[a-zA-Z]:[\\/])/; +/** + * @param {string} path + * @returns {boolean} + */ +function inNodeModules(path) { + return /[\\/]node_modules[\\/]/.test(path); +} + +/** + * @param {string} path + * @returns {boolean} + */ +function isFile(path) { + return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false; +} + +/** + * Whether the importer's tsconfig may map `request`, as the ES module resolver decides: + * a bare request that is not a built-in module, from a file outside `node_modules`. + * + * @param {string} request + * @param {{ filename?: string | null } | null | undefined} parent + * @returns {parent is { filename: string }} + */ +function mapsThroughTsconfig(request, parent) { + const filename = parent?.filename; + return ( + typeof filename === "string" && + !inNodeModules(filename) && + !PATH_REQUEST.test(request) && + !isBuiltin(request) + ); +} + /** * Whether `parent` resolves `request` TypeScript first, as the ES module resolver * decides: a relative or absolute request from a TypeScript or JSX file outside @@ -207,7 +299,7 @@ function resolvesSourceFirst(request, parent) { return ( typeof filename === "string" && /\.(?:[cm]?ts|tsx|jsx)$/.test(filename) && - !/[\\/]node_modules[\\/]/.test(filename) && + !inNodeModules(filename) && PATH_REQUEST.test(request) ); } @@ -219,23 +311,48 @@ function resolvesSourceFirst(request, parent) { * @returns {string | undefined} */ function sourceBeside(filename) { - return typeScriptSources(filename).find((source) => - statSync(source, { throwIfNoEntry: false })?.isFile(), - ); + return sourcesFor(filename, SOURCES_BEFORE_OUTPUT).find(isFile); +} + +/** + * The file the ES module resolver would find for an extensionless `request` that + * Node.js resolved to `filename` by adding an extension it tries later, when one exists. + * + * @param {string} request + * @param {string} filename + * @returns {string | undefined} + */ +function extensionlessSource(request, filename) { + // A request that names its file's extension resolves to that very name. + const name = basename(request); + const file = basename(filename); + if (!file.startsWith(`${name}.`)) { + return undefined; + } + const added = file.slice(name.length); + const stem = filename.slice(0, -added.length); + // An extension the ES module resolver does not try (`.es6`, `.es`) keeps its file. + const index = SOURCE_EXTENSIONS.indexOf(added); + return index === -1 + ? undefined + : SOURCE_EXTENSIONS.slice(0, index) + .map((extension) => stem + extension) + .find(isFile); } /** - * The TypeScript files a relative or absolute request for compiled output may name. + * The files a relative or absolute request for compiled output may name, from `table`. * * @param {string} request + * @param {Record} table * @returns {string[]} */ -function typeScriptSources(request) { +function sourcesFor(request, table) { if (!PATH_REQUEST.test(request)) { return []; } const extension = /\.[cm]?jsx?$/.exec(request)?.[0]; - const sources = extension && TYPESCRIPT_SOURCES[extension]; + const sources = extension && table[extension]; return sources ? sources.map((source) => request.slice(0, -extension.length) + source) : []; } diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts new file mode 100644 index 00000000..a3b96cbd --- /dev/null +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -0,0 +1,188 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, describe, expect, test } from "vitest"; + +/** + * In a `"type": "commonjs"` package whose tsconfig `module` emits CommonJS, a file with + * ES module syntax is compiled to CommonJS, as `tsc` compiles it (#905). Its imports + * become `require()` calls, which Node.js' CommonJS resolution and the + * `Module._resolveFilename` patch in `register.mjs` resolve, instead of oxc-node's + * own resolver that resolved them while the file ran as an ES module. Compiling it + * must not change which file an import finds, or what a namespace import holds. + */ +const CORE = dirname(fileURLToPath(new URL("../../core/register.mjs", import.meta.url))); + +const roots: string[] = []; + +afterAll(() => { + for (const root of roots) { + rmSync(root, { force: true, recursive: true }); + } +}); + +const COMMONJS = JSON.stringify({ name: "fx", private: true, type: "commonjs" }); +const NODENEXT = { module: "nodenext" }; + +function fixture( + files: Record, + compilerOptions: Record = NODENEXT, +): string { + const root = mkdtempSync(join(tmpdir(), "oxc-node-commonjs-lowering-")); + roots.push(root); + writeFileSync(join(root, "package.json"), COMMONJS); + writeFileSync(join(root, "tsconfig.json"), JSON.stringify({ compilerOptions })); + for (const [name, contents] of Object.entries(files)) { + const path = join(root, name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); + } + mkdirSync(join(root, "node_modules", "@oxc-node"), { recursive: true }); + symlinkSync( + CORE, + join(root, "node_modules", "@oxc-node", "core"), + // `junction` is the only link type Windows allows without elevated privileges. + process.platform === "win32" ? "junction" : "dir", + ); + return root; +} + +function run(root: string, entry: string): string { + const result = spawnSync(process.execPath, ["--import", "@oxc-node/core/register", entry], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + NODE_OPTIONS: undefined, + OXC_LOG: undefined, + OXC_TRANSFORM_ALL: undefined, + TS_NODE_PROJECT: undefined, + OXC_TSCONFIG_PATH: undefined, + }, + timeout: 30_000, + }); + const output = `${result.stdout}${result.stderr}`; + expect(result.error, result.error?.message).toBeFalsy(); + expect(result.status, output).toBe(0); + return output; +} + +describe("an ES module compiled to CommonJS", () => { + test("resolves an import through a tsconfig `paths` alias", () => { + // As an ES module, the import went through oxc-node's resolver, which applies the + // tsconfig's `paths`. `require("@lib/value")` must find the same file. + const root = fixture( + { + "lib/value.ts": "export const value: number = 42;\n", + "main.ts": 'import { value } from "@lib/value";\nconsole.log("value:", value);\n', + }, + { ...NODENEXT, paths: { "@lib/*": ["./lib/*"] } }, + ); + expect(run(root, "./main.ts")).toContain("value: 42"); + }); + + test("resolves an import through a tsconfig `paths` alias ahead of an installed package", () => { + // oxc-node's resolver, `tsc` and tsx all try `paths` before `node_modules`. + const root = fixture( + { + "node_modules/pkg/package.json": JSON.stringify({ name: "pkg", main: "index.js" }), + "node_modules/pkg/index.js": 'exports.from = "package";\n', + "shim/pkg.ts": 'export const from: string = "alias";\n', + "main.ts": 'import { from } from "pkg";\nconsole.log("from:", from);\n', + }, + { ...NODENEXT, paths: { pkg: ["./shim/pkg.ts"] } }, + ); + expect(run(root, "./main.ts")).toContain("from: alias"); + }); + + test("resolves an extensionless import to `config.ts` beside `config.json`", () => { + // Node.js' CommonJS resolution tries `.json` before the extensions oxc-node + // registers. oxc-node's resolver tries TypeScript first, and so does what `tsc` + // emits: `config.js`, which Node.js tries before `config.json`. + const root = fixture({ + "config.ts": 'export const source: string = "ts";\n', + "config.json": JSON.stringify({ source: "json" }), + "main.ts": 'import { source } from "./config";\nconsole.log("source:", source);\n', + }); + expect(run(root, "./main.ts")).toContain("source: ts"); + }); + + test.each([ + ["`./component.js` to `component.jsx`", "component.jsx", "./component.js"], + ["`./component.jsx` to `component.js`", "component.js", "./component.jsx"], + ])("resolves %s when only it exists", (_what, file, specifier) => { + // oxc-node's resolver maps `.js` to `.ts`, `.tsx`, `.js` and `.jsx`, and `.jsx` + // to `.tsx`, `.ts`, `.jsx` and `.js` (#840). + const root = fixture( + { + [file]: 'export const name = "component";\n', + "main.ts": `import { name } from ${JSON.stringify(specifier)};\nconsole.log("name:", name);\n`, + }, + { ...NODENEXT, allowJs: true }, + ); + expect(run(root, "./main.ts")).toContain("name: component"); + }); + + test("resolves `./component.js` to `component.js` beside `component.jsx`", () => { + // `.jsx` is a fallback for a missing `.js` file, not a source that wins over it. + const root = fixture( + { + "component.js": 'exports.name = "js";\n', + "component.jsx": 'export const name = "jsx";\n', + "main.ts": 'import { name } from "./component.js";\nconsole.log("name:", name);\n', + }, + { ...NODENEXT, allowJs: true }, + ); + expect(run(root, "./main.ts")).toContain("name: js"); + }); + + test("keeps a CommonJS module's non-enumerable exports in a namespace import", () => { + // `cjs-module-lexer` detects `Object.defineProperty(exports, "value", { value })`, + // so an ES module's namespace import has `value`, enumerable or not. `tsc`'s + // `__importStar` copies every own property name too. + const root = fixture({ + "legacy.cjs": [ + 'Object.defineProperty(exports, "value", { value: 42 });', + "exports.visible = 1;", + ].join("\n"), + "main.ts": [ + 'import * as legacy from "./legacy.cjs";', + 'console.log("value:", legacy.value, "visible:", legacy.visible);', + ].join("\n"), + }); + expect(run(root, "./main.ts")).toContain("value: 42 visible: 1"); + }); +}); + +describe("a TypeScript file in a CommonJS package", () => { + test("resolves a `require()` through a tsconfig `paths` alias", () => { + // `require()` and an import compiled to it cannot be told apart, and `tsc` resolves + // `import x = require()` through `paths` too. + const root = fixture( + { + "lib/value.ts": "export const value: number = 42;\n", + "main.ts": 'const { value } = require("@lib/value");\nconsole.log("value:", value);\n', + }, + { ...NODENEXT, paths: { "@lib/*": ["./lib/*"] } }, + ); + expect(run(root, "./main.ts")).toContain("value: 42"); + }); + + test.each([ + ["the path `require.resolve()` returns", 'require.resolve("pkg")'], + ["a relative path into `node_modules`", '"./node_modules/pkg/lib.js"'], + ])("runs a package's compiled file when it requires %s", (_what, request) => { + // A package that ships `lib.ts` beside its compiled `lib.js` runs `lib.js`, as the + // ES module resolver keeps it. Node.js refuses to strip the types of a `.ts` file + // inside `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). + const root = fixture({ + "node_modules/pkg/package.json": JSON.stringify({ name: "pkg", main: "lib.js" }), + "node_modules/pkg/lib.js": 'exports.from = "js";\n', + "node_modules/pkg/lib.ts": 'export const from: string = "ts";\n', + "main.ts": `const lib = require(${request});\nconsole.log("from:", lib.from);\n`, + }); + expect(run(root, "./main.ts")).toContain("from: js"); + }); +}); diff --git a/src/lib.rs b/src/lib.rs index 0a7422a4..73418aea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2771,12 +2771,14 @@ const COMMONJS_RESERVED: [&str; 8] = [ /// `tsc`'s helpers, as it emits them without `importHelpers`. A module is taken as-is /// when it is a transpiled ES module (`__esModule`) or a real one (`require()` of an ES -/// module returns its namespace). +/// module returns its namespace). A namespace import copies every own property name, +/// enumerable or not, as `tsc`'s `__importStar` does: an ES module's namespace import of +/// a CommonJS module has an `Object.defineProperty(exports, "a", { value })` export too. const IMPORT_DEFAULT_HELPER: &str = "var __importDefault = function (mod) { return mod && \ (mod.__esModule || mod[Symbol.toStringTag] === \"Module\") ? mod : { default: mod }; };"; const IMPORT_STAR_HELPER: &str = "var __importStar = function (mod) { if (mod && (mod.__esModule \ || mod[Symbol.toStringTag] === \"Module\")) return mod; var result = {}; if (mod != null) \ - for (const k in mod) if (k !== \"default\" && Object.prototype.hasOwnProperty.call(mod, k)) \ + for (const k of Object.getOwnPropertyNames(mod)) if (k !== \"default\") \ Object.defineProperty(result, k, { enumerable: true, get: function () { return mod[k]; } }); \ result.default = mod; return result; };"; const EXPORT_STAR_HELPER: &str = "var __exportStar = function (m, exports) { for (const p in m) \ @@ -3342,6 +3344,70 @@ impl FromNapiValue for OxcResolveOptions { } } +/// The files a tsconfig `paths` alias or `baseUrl` maps a bare `require()` request to, +/// in the order to try them. Not checked on disk: `register.mjs` resolves each one with +/// Node.js' CommonJS resolution, ahead of the request itself. +/// +/// An import resolves through [`create_resolve`], which applies the importer's tsconfig +/// itself, but `require()` stays on Node.js' own CommonJS resolution, which knows +/// nothing of tsconfig. Without these, an ES module compiled to CommonJS loses the +/// aliases it resolved as an ES module (#905). +/// +/// The config is the one [`create_resolve`] applies to `parent` +/// ([`TsconfigSource::for_importer`]), and as there, nothing maps a request that is not +/// bare ([`is_bare_specifier`]) or a request from inside `node_modules`. `cwd` is only +/// read when this is the loader context's first resolution. +#[cfg_attr(feature = "napi", napi)] +pub fn tsconfig_path_candidates( + env: Env, + cwd: String, + parent: String, + specifier: String, +) -> Result> { + let _environment = LoaderEnvironmentGuard::from_env(&env)?; + Ok(path_alias_candidates(PathBuf::from(cwd), Path::new(&parent), &specifier) + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect()) +} + +fn path_alias_candidates(cwd: PathBuf, parent: &Path, specifier: &str) -> Vec { + if !is_bare_specifier(specifier) + || parent.components().any(|component| component.as_os_str() == "node_modules") + { + return Vec::new(); + } + let context = loader_context(); + let (resolver, tsconfig_source) = + context.resolver_and_tsconfig.get_or_init(|| init_resolver(cwd)); + let Some(tsconfig) = tsconfig_source.for_importer(resolver, parent, specifier) else { + return Vec::new(); + }; + // A config found by discovery is already the referenced project that claims the + // file. An explicitly named one maps the aliases of the project it references that + // holds the file, as `oxc_resolver` does for it. + let project = match tsconfig_source { + TsconfigSource::Manual(_) => tsconfig + .references_resolved + .iter() + .find(|project| parent.starts_with(project.directory())) + .unwrap_or(&tsconfig), + TsconfigSource::Auto => &tsconfig, + }; + let mut candidates = project.resolve_path_alias_or_base_url(specifier); + // That adds `baseUrl` only when no alias matches. The resolver also tries it when + // an alias's files are all missing. + if let Some(base_url) = tsconfig.compiler_options.base_url.as_deref() + && base_url.is_absolute() + { + let path = base_url.join(specifier); + if !candidates.contains(&path) { + candidates.push(path); + } + } + candidates +} + #[cfg_attr(feature = "napi", napi)] #[cfg_attr(not(target_family = "wasm"), allow(unused_variables))] #[allow(clippy::type_complexity)] From 19c19cd4b39eabfcaeb9499ac79339f1e766c67b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 19:26:02 +0800 Subject: [PATCH 03/23] fix(core): match tsc's CommonJS output in import cycles - Reassigned locals, decorated classes and namespaces are `exports.x` itself, as tsc emits them: live, writable, and `undefined` rather than a TDZ error when an import cycle reads them early. Re-exports are getters defined after their `require()`. - An anonymous default function is hoisted as `default_1` and exported up front. - A side-effect import is a bare `require()`. - Only value bindings named like the CommonJS wrapper or the helpers keep a file an ES module; types, ambient declarations and type-only imports do not. - Tests for cycles, a class's statics through a namespace import, `paths` and the `.jsx` fallback in a compiled file. - README: the output's shape, cycles, snapshots for ES importers, the `require` condition, the per-directory `require()` cache, and known limits. Co-Authored-By: Claude Opus 5.5 --- README.md | 57 ++- .../integrate-commonjs/src/alias/value.ts | 1 + .../integrate-commonjs/src/commonjs.test.ts | 44 ++ packages/integrate-commonjs/src/cycle-c.ts | 28 ++ packages/integrate-commonjs/src/cycle-d.ts | 10 + packages/integrate-commonjs/src/klass.cjs | 5 + packages/integrate-commonjs/src/klass.d.cts | 7 + packages/integrate-commonjs/src/widget.d.ts | 1 + packages/integrate-commonjs/src/widget.jsx | 1 + packages/integrate-commonjs/tsconfig.json | 5 +- src/lib.rs | 418 ++++++++++++++++-- 11 files changed, 519 insertions(+), 58 deletions(-) create mode 100644 packages/integrate-commonjs/src/alias/value.ts create mode 100644 packages/integrate-commonjs/src/cycle-c.ts create mode 100644 packages/integrate-commonjs/src/cycle-d.ts create mode 100644 packages/integrate-commonjs/src/klass.cjs create mode 100644 packages/integrate-commonjs/src/klass.d.cts create mode 100644 packages/integrate-commonjs/src/widget.d.ts create mode 100644 packages/integrate-commonjs/src/widget.jsx diff --git a/README.md b/README.md index 66d8658d..18794a9c 100644 --- a/README.md +++ b/README.md @@ -365,17 +365,25 @@ extension rewriting. A `.ts`, `.tsx`, `.js` or `.jsx` file with `import`/`export` syntax in a `"type": "commonjs"` package is compiled to CommonJS when its tsconfig's `module` is `commonjs`, `node16`, `node18`, `node20` or `nodenext`, as `tsc` compiles it. -The output takes `tsc`'s shape: an export of a local that is never reassigned is a -plain, writable property of `exports`, so `mock.method(namespace, "name")` replaces -it for every importer, as it does under `tsc`. Re-exports and reassigned locals -stay live through getters. +The output takes `tsc`'s shape. An exported local is a plain, writable property of +`exports`, so `mock.method(namespace, "name")` replaces it for every CommonJS +importer, as it does under `tsc`. A local that is never reassigned is assigned once +it is declared; a reassigned one is `exports.name` itself, so it stays live. A +re-export, and a reassigned local exported under two names, declared by +destructuring or set to an anonymous function, stays live through a getter +instead, which a test cannot replace. In an import cycle, an export that has not +been set yet reads as `undefined`, as under `tsc`; function declarations are +exported before the file runs. A file's imports run before the rest of it, as in +an ES module. The package must say `"type": "commonjs"`: `tsc` also emits CommonJS for a package without a `"type"`, but there such a file runs as an ES module, as before, so a project relying on Node.js' syntax detection keeps working. A file also stays an ES module when it uses `import.meta` or top-level await, which have no CommonJS -form, or declares a name the CommonJS wrapper takes (`exports`, `require`, -`module`, `__filename`, `__dirname`). Outside that combination — no `"type"`, +form, or declares a value named like one the CommonJS wrapper or the compiled +output uses (`exports`, `require`, `module`, `__filename`, `__dirname`, +`__importDefault`, `__importStar`, `__exportStar`); a type of that name is fine. +Removing `"type": "commonjs"` turns the compilation off. Outside that combination — no `"type"`, `"type": "module"`, or an ES `module` setting — a file with module syntax runs as an ES module, as before. A `.cts` file is not compiled this way; see [Programmatic transformation](#programmatic-transformation). @@ -398,12 +406,33 @@ CommonJS module: as default is `lib.default`. TypeScript types the import the same way under `nodenext`. Earlier releases ran the file as an ES module, whose default import was its default export. - -The compiled file's relative `require()` calls resolve as its imports did: -TypeScript first, by the file's own name or by its output's, so `./lib.js` and -`./lib` find `lib.ts`, also when a compiled `lib.js` sits next to it, and -`./lib.mjs` finds `lib.mts`. The relative `require()` calls of every TypeScript -file outside `node_modules` resolve this way. +- Its imports are a snapshot taken when the file finishes loading, as for any + CommonJS module: a later write to an exported `let` does not reach them. + Earlier releases kept them live. + +The compiled file's imports are `require()` calls. They resolve as its imports +did, with two differences: + +- A dependency resolves with the `require` condition, as under `tsc`, so a package + whose `exports` offers only `import` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`. + An ES `module` setting or `"type": "module"` keeps such a file an ES module. +- Node.js caches a relative `require()` per directory and request. When a + JavaScript file `require()`s `./lib.js` before a TypeScript file in the same + directory does, both get `lib.js`, even next to `lib.ts`; when the TypeScript + file asks first, both get `lib.ts`. + +Otherwise TypeScript comes first, by the file's own name or by its output's: +`./lib.js` and `./lib` find `lib.ts`, also when a compiled `lib.js` sits next to +it, `./lib.mjs` finds `lib.mts`, and `./config` finds `config.ts` before +`config.json`. When no `lib.js` exists, `./lib.js` finds `lib.jsx` too. tsconfig +`paths` and `baseUrl` are tried before `node_modules`. The `require()` calls of +every TypeScript file outside `node_modules` resolve this way; a file a package +ships keeps its compiled `.js`. + +Stack traces report the source's lines; for a file Node.js loads through the +module hooks — the entry point, or one an ES module imports — a column can be a +character off. Error messages and `Function#toString` show the compiled names of +imports, such as `__vite_ssr_import_0__`. Importing an ES module — an `.mts` or `.mjs` file, or a `"type": "module"` package — from the compiled file is `require()` of an ES module. Node.js loads the @@ -468,8 +497,8 @@ The standalone transformer emits CommonJS. The registered loader preserves the module format selected for ESM files. A `.cts` file is CommonJS, so it cannot use ES module syntax (`import`, `export`, -or `import.meta`): oxc does not compile it to CommonJS the way `tsc` and `tsx` do, -and the loader reports an error instead. Rename such a file to `.mts`, or write it +or `import.meta`): oxc-node does not compile a `.cts` file's module syntax to +CommonJS the way `tsc` and `tsx` do, and the loader reports an error instead. Rename such a file to `.mts`, or write it with `import x = require()` and `export =`. Type-only imports and exports are fine. ## Importing TypeScript from a tool diff --git a/packages/integrate-commonjs/src/alias/value.ts b/packages/integrate-commonjs/src/alias/value.ts new file mode 100644 index 00000000..0ed83817 --- /dev/null +++ b/packages/integrate-commonjs/src/alias/value.ts @@ -0,0 +1 @@ +export const aliased = "aliased"; diff --git a/packages/integrate-commonjs/src/commonjs.test.ts b/packages/integrate-commonjs/src/commonjs.test.ts index 123c24d2..a4ff5a9d 100644 --- a/packages/integrate-commonjs/src/commonjs.test.ts +++ b/packages/integrate-commonjs/src/commonjs.test.ts @@ -4,11 +4,15 @@ import test from "node:test"; import legacyDefault, { legacy } from "./legacy.cjs"; import Greeter, * as lib from "./lib"; import { answer, doubled, time } from "./app"; +import { aliased } from "@alias/value"; import { readB, seenDuringLoad } from "./cycle-a"; +import * as cycle from "./cycle-c"; +import * as Klass from "./klass.cjs"; import { compute, esmValue } from "./output-names"; import { where as shadowedByOutputName } from "./shadowed.js"; import { where as shadowedExtensionless } from "./shadowed"; import { fail } from "./throws"; +import { widget } from "./widget.js"; // `tsc` compiles ES module syntax in a `"type": "commonjs"` package to CommonJS when // tsconfig's `module` is `nodenext`, and so does oxc-node. @@ -98,6 +102,46 @@ void test("an import cycle sees what tsc's output sees", () => { assert.equal(readB(), "b"); }); +// `cycle-d` reads `cycle-c` while it is loading: a reassigned `let`, a decorated class, a +// namespace and a re-export are `undefined` then, as under `tsc`, not a TDZ error, and +// the anonymous default function is declared already. +void test("an import cycle reads what is not set yet as undefined", () => { + assert.deepEqual(cycle.seen, { + counter: undefined, + service: undefined, + settings: undefined, + reexported: undefined, + anonymous: "anonymous default", + }); + assert.equal(cycle.reexported, "more"); + assert.equal(cycle.Settings.level, 1); +}); + +// A reassigned `let` and a decorated class are plain data properties, as `tsc` emits +// them: live for every importer, and writable, so a test can replace them. +void test("a reassigned export stays live and can be replaced", () => { + assert.equal(cycle.counter, 0); + assert.equal(cycle.bump(), 1); + assert.equal(cycle.counter, 1); + for (const name of ["counter", "Service", "Settings"]) { + assert.equal(Object.getOwnPropertyDescriptor(cycle, name)?.writable, true, name); + } +}); + +void test("a namespace import of a CommonJS class keeps its static members", () => { + assert.equal(Klass.create(), "created"); +}); + +// With `allowJs`, `tsc` resolves `./widget.js` to `widget.jsx`, and `require()` finds it +// when no `widget.js` exists. +void test("an import named by its output finds a JSX file", () => { + assert.equal(widget, "widget.jsx"); +}); + +void test("a tsconfig `paths` alias resolves in a file compiled to CommonJS", () => { + assert.equal(aliased, "aliased"); +}); + void test("a stack trace reports the TypeScript file's own lines", () => { assert.throws(fail, (error: Error) => /throws\.ts:5:9\)/.test(error.stack ?? "")); }); diff --git a/packages/integrate-commonjs/src/cycle-c.ts b/packages/integrate-commonjs/src/cycle-c.ts new file mode 100644 index 00000000..8f1647ce --- /dev/null +++ b/packages/integrate-commonjs/src/cycle-c.ts @@ -0,0 +1,28 @@ +// `cycle-d` imports this module back while it is still loading. Under `tsc`, what it +// reads then is `undefined`, apart from the function declarations; none of it throws. +import { snapshot } from "./cycle-d"; + +export let counter = 0; + +export function bump(): number { + return ++counter; +} + +function tag(target: unknown): void { + void target; +} + +@tag +export class Service {} + +export namespace Settings { + export const level = 1; +} + +export { more as reexported } from "./more"; + +export default function () { + return "anonymous default"; +} + +export const seen = snapshot; diff --git a/packages/integrate-commonjs/src/cycle-d.ts b/packages/integrate-commonjs/src/cycle-d.ts new file mode 100644 index 00000000..5f8fb0cb --- /dev/null +++ b/packages/integrate-commonjs/src/cycle-d.ts @@ -0,0 +1,10 @@ +import anonymous, * as c from "./cycle-c"; + +// Read while `cycle-c`, which is imported first, is still loading. +export const snapshot = { + counter: c.counter, + service: c.Service, + settings: c.Settings, + reexported: c.reexported, + anonymous: anonymous(), +}; diff --git a/packages/integrate-commonjs/src/klass.cjs b/packages/integrate-commonjs/src/klass.cjs new file mode 100644 index 00000000..1429d5c9 --- /dev/null +++ b/packages/integrate-commonjs/src/klass.cjs @@ -0,0 +1,5 @@ +module.exports = class Klass { + static create() { + return "created"; + } +}; diff --git a/packages/integrate-commonjs/src/klass.d.cts b/packages/integrate-commonjs/src/klass.d.cts new file mode 100644 index 00000000..c5a1d96f --- /dev/null +++ b/packages/integrate-commonjs/src/klass.d.cts @@ -0,0 +1,7 @@ +// A class at runtime; typed as a function with a namespace, which a namespace import +// of `export =` allows. +declare function Klass(): void; +declare namespace Klass { + function create(): string; +} +export = Klass; diff --git a/packages/integrate-commonjs/src/widget.d.ts b/packages/integrate-commonjs/src/widget.d.ts new file mode 100644 index 00000000..d6aa3520 --- /dev/null +++ b/packages/integrate-commonjs/src/widget.d.ts @@ -0,0 +1 @@ +export declare const widget: string; diff --git a/packages/integrate-commonjs/src/widget.jsx b/packages/integrate-commonjs/src/widget.jsx new file mode 100644 index 00000000..9059f82c --- /dev/null +++ b/packages/integrate-commonjs/src/widget.jsx @@ -0,0 +1 @@ +module.exports = { widget: "widget.jsx" }; diff --git a/packages/integrate-commonjs/tsconfig.json b/packages/integrate-commonjs/tsconfig.json index 85241bef..a2137da2 100644 --- a/packages/integrate-commonjs/tsconfig.json +++ b/packages/integrate-commonjs/tsconfig.json @@ -4,7 +4,10 @@ "module": "nodenext", "strict": true, "experimentalDecorators": true, - "types": ["node"] + "types": ["node"], + "paths": { + "@alias/*": ["./src/alias/*"] + } }, "include": ["src"] } diff --git a/src/lib.rs b/src/lib.rs index 73418aea..1fb6b2a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,11 +18,13 @@ use oxc::{ allocator::{Allocator, CloneIn, TakeIn}, ast::{ ast::{ - Argument, ArrowFunctionExpression, AwaitExpression, Class, Declaration, Decorator, - Directive, ExportDefaultDeclarationKind, Expression, ForOfStatement, - IdentifierReference, ImportDeclarationSpecifier, ObjectPropertyKind, Program, - Statement, StringLiteral, TSExternalModuleDeclaration, TSGlobalDeclaration, - TSModuleReference, TSNamespaceDeclaration, TemplateLiteral, VariableDeclaration, + Argument, ArrowFunctionExpression, AssignmentTarget, AssignmentTargetMaybeDefault, + AssignmentTargetProperty, AwaitExpression, CallExpression, Class, Declaration, + Decorator, Directive, ExportDefaultDeclarationKind, Expression, ForOfStatement, + FunctionType, IdentifierReference, ImportDeclarationSpecifier, ObjectProperty, + ObjectPropertyKind, Program, SimpleAssignmentTarget, Statement, StringLiteral, + TSExternalModuleDeclaration, TSGlobalDeclaration, TSModuleReference, + TSNamespaceDeclaration, TemplateLiteral, VariableDeclaration, }, builder::AstBuilder, }, @@ -32,7 +34,7 @@ use oxc::{ Diagnostics, GraphicalReportHandler, GraphicalTheme, NamedSource, OxcDiagnostic, }, parser::{Parser, ParserReturn}, - semantic::SemanticBuilder, + semantic::{Scoping, SemanticBuilder, SymbolId}, span::{SPAN, SourceType, Span}, syntax::{module_record::ModuleRecord, scope::ScopeFlags}, transformer::{ @@ -2198,8 +2200,9 @@ fn cts_uses_import_meta(source: &str, source_type: SourceType) -> bool { /// The error for a `.cts` file that uses ECMAScript module syntax. /// /// tsc (`module: nodenext`) and tsx compile a `.cts` file's `import`/`export` to -/// CommonJS, but oxc has no ESM-to-CommonJS module transform: the module transform only -/// rewrites TypeScript's `import =` / `export =`. The declarations would reach Node.js +/// CommonJS, but oxc's module transform only rewrites TypeScript's `import =` / +/// `export =`, and `lower_runner_to_commonjs` covers `.ts`, `.tsx`, `.js` and `.jsx` +/// files in a `"type": "commonjs"` package only. The declarations would reach Node.js /// intact inside a CommonJS file, which fails differently on every load path — /// `ERR_REQUIRE_CYCLE_MODULE` as an entry point, no named exports when imported, and /// `require is not defined in ES module scope` from the injected helpers when @@ -2789,9 +2792,11 @@ const EXPORT_STAR_HELPER: &str = "var __exportStar = function (m, exports) { for /// running as an ES module: a file whose package is `"type": "commonjs"` and whose /// tsconfig `module` emits CommonJS for such a package. `tsc` also emits CommonJS for a /// package without a `"type"`, but there the file keeps running as an ES module, as -/// Node.js' syntax detection runs it. The caller checks the extension is an ambiguous one. A module that cannot be CommonJS stays an -/// ES module: one using `import.meta` or top-level await, or declaring a name the -/// CommonJS wrapper or its helpers take (see [`COMMONJS_RESERVED`]). +/// Node.js' syntax detection runs it. The caller checks the extension is an ambiguous +/// one. A module that cannot be CommonJS stays an ES module: one using `import.meta` or +/// top-level await, or declaring a value named as the CommonJS wrapper's parameters or +/// its helpers are (see [`COMMONJS_RESERVED`]). A `declare`d or type-only declaration +/// of such a name is erased, so it does not count. fn lowers_to_commonjs( path: &Path, compiler_options: Option<&CompilerOptions>, @@ -2807,11 +2812,16 @@ fn lowers_to_commonjs( }); emits_commonjs && module_record.import_metas.is_empty() - && !has_top_level_await(program) && matches!(package_type(path), Some(PackageType::CommonJs)) + && !has_top_level_await(program) && { let scoping = SemanticBuilder::new().build(program).semantic.into_scoping(); - COMMONJS_RESERVED.iter().all(|&name| scoping.get_root_binding(name.into()).is_none()) + COMMONJS_RESERVED.iter().all(|&name| { + scoping.get_root_binding(name.into()).is_none_or(|symbol| { + let flags = scoping.symbol_flags(symbol); + flags.is_ambient() || flags.is_type_import() || !flags.is_value() + }) + }) } } @@ -2984,40 +2994,112 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< .map_or(0, |symbol| scoping.get_resolved_references(symbol).count()) }; + // `export default function () {}` reaches here as `const __vite_ssr_export_default__ = + // function () {}`. `tsc` declares it as `function default_1() {}`, hoisted, so an + // import cycle can call it before this file runs that far. + let default_function = program.body.iter().enumerate().skip(hoisted).find_map(|(at, s)| { + let Statement::VariableDeclaration(declaration) = s else { return None }; + let [declarator] = declaration.declarations.as_slice() else { return None }; + (declarator.id.get_binding_identifier()?.name == SSR_EXPORT_DEFAULT + && matches!(&declarator.init, Some(Expression::FunctionExpression(f)) if f.id.is_none())) + .then_some(at) + }); + let default_name = default_function.map(|at| { + declared_at.insert(SSR_EXPORT_DEFAULT, (at, true)); + (1..) + .map(|n| format!("default_{n}")) + .find(|name| scoping.get_root_binding(name.as_str().into()).is_none()) + .expect("an unused name") + }); + + // Top-level `let`/`var` bindings that are reassigned can become `exports.name` itself, + // as `tsc` compiles an exported `let`: declared by a plain identifier, exported under + // one name, and not a function or class whose name would come from the binding. + let mut export_names: HashMap<&str, usize> = HashMap::new(); + for statement in &program.body[..hoisted] { + if let Some((_, Expression::Identifier(local))) = hoisted_export(statement) { + *export_names.entry(local.name.as_str()).or_default() += 1; + } + } + let mut plain_declarations: HashMap<&str, bool> = HashMap::new(); + for statement in program.body.iter().skip(hoisted) { + let Statement::VariableDeclaration(declaration) = statement else { continue }; + for declarator in &declaration.declarations { + let plain = declarator.id.get_binding_identifier().is_some() + && !declarator + .init + .as_ref() + .is_some_and(Expression::is_anonymous_function_definition); + for ident in declarator.id.get_binding_identifiers() { + let entry = plain_declarations.entry(ident.name.as_str()).or_insert(true); + *entry &= plain; + } + } + } + let mut prelude = String::from("\"use strict\";\n"); prelude.push_str("Object.defineProperty(exports, \"__esModule\", { value: true });\n"); let mut cleared = Vec::new(); let mut assigned_after: HashMap = HashMap::new(); let mut getters = String::new(); let mut kept = Vec::new(); + let mut members: HashMap = HashMap::new(); for (index, statement) in program.body[..hoisted].iter().enumerate() { let (name, value) = hoisted_export(statement).expect("counted above"); let member = exports_member(name); - let settled = match value { - Expression::Identifier(local) => scoping - .get_root_binding(local.name.as_str().into()) - .filter(|&symbol| { - !scoping.get_resolved_references(symbol).any(|reference| reference.is_write()) - }) - .and(declared_at.get(local.name.as_str())) - .map(|&(at, hoisted_function)| (local.name.as_str(), at, hoisted_function)), + let local = match value { + Expression::Identifier(local) => Some(local.name.as_str()), _ => None, }; - match (settled, export_getter_source(value)) { - (Some((local, _, true)), _) => { + let symbol = local.and_then(|local| scoping.get_root_binding(local.into())); + let written = symbol.is_some_and(|symbol| { + scoping.get_resolved_references(symbol).any(|reference| reference.is_write()) + }); + let declared = local.and_then(|local| declared_at.get(local).copied()); + match (local, declared, symbol) { + (Some(local), Some((_, true)), _) if !written => { + let local = if local == SSR_EXPORT_DEFAULT { + default_name.as_deref().unwrap_or(local) + } else { + local + }; prelude.push_str(&format!("{member} = {local};\n")); } - (Some((local, at, false)), _) => { + (Some(local), Some((at, false)), _) if !written => { cleared.push(member.clone()); assigned_after.entry(at).or_default().push_str(&format!("{member} = {local};\n")); } - (None, Some(getter)) => getters.push_str(&format!( - "Object.defineProperty(exports, {}, {{ enumerable: true, get: function () {{ \ - return {getter}; }} }});\n", - serde_json::Value::from(name) - )), - // Nothing the runner emits today; keep its own definition. - (None, None) => kept.push(index), + (Some(local), Some((_, false)), Some(symbol)) + if export_names.get(local) == Some(&1) + && plain_declarations.get(local) == Some(&true) => + { + cleared.push(member.clone()); + members.insert(symbol, member); + } + _ => match export_getter_source(value) { + Some(getter) => { + let getter = format!( + "Object.defineProperty(exports, {}, {{ enumerable: true, get: function () \ + {{ return {getter}; }} }});\n", + serde_json::Value::from(name) + ); + // Defined once what it reads is initialized, as `tsc` places a + // re-export after its `require()`: an import cycle that reads it earlier + // gets `undefined`, not a TDZ error. + let anchor = getter_object(value) + .and_then(|object| declared_at.get(object)) + .filter(|&&(_, hoisted_function)| !hoisted_function); + match anchor { + Some(&(at, _)) => { + cleared.push(member); + assigned_after.entry(at).or_default().push_str(&getter); + } + None => getters.push_str(&getter), + } + } + // Nothing the runner emits today; keep its own definition. + None => kept.push(index), + }, } } if !cleared.is_empty() { @@ -3071,6 +3153,9 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< let Some(&(specifier, ref names)) = imports.get(name.name.as_str()) else { continue }; let replacement = if inlined.contains(name.name.as_str()) { String::new() + } else if reference_count(name.name.as_str()) == 0 { + // A side-effect import, as `tsc` emits it: no binding, no helper. + format!("require({});\n", serde_json::Value::from(specifier)) } else { format!("const {} = {};\n", name.name, require(specifier, names.as_deref())) }; @@ -3101,7 +3186,23 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< } match replacements.remove(&index) { Some(replacement) => body.extend(parse_generated(allocator, &replacement).1), - None => body.push(statement), + None if Some(index) == default_function => { + body.push(default_function_declaration( + allocator, + statement, + default_name.as_deref().expect("named with the function"), + )); + } + None => match statement { + Statement::VariableDeclaration(declaration) + if declaration.declarations.iter().any(|declarator| { + binding_symbol(declarator).is_some_and(|s| members.contains_key(&s)) + }) => + { + body.extend(exported_declarations(allocator, declaration.unbox(), &members)); + } + statement => body.push(statement), + }, } if let Some(assignments) = assigned_after.remove(&index) { body.extend(parse_generated(allocator, &assignments).1); @@ -3111,9 +3212,214 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< if !program.directives.iter().any(|directive| directive.directive == "use strict") { program.directives.extend(directives); } + if !members.is_empty() { + ExportedBindings { allocator, scoping: &scoping, members: &members }.visit_program(program); + } + if let Some(default_name) = &default_name { + RenameDefault { name: allocator.alloc_str(default_name) }.visit_program(program); + } Rewrite { ast: AstBuilder::new(allocator) }.visit_program(program); } +const SSR_EXPORT_DEFAULT: &str = "__vite_ssr_export_default__"; + +/// The binding a getter result reads: a local, or the module object of an import. +fn getter_object<'s>(expression: &'s Expression<'_>) -> Option<&'s str> { + match expression { + Expression::Identifier(ident) => Some(ident.name.as_str()), + Expression::StaticMemberExpression(member) => match &member.object { + Expression::Identifier(object) => Some(object.name.as_str()), + _ => None, + }, + Expression::ComputedMemberExpression(member) => match &member.object { + Expression::Identifier(object) => Some(object.name.as_str()), + _ => None, + }, + _ => None, + } +} + +fn binding_symbol(declarator: &oxc::ast::ast::VariableDeclarator<'_>) -> Option { + declarator.id.get_binding_identifier()?.symbol_id.get() +} + +/// One generated expression, without mappings. +fn generated_expression<'a>(allocator: &'a Allocator, source: &str) -> Expression<'a> { + let (_, mut body) = parse_generated(allocator, &format!("{source};")); + match body.pop() { + Some(Statement::ExpressionStatement(statement)) => statement.unbox().expression, + _ => unreachable!("{source} is an expression"), + } +} + +/// `const __vite_ssr_export_default__ = function () {}` as `function default_1() {}`. +fn default_function_declaration<'a>( + allocator: &'a Allocator, + statement: Statement<'a>, + name: &str, +) -> Statement<'a> { + let Statement::VariableDeclaration(declaration) = statement else { unreachable!() }; + let mut declarator = declaration.unbox().declarations.into_iter().next().expect("one"); + let Some(Expression::FunctionExpression(mut function)) = declarator.init.take() else { + unreachable!("checked by the caller") + }; + let (_, mut named) = parse_generated(allocator, &format!("function {name}() {{}}")); + let Some(Statement::FunctionDeclaration(mut named)) = named.pop() else { unreachable!() }; + function.r#type = FunctionType::FunctionDeclaration; + function.id = named.id.take(); + Statement::FunctionDeclaration(function) +} + +/// A top-level declaration with bindings that become `exports.name`, in order: each such +/// declarator is an assignment to its export (or nothing, without an initializer), and the +/// others keep a declaration of their own. +fn exported_declarations<'a>( + allocator: &'a Allocator, + declaration: VariableDeclaration<'a>, + members: &HashMap, +) -> Vec> { + let kind = declaration.kind.as_str(); + let mut statements = Vec::new(); + for mut declarator in declaration.declarations { + match binding_symbol(&declarator).and_then(|symbol| members.get(&symbol)) { + Some(member) => { + let Some(init) = declarator.init.take() else { continue }; + let (_, mut assignment) = parse_generated(allocator, &format!("{member} = 0;")); + let Some(Statement::ExpressionStatement(statement)) = assignment.last_mut() else { + unreachable!() + }; + let Expression::AssignmentExpression(assignment_expression) = + &mut statement.expression + else { + unreachable!() + }; + assignment_expression.right = init; + statements.extend(assignment); + } + None => { + let (_, mut kept) = parse_generated(allocator, &format!("{kind} binding;")); + let Some(Statement::VariableDeclaration(kept_declaration)) = kept.last_mut() else { + unreachable!() + }; + kept_declaration.declarations.clear(); + kept_declaration.declarations.push(declarator); + statements.extend(kept); + } + } + } + statements +} + +/// Reads and writes of a binding that became `exports.name` go through `exports.name`. +struct ExportedBindings<'s, 'a> { + allocator: &'a Allocator, + scoping: &'s Scoping, + members: &'s HashMap, +} + +impl ExportedBindings<'_, '_> { + fn member(&self, ident: &IdentifierReference<'_>) -> Option<&str> { + let symbol = self.scoping.get_reference(ident.reference_id.get()?).symbol_id()?; + self.members.get(&symbol).map(String::as_str) + } +} + +impl<'a> VisitMut<'a> for ExportedBindings<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if let Expression::Identifier(ident) = expression + && let Some(member) = self.member(ident) + { + *expression = generated_expression(self.allocator, member); + return; + } + walk_mut::walk_expression(self, expression); + } + + fn visit_call_expression(&mut self, call: &mut CallExpression<'a>) { + // `(0, exports.f)()`, as `tsc` calls it: no `this` from `exports`. + if let Expression::Identifier(ident) = &call.callee + && let Some(member) = self.member(ident) + { + call.callee = generated_expression(self.allocator, &format!("(0, {member})")); + } + walk_mut::walk_call_expression(self, call); + } + + fn visit_simple_assignment_target(&mut self, target: &mut SimpleAssignmentTarget<'a>) { + if let SimpleAssignmentTarget::AssignmentTargetIdentifier(ident) = target + && let Some(member) = self.member(ident) + { + let Expression::AssignmentExpression(assignment) = + generated_expression(self.allocator, &format!("{member} = 0")) + else { + unreachable!() + }; + *target = assignment.unbox().left.into_simple_assignment_target(); + return; + } + walk_mut::walk_simple_assignment_target(self, target); + } + + fn visit_assignment_target_property(&mut self, property: &mut AssignmentTargetProperty<'a>) { + // `({ a } = value)` as `({ a: exports.a } = value)`. + if let AssignmentTargetProperty::AssignmentTargetPropertyIdentifier(shorthand) = property + && let Some(member) = self.member(&shorthand.binding) + { + let key = shorthand.binding.name.as_str(); + let init = shorthand.init.take(); + let source = if init.is_some() { + format!("({{ {key}: {member} = 0 }} = 0)") + } else { + format!("({{ {key}: {member} }} = 0)") + }; + let Expression::ParenthesizedExpression(parenthesized) = + generated_expression(self.allocator, &source) + else { + unreachable!() + }; + let Expression::AssignmentExpression(assignment) = parenthesized.unbox().expression + else { + unreachable!() + }; + let AssignmentTarget::ObjectAssignmentTarget(object) = assignment.unbox().left else { + unreachable!() + }; + let mut replacement = + object.unbox().properties.into_iter().next().expect("one property"); + if let (Some(init), AssignmentTargetProperty::AssignmentTargetPropertyProperty(renamed)) = + (init, &mut replacement) + && let AssignmentTargetMaybeDefault::AssignmentTargetWithDefault(with_default) = + &mut renamed.binding + { + with_default.init = init; + } + *property = replacement; + } + walk_mut::walk_assignment_target_property(self, property); + } + + fn visit_object_property(&mut self, property: &mut ObjectProperty<'a>) { + walk_mut::walk_object_property(self, property); + // `{ a }` read as `{ a: exports.a }`. + if property.shorthand && !matches!(property.value, Expression::Identifier(_)) { + property.shorthand = false; + } + } +} + +/// References to the default export's binding name its hoisted declaration instead. +struct RenameDefault<'a> { + name: &'a str, +} + +impl<'a> VisitMut<'a> for RenameDefault<'a> { + fn visit_identifier_reference(&mut self, ident: &mut IdentifierReference<'a>) { + if ident.name == SSR_EXPORT_DEFAULT { + ident.name = self.name.into(); + } + } +} + /// The runner transform drops the default binding of `import a, * as b from "x"`, so /// the declaration is split first: `import a from "x"; import * as b from "x";`. fn split_namespace_imports<'a>(allocator: &'a Allocator, program: &mut Program<'a>) { @@ -5638,7 +5944,7 @@ mod tests { assert!(lowered(&code), "{code}"); assert!(code.contains("exports.compute = void 0;"), "{code}"); assert!(code.contains("exports.compute = compute;"), "{code}"); - assert!(!code.contains("get"), "{code}"); + assert!(!code.contains("get: function"), "{code}"); let code = run( "cjs/forms.ts", @@ -5654,21 +5960,47 @@ mod tests { assert!(code.contains("= __importStar(require(\"./b\"))"), "{code}"); assert!(code.contains("= require(\"./c\")"), "{code}"); assert!(code.contains("__exportStar(require(\"./e\"), exports)"), "{code}"); - // Re-exports and reassigned locals stay live through getters, without - // `configurable`, which `cjs-module-lexer` would not detect. - for export in ["\"r\"", "\"counter\""] { - assert!( - code.contains(&format!( - "Object.defineProperty(exports, {export}, {{ enumerable: true, get: function() {{" - )), - "{code}" - ); - } + // A re-export stays live through a getter, without `configurable`, which + // `cjs-module-lexer` would not detect, defined after its `require()`. + let getter = "Object.defineProperty(exports, \"r\", { enumerable: true, get: function() {"; + assert!(code.find(getter) > code.find("require(\"./g\")"), "{code}"); + // A reassigned `let` becomes `exports.counter` itself, as `tsc` compiles it: live, + // writable, and `undefined` rather than a TDZ error before it runs. + assert!(code.contains("exports.counter = 0;"), "{code}"); + assert!(code.contains("exports.counter++"), "{code}"); + assert!(!code.contains("let counter"), "{code}"); // A hoisted function is exported up front; `import()` is CommonJS' own. assert!(code.contains("exports.bump = bump;"), "{code}"); assert!(code.contains("import(\"./dyn\")"), "{code}"); assert!(code.contains("exports.default = "), "{code}"); + // A side-effect import is a bare `require()`, as `tsc` emits it. + let code = run("cjs/side.ts", "import './b';\nexport const n = 1;\n"); + assert!(lowered(&code) && code.contains("require(\"./b\");"), "{code}"); + assert!(!code.contains("= require(\"./b\")"), "{code}"); + + // An anonymous default function is hoisted under a name and exported up front, + // as `tsc` emits `exports.default = default_1` before the body runs. + let code = run("cjs/anonymous.ts", "export const n = 1;\nexport default function () {}\n"); + assert!(lowered(&code), "{code}"); + assert!(code.contains("function default_1()"), "{code}"); + assert!(code.contains("exports.default = default_1;"), "{code}"); + assert!(code.find("exports.default = default_1;") < code.find("exports.n = n;"), "{code}"); + + // Only a value binding clashes with the CommonJS wrapper: a type, an ambient + // declaration or a type-only import of the same name is erased and lowers. + for (name, source) in [ + ("cjs/ambient.ts", "declare const require: any;\nexport const n = 1;\n"), + ("cjs/type.ts", "type exports = number;\nexport const n: exports = 1;\n"), + ( + "cjs/type-import.ts", + "import type * as module from 'node:path';\nexport const n = 1;\n", + ), + ] { + let code = run(name, source); + assert!(lowered(&code) && code.contains("exports.n = n;"), "{name}: {code}"); + } + // Every `module` that `tsc` compiles to CommonJS for a `"type": "commonjs"` package. for dir in ["commonjs", "node16", "node18", "node20", "cased"] { let code = run(&format!("{dir}/lib.ts"), "export const n = 1;\n"); From c135b3d23f0c45f79772d66019bab90267cb0282 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 19:43:54 +0800 Subject: [PATCH 04/23] fix(core): export each declarator in turn, and call tags without a receiver - `export const a = 1, b = f();` assigns `exports.a` before `f()` runs, as tsc emits it, so an import cycle sees `a`. - A reassigned export used as a template tag is called as `(0, exports.tag)`, as tsc calls it, so it gets no `this`. - `require(".")` and `require("..")` are relative requests, so a TypeScript importer finds `index.ts` before a compiled `index.js` next to it, as for `./` and `../`. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 3 +- .../integrate-commonjs/src/barrel/index.js | 2 + .../integrate-commonjs/src/barrel/index.ts | 1 + .../src/barrel/inner/use.ts | 3 + .../integrate-commonjs/src/commonjs.test.ts | 15 +++++ .../integrate-commonjs/src/declarators.ts | 15 +++++ src/lib.rs | 55 ++++++++++++++++++- 7 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 packages/integrate-commonjs/src/barrel/index.js create mode 100644 packages/integrate-commonjs/src/barrel/index.ts create mode 100644 packages/integrate-commonjs/src/barrel/inner/use.ts create mode 100644 packages/integrate-commonjs/src/declarators.ts diff --git a/packages/core/register.mjs b/packages/core/register.mjs index cbac9610..d4cf3a6c 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -249,7 +249,8 @@ const SOURCE_EXTENSIONS = [ ".node", ]; -const PATH_REQUEST = /^(?:\.{1,2}[\\/]|[\\/]|[a-zA-Z]:[\\/])/; +// `.` and `..` themselves are relative too: `require("..")` names the parent directory. +const PATH_REQUEST = /^(?:\.{1,2}(?:[\\/]|$)|[\\/]|[a-zA-Z]:[\\/])/; /** * @param {string} path diff --git a/packages/integrate-commonjs/src/barrel/index.js b/packages/integrate-commonjs/src/barrel/index.js new file mode 100644 index 00000000..12fd18b4 --- /dev/null +++ b/packages/integrate-commonjs/src/barrel/index.js @@ -0,0 +1,2 @@ +// Stale output of `index.ts`, which a TypeScript importer resolves first. +exports.where = "barrel/index.js"; diff --git a/packages/integrate-commonjs/src/barrel/index.ts b/packages/integrate-commonjs/src/barrel/index.ts new file mode 100644 index 00000000..de1dd6e3 --- /dev/null +++ b/packages/integrate-commonjs/src/barrel/index.ts @@ -0,0 +1 @@ +export const where = "barrel/index.ts"; diff --git a/packages/integrate-commonjs/src/barrel/inner/use.ts b/packages/integrate-commonjs/src/barrel/inner/use.ts new file mode 100644 index 00000000..203ec9be --- /dev/null +++ b/packages/integrate-commonjs/src/barrel/inner/use.ts @@ -0,0 +1,3 @@ +import { where } from ".."; + +export const parent = where; diff --git a/packages/integrate-commonjs/src/commonjs.test.ts b/packages/integrate-commonjs/src/commonjs.test.ts index a4ff5a9d..4ef08eee 100644 --- a/packages/integrate-commonjs/src/commonjs.test.ts +++ b/packages/integrate-commonjs/src/commonjs.test.ts @@ -6,7 +6,9 @@ import Greeter, * as lib from "./lib"; import { answer, doubled, time } from "./app"; import { aliased } from "@alias/value"; import { readB, seenDuringLoad } from "./cycle-a"; +import { parent } from "./barrel/inner/use"; import * as cycle from "./cycle-c"; +import { second, tagged } from "./declarators"; import * as Klass from "./klass.cjs"; import { compute, esmValue } from "./output-names"; import { where as shadowedByOutputName } from "./shadowed.js"; @@ -69,6 +71,19 @@ void test("a TypeScript file is found before compiled output next to it", () => assert.equal(shadowedExtensionless, "shadowed.ts"); }); +void test("`..` finds the TypeScript file before compiled output next to it", () => { + assert.equal(parent, "barrel/index.ts"); +}); + +void test("an import cycle sees an export from an earlier declarator", () => { + assert.equal(second, 1); +}); + +// `tsc` calls a tag as `(0, exports.tag)`, without `exports` as `this`. +void test("a reassigned export used as a template tag gets no receiver", () => { + assert.equal(tagged, "unbound"); +}); + void test("an ES module importer gets the named exports", async () => { const esm = await import("./esm-importer.mjs"); assert.equal(esm.compute(1), 2); diff --git a/packages/integrate-commonjs/src/declarators.ts b/packages/integrate-commonjs/src/declarators.ts new file mode 100644 index 00000000..51e4fb7b --- /dev/null +++ b/packages/integrate-commonjs/src/declarators.ts @@ -0,0 +1,15 @@ +import * as self from "./declarators"; + +// `tsc` exports `first` before `second`'s initializer runs. +export const first = 1, + second = self.first; + +function initial(_strings: TemplateStringsArray): string { + return "initial"; +} + +export let tag = initial; +tag = function (this: unknown, _strings: TemplateStringsArray): string { + return this === undefined ? "unbound" : "bound"; +}; +export const tagged = tag`x`; diff --git a/src/lib.rs b/src/lib.rs index 1fb6b2a7..fa1b9464 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ use oxc::{ FunctionType, IdentifierReference, ImportDeclarationSpecifier, ObjectProperty, ObjectPropertyKind, Program, SimpleAssignmentTarget, Statement, StringLiteral, TSExternalModuleDeclaration, TSGlobalDeclaration, TSModuleReference, - TSNamespaceDeclaration, TemplateLiteral, VariableDeclaration, + TSNamespaceDeclaration, TaggedTemplateExpression, TemplateLiteral, VariableDeclaration, }, builder::AstBuilder, }, @@ -2929,8 +2929,9 @@ fn top_level_bindings<'s>(statement: &'s Statement<'_>) -> (Vec<&'s str>, bool) /// * An export of a local that is never reassigned is a plain data property, assigned /// after its declaration (`exports.a = void 0; ...; exports.a = a;`). It is writable and /// configurable, so `mock.method(namespace, "a")` can replace it and every importer, -/// which reads it as `lib.a` at the call, sees the mock. A reassigned local and a -/// re-export keep a getter, which is what keeps them live. +/// which reads it as `lib.a` at the call, sees the mock. A reassigned local is +/// `exports.a` itself, as `tsc` emits it, so it stays live and writable; a re-export +/// keeps a getter. /// * A named import is `require()`'s result itself; a default or namespace import goes /// through `__importDefault` / `__importStar`, `export *` through `__exportStar`. /// * Every export is spelled the way `cjs-module-lexer` detects, so an ES module @@ -2969,6 +2970,32 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< } } + // `export const a = 1, b = f();` has `a` exported before `f()` runs, as `tsc` emits + // `exports.a = 1, exports.b = f()`: one declaration per declarator gives each export + // its own place right after it. + let original = program.body.take_in(&allocator); + program.body.reserve(original.len()); + for statement in original { + match statement { + Statement::VariableDeclaration(mut declaration) + if declaration.declarations.len() > 1 && !declaration.kind.is_using() => + { + let (span, kind) = (declaration.span, declaration.kind); + for declarator in declaration.declarations.take_in(&allocator) { + let declarations = oxc::allocator::Vec::from_array_in([declarator], &allocator); + program.body.push(Statement::new_variable_declaration( + span, + kind, + declarations, + false, + &AstBuilder::new(allocator), + )); + } + } + statement => program.body.push(statement), + } + } + let scoping = SemanticBuilder::new().build(program).semantic.into_scoping(); let hoisted = program.body.iter().take_while(|s| hoisted_export(s).is_some()).count(); @@ -3345,6 +3372,16 @@ impl<'a> VisitMut<'a> for ExportedBindings<'_, 'a> { walk_mut::walk_call_expression(self, call); } + fn visit_tagged_template_expression(&mut self, tagged: &mut TaggedTemplateExpression<'a>) { + // A tag gets no `this` from `exports` either. + if let Expression::Identifier(ident) = &tagged.tag + && let Some(member) = self.member(ident) + { + tagged.tag = generated_expression(self.allocator, &format!("(0, {member})")); + } + walk_mut::walk_tagged_template_expression(self, tagged); + } + fn visit_simple_assignment_target(&mut self, target: &mut SimpleAssignmentTarget<'a>) { if let SimpleAssignmentTarget::AssignmentTargetIdentifier(ident) = target && let Some(member) = self.member(ident) @@ -5974,6 +6011,18 @@ mod tests { assert!(code.contains("import(\"./dyn\")"), "{code}"); assert!(code.contains("exports.default = "), "{code}"); + // Each declarator's export is assigned before the next initializer runs, and a + // rewritten tag gets no `this`, as `tsc` emits them. + let code = run( + "cjs/declarators.ts", + "export const a = 1, b = a + 1;\nfunction t() {}\nexport let tag = t;\ntag = t;\n\ + export const s = tag`x`;\n", + ); + assert!(lowered(&code), "{code}"); + assert!(code.contains("exports.a = a;"), "{code}"); + assert!(code.find("exports.a = a;") < code.find("b = a + 1"), "{code}"); + assert!(code.contains("(0, exports.tag)`x`"), "{code}"); + // A side-effect import is a bare `require()`, as `tsc` emits it. let code = run("cjs/side.ts", "import './b';\nexport const n = 1;\n"); assert!(lowered(&code) && code.contains("require(\"./b\");"), "{code}"); From 4406e93e110d870c7d55405c53cb41d54f9e67b6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 20:37:53 +0800 Subject: [PATCH 05/23] fix(core): resolve mapped, extensionless and symlinked requires as the ES module did - A package `imports` entry or a self-reference through `exports` that names missing compiled output (`#value` -> `./value.js`, only `value.ts` on disk) finds its source through the native resolver, with the `require` condition. - A `.js` file compiled from an ES module resolves an extensionless request TypeScript first, as it did as an ES module. It is told apart from hand-written CommonJS by `exports.__esModule`, which its first statement sets. - A source chosen over compiled output goes back through Node.js' resolution, so a symlinked source gets its real path and loads once. - test: the `--kill-timeout=300` watch test no longer expects the app's and oxnode's lines in one order, which two processes cannot promise. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- packages/core/register.mjs | 82 ++++++++++++++++++- .../__tests__/commonjs-lowering.spec.ts | 56 +++++++++++++ .../__tests__/watch-cli.spec.ts | 5 +- 4 files changed, 143 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 18794a9c..a538fe1c 100644 --- a/README.md +++ b/README.md @@ -425,9 +425,11 @@ Otherwise TypeScript comes first, by the file's own name or by its output's: `./lib.js` and `./lib` find `lib.ts`, also when a compiled `lib.js` sits next to it, `./lib.mjs` finds `lib.mts`, and `./config` finds `config.ts` before `config.json`. When no `lib.js` exists, `./lib.js` finds `lib.jsx` too. tsconfig -`paths` and `baseUrl` are tried before `node_modules`. The `require()` calls of -every TypeScript file outside `node_modules` resolve this way; a file a package -ships keeps its compiled `.js`. +`paths` and `baseUrl` are tried before `node_modules`, and a package `imports` +entry or a self-reference that names missing output finds its source. The +`require()` calls of every TypeScript file outside `node_modules` resolve this +way, and so do the extensionless ones of a `.js` file compiled from an ES module; +a file a package ships keeps its compiled `.js`. Stack traces report the source's lines; for a file Node.js loads through the module hooks — the entry point, or one an ES module imports — a column can be a diff --git a/packages/core/register.mjs b/packages/core/register.mjs index d4cf3a6c..ac529c5a 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { statSync } from "node:fs"; import * as NodeModule from "node:module"; import { basename } from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { isMainThread } from "node:worker_threads"; import { addHook } from "pirates"; @@ -200,6 +200,15 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o // Not this one either; the original error is the one to report. } } + // A package `imports` entry (`#value`) or a self-reference through `exports` names + // compiled output too, which Node.js looks for only after mapping the request. + // The ES module resolver maps it and then tries the source, so it is asked here. + if (mapsThroughTsconfig(request, parent)) { + const mapped = resolveMapped(request, parent.filename); + if (mapped !== undefined) { + return mapped; + } + } } throw error; } @@ -207,11 +216,20 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o // it (#840), where Node.js' CommonJS resolution finds another file first: the output // beside its source — compiled in place, or stale — that `./lib.js` and `./lib` both // name, or `config.json` beside `config.ts` for `./config`. A package's files stay - // what it compiled, as they do for the ES module resolver. + // what it compiled, as they do for the ES module resolver. A JavaScript file compiled + // from an ES module resolves an extensionless request the same way, as it did as an + // ES module; one that names its file keeps it. + let source; if (resolvesSourceFirst(request, parent) && !inNodeModules(resolved)) { - return sourceBeside(resolved) ?? extensionlessSource(request, resolved) ?? resolved; + source = sourceBeside(resolved) ?? extensionlessSource(request, resolved); + } else if (compiledFromEsm(request, parent) && !inNodeModules(resolved)) { + source = extensionlessSource(request, resolved); } - return resolved; + // Node.js' own resolution gives the source its real path, as it gave `resolved`, so a + // symlinked source is one module whichever name reaches it. + return source === undefined + ? resolved + : resolveFilename.call(this, source, parent, isMain, options); }; // The files the ES module resolver tries, for a TypeScript importer, ahead of the @@ -305,6 +323,62 @@ function resolvesSourceFirst(request, parent) { ); } +/** + * Whether a JavaScript `parent` was compiled to CommonJS from an ES module, and asks for + * a relative or absolute `request`. Its first statement marks `exports` with + * `__esModule`, before any `require()`, as `tsc`'s output does. + * + * @param {string} request + * @param {{ filename?: string | null, exports?: unknown } | null | undefined} parent + * @returns {boolean} + */ +function compiledFromEsm(request, parent) { + const filename = parent?.filename; + const exports = parent?.exports; + return ( + typeof filename === "string" && + filename.endsWith(".js") && + !inNodeModules(filename) && + PATH_REQUEST.test(request) && + exports != null && + /** @type {{ __esModule?: unknown }} */ (exports).__esModule === true + ); +} + +/** + * The file the ES module resolver finds for a bare `request` from `filename`, with the + * `require` condition, when Node.js' CommonJS resolution found none. + * + * @param {string} request + * @param {string} filename + * @returns {string | undefined} + */ +function resolveMapped(request, filename) { + try { + const { url } = /** @type {{ url: string }} */ ( + createResolve( + { getCurrentDirectory: () => process.cwd() }, + request, + { + conditions: ["node", "require"], + importAttributes: {}, + parentURL: pathToFileURL(filename).href, + }, + // Called with what the request maps to, or with the request itself. + (url) => ({ url }), + ) + ); + return typeof url === "string" && url.startsWith("file:") ? fileURLToPath(url) : undefined; + } catch { + return undefined; + } finally { + // Resolving reads the package.json and tsconfig it maps through. + if (watch) { + flush(watch, takeWatchDependencies); + } + } +} + /** * The TypeScript file a resolved JavaScript file may be compiled from, when it exists. * diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index a3b96cbd..cac00989 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -185,4 +185,60 @@ describe("a TypeScript file in a CommonJS package", () => { }); expect(run(root, "./main.ts")).toContain("from: js"); }); + + test.each([ + ["a package `imports` entry", "#value", { imports: { "#value": "./value.js" } }], + ["a self-reference through `exports`", "fx/value", { exports: { "./value": "./value.js" } }], + ])("resolves %s that names compiled output to its source", (_what, specifier, fields) => { + // Node.js maps the request to `./value.js` and stops there; the ES module resolver + // tried `value.ts` after mapping it. + const root = fixture({ + "package.json": JSON.stringify({ ...JSON.parse(COMMONJS), ...fields }), + "value.ts": 'export const value: string = "ts";\n', + "main.ts": `import { value } from ${JSON.stringify(specifier)};\nconsole.log("value:", value);\n`, + }); + expect(run(root, "./main.ts")).toContain("value: ts"); + }); + + test("resolves a compiled JavaScript file's extensionless import TypeScript first", () => { + // As an ES module, `main.js` found `dep.ts` before `dep.js` beside it. + const root = fixture( + { + "dep.js": 'exports.name = "js";\n', + "dep.ts": 'export const name: string = "ts";\n', + "main.js": 'import { name } from "./dep";\nconsole.log("name:", name);\n', + }, + { ...NODENEXT, allowJs: true }, + ); + expect(run(root, "./main.js")).toContain("name: ts"); + }); + + test("keeps a hand-written CommonJS file's extensionless require() on Node.js' order", () => { + const root = fixture( + { + "dep.js": 'exports.name = "js";\n', + "dep.ts": 'export const name: string = "ts";\n', + "main.js": 'console.log("name:", require("./dep").name);\n', + }, + { ...NODENEXT, allowJs: true }, + ); + expect(run(root, "./main.js")).toContain("name: js"); + }); + + // Windows lets only elevated processes create file symlinks. + test.skipIf(process.platform === "win32")( + "loads a symlinked source once, whichever name reaches it", + () => { + const root = fixture({ + "real.ts": "export const object = {};\n", + "alias.js": "exports.object = {};\n", + "main.ts": + 'import { object as viaAlias } from "./alias.js";\n' + + 'import { object as viaReal } from "./real.ts";\n' + + 'console.log("same:", viaAlias === viaReal);\n', + }); + symlinkSync("real.ts", join(root, "alias.ts")); + expect(run(root, "./main.ts")).toContain("same: true"); + }, + ); }); diff --git a/packages/integrate-vitest/__tests__/watch-cli.spec.ts b/packages/integrate-vitest/__tests__/watch-cli.spec.ts index f52158d9..824af7bb 100644 --- a/packages/integrate-vitest/__tests__/watch-cli.spec.ts +++ b/packages/integrate-vitest/__tests__/watch-cli.spec.ts @@ -765,8 +765,11 @@ ${KEEP}`, const [, gc] = await o.waitFor(/^GC listening v1 (\d+)\n/m); await o.watched(join(root, "dep.ts")); await o.editUntil(join(root, "dep.ts"), () => value("v2"), /^GC listening v2 \d+\n/m); + // The app and oxnode write to stdout separately, so these two lines arrive in either + // order; each comes before the restart. + expect(o.stdout()).toMatch(/^GC ignored v1\n[\s\S]*^RUN v2 /m); expect(o.stdout()).toMatch( - /^GC ignored v1\n[\s\S]*\[oxnode\] still running after 300ms, force killing\n[\s\S]*^RUN v2 /m, + /\[oxnode\] still running after 300ms, force killing\n[\s\S]*^RUN v2 /m, ); expect(count(o.stdout(), /force killing/)).toBe(1); expect(await isGone(Number(gc)), "the app's server").toBe(true); From a98adf25791f372e42b9fb659a9126aefac46776 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 20:58:48 +0800 Subject: [PATCH 06/23] fix(core): keep active conditions and watch reports in the require() fallback - A package `imports` entry or self-reference that names missing output now takes the file Node.js mapped it to, which its error names, so every active condition (`--conditions=development`) applies, not a fixed list. - A relative request Node.js cannot find asks the native resolver, which reports its other candidates to watch mode: creating `barrel/index.ts` for `import "./barrel"` restarts the app again. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 53 ++++++++++++++----- .../__tests__/commonjs-lowering.spec.ts | 46 +++++++++++----- .../__tests__/watch-cli.spec.ts | 15 ++++++ 3 files changed, 88 insertions(+), 26 deletions(-) diff --git a/packages/core/register.mjs b/packages/core/register.mjs index ac529c5a..a44a221f 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { statSync } from "node:fs"; import * as NodeModule from "node:module"; -import { basename } from "node:path"; +import { basename, isAbsolute } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { isMainThread } from "node:worker_threads"; @@ -200,13 +200,26 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o // Not this one either; the original error is the one to report. } } - // A package `imports` entry (`#value`) or a self-reference through `exports` names - // compiled output too, which Node.js looks for only after mapping the request. - // The ES module resolver maps it and then tries the source, so it is asked here. - if (mapsThroughTsconfig(request, parent)) { - const mapped = resolveMapped(request, parent.filename); - if (mapped !== undefined) { - return mapped; + const filename = parent?.filename; + if (typeof filename === "string" && !inNodeModules(filename) && !isBuiltin(request)) { + const found = PATH_REQUEST.test(request) + ? // The ES module resolver's other candidates — `index.ts` in a directory, say + // — and its report of them to watch mode, so creating one restarts the app. + resolveNative(request, filename) + : // A package `imports` entry (`#value`) or a self-reference through `exports` + // names compiled output too. Node.js has mapped it, with every active + // condition, and names the file it found missing. + sourcesFor(mappedFile(error, request), MISSING_OUTPUT_SOURCES) + .map((candidate) => { + try { + return resolveFilename.call(this, candidate, parent, isMain, options); + } catch { + return undefined; + } + }) + .find((resolved) => resolved !== undefined); + if (found !== undefined) { + return found; } } } @@ -346,14 +359,28 @@ function compiledFromEsm(request, parent) { } /** - * The file the ES module resolver finds for a bare `request` from `filename`, with the - * `require` condition, when Node.js' CommonJS resolution found none. + * The missing file Node.js mapped a bare `request` to through a package's `imports` or + * `exports`, as its error names it, or `""`. A package that is not there at all is + * named by the request itself. + * + * @param {{ message?: unknown }} error + * @param {string} request + * @returns {string} + */ +function mappedFile(error, request) { + const mapped = /^Cannot find module '(.+)'/.exec(String(error.message))?.[1]; + return mapped !== undefined && mapped !== request && isAbsolute(mapped) ? mapped : ""; +} + +/** + * The file the ES module resolver finds for a relative or absolute `request` from + * `filename`, when Node.js' CommonJS resolution found none. * * @param {string} request * @param {string} filename * @returns {string | undefined} */ -function resolveMapped(request, filename) { +function resolveNative(request, filename) { try { const { url } = /** @type {{ url: string }} */ ( createResolve( @@ -364,7 +391,7 @@ function resolveMapped(request, filename) { importAttributes: {}, parentURL: pathToFileURL(filename).href, }, - // Called with what the request maps to, or with the request itself. + // Called with the file it found, or with the request itself. (url) => ({ url }), ) ); @@ -372,7 +399,7 @@ function resolveMapped(request, filename) { } catch { return undefined; } finally { - // Resolving reads the package.json and tsconfig it maps through. + // Resolving records the files it looked for, and the tsconfig it read. if (watch) { flush(watch, takeWatchDependencies); } diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index cac00989..43eba81d 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -49,20 +49,24 @@ function fixture( return root; } -function run(root: string, entry: string): string { - const result = spawnSync(process.execPath, ["--import", "@oxc-node/core/register", entry], { - cwd: root, - encoding: "utf8", - env: { - ...process.env, - NODE_OPTIONS: undefined, - OXC_LOG: undefined, - OXC_TRANSFORM_ALL: undefined, - TS_NODE_PROJECT: undefined, - OXC_TSCONFIG_PATH: undefined, +function run(root: string, entry: string, nodeArgs: string[] = []): string { + const result = spawnSync( + process.execPath, + [...nodeArgs, "--import", "@oxc-node/core/register", entry], + { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + NODE_OPTIONS: undefined, + OXC_LOG: undefined, + OXC_TRANSFORM_ALL: undefined, + TS_NODE_PROJECT: undefined, + OXC_TSCONFIG_PATH: undefined, + }, + timeout: 30_000, }, - timeout: 30_000, - }); + ); const output = `${result.stdout}${result.stderr}`; expect(result.error, result.error?.message).toBeFalsy(); expect(result.status, output).toBe(0); @@ -200,6 +204,22 @@ describe("a TypeScript file in a CommonJS package", () => { expect(run(root, "./main.ts")).toContain("value: ts"); }); + test("resolves a package `imports` entry with the active conditions", () => { + // Node.js picks `./dev.js` under `--conditions=development`, so its source is + // `dev.ts`, not the `default` target's. + const root = fixture({ + "package.json": JSON.stringify({ + ...JSON.parse(COMMONJS), + imports: { "#value": { development: "./dev.js", default: "./prod.js" } }, + }), + "dev.ts": 'export const value: string = "dev";\n', + "prod.ts": 'export const value: string = "prod";\n', + "main.ts": 'import { value } from "#value";\nconsole.log("value:", value);\n', + }); + expect(run(root, "./main.ts")).toContain("value: prod"); + expect(run(root, "./main.ts", ["--conditions=development"])).toContain("value: dev"); + }); + test("resolves a compiled JavaScript file's extensionless import TypeScript first", () => { // As an ES module, `main.js` found `dep.ts` before `dep.js` beside it. const root = fixture( diff --git a/packages/integrate-vitest/__tests__/watch-cli.spec.ts b/packages/integrate-vitest/__tests__/watch-cli.spec.ts index 824af7bb..d64d54c5 100644 --- a/packages/integrate-vitest/__tests__/watch-cli.spec.ts +++ b/packages/integrate-vitest/__tests__/watch-cli.spec.ts @@ -167,6 +167,21 @@ ${KEEP}`, await o.editUntil(join(root, "gen/deep/x.ts"), () => value("x"), runLine("later x")); }); + test("creating a missing index file an import compiled to CommonJS names", async () => { + // `require("./barrel")` from a file compiled to CommonJS reports `barrel/index.ts` + // too, as the import did when the file ran as an ES module (#905). + const root = project({ + "package.json": JSON.stringify({ private: true, type: "commonjs" }), + "tsconfig.json": JSON.stringify({ compilerOptions: { module: "nodenext" } }), + "main.ts": `import { v } from "./barrel";\nconsole.log("RUN", v, process.pid);\n${KEEP}`, + "barrel/other.ts": value("other"), + }); + const o = startOxnode(["watch", "main.ts"], root); + await o.waitFor(/\[oxnode\] exited with code 1 — waiting for changes/); + await o.watched(join(root, "barrel/index.ts")); + await o.editUntil(join(root, "barrel/index.ts"), () => value("made"), runLine("made")); + }); + test("tsconfig.json and the config it extends", async () => { const paths = (dir: string) => ({ paths: { "@lib/*": [`./${dir}/*`] } }); const root = project({ From f1709b2e4a639678af9ee22841563863fa3dff9e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:38:12 +0800 Subject: [PATCH 07/23] fix(core): export destructured bindings as they run, and find sources by the requested name - `export const [a = 1, b = self.a] = v;` becomes `[exports.a = 1, exports.b = self.a] = v;`, as tsc emits it, so each binding is exported before the next default runs. Object patterns and rest elements too; the pattern's own reads go through `exports`. - A package `imports` entry or a self-reference that maps to compiled output next to its source loads the source, as the ES module resolver did. - The source is looked for beside the name a request reaches before Node.js follows a symlink there: `./alias.js` linking to `build/real.js` finds `alias.ts`. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +- packages/core/register.mjs | 62 ++++++- .../integrate-commonjs/src/commonjs.test.ts | 3 +- .../integrate-commonjs/src/declarators.ts | 4 + .../__tests__/commonjs-lowering.spec.ts | 31 ++++ src/lib.rs | 151 +++++++++++++++++- 6 files changed, 248 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a538fe1c..fbab09f1 100644 --- a/README.md +++ b/README.md @@ -368,10 +368,11 @@ is `commonjs`, `node16`, `node18`, `node20` or `nodenext`, as `tsc` compiles it. The output takes `tsc`'s shape. An exported local is a plain, writable property of `exports`, so `mock.method(namespace, "name")` replaces it for every CommonJS importer, as it does under `tsc`. A local that is never reassigned is assigned once -it is declared; a reassigned one is `exports.name` itself, so it stays live. A -re-export, and a reassigned local exported under two names, declared by -destructuring or set to an anonymous function, stays live through a getter -instead, which a test cannot replace. In an import cycle, an export that has not +it is declared; a reassigned one is `exports.name` itself, so it stays live, and +so is each binding of an `export const [a, b] = …` pattern. A re-export, and a +reassigned local exported under two names, declared by a pattern with a binding +that is not exported, or set to an anonymous function, stays live through a +getter instead, which a test cannot replace. In an import cycle, an export that has not been set yet reads as `undefined`, as under `tsc`; function declarations are exported before the file runs. A file's imports run before the rest of it, as in an ES module. @@ -426,7 +427,9 @@ Otherwise TypeScript comes first, by the file's own name or by its output's: it, `./lib.mjs` finds `lib.mts`, and `./config` finds `config.ts` before `config.json`. When no `lib.js` exists, `./lib.js` finds `lib.jsx` too. tsconfig `paths` and `baseUrl` are tried before `node_modules`, and a package `imports` -entry or a self-reference that names missing output finds its source. The +entry or a self-reference that names output finds its source the same way. The +source is looked for beside the name a request reaches, before a symlink there is +followed. The `require()` calls of every TypeScript file outside `node_modules` resolve this way, and so do the extensionless ones of a `.js` file compiled from an ES module; a file a package ships keeps its compiled `.js`. diff --git a/packages/core/register.mjs b/packages/core/register.mjs index a44a221f..c3269cf1 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { statSync } from "node:fs"; import * as NodeModule from "node:module"; -import { basename, isAbsolute } from "node:path"; +import { basename, dirname, extname, isAbsolute, resolve as resolvePath } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { isMainThread } from "node:worker_threads"; @@ -232,11 +232,23 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o // what it compiled, as they do for the ES module resolver. A JavaScript file compiled // from an ES module resolves an extensionless request the same way, as it did as an // ES module; one that names its file keeps it. + // The output may be a symlink: Node.js has followed it, and the source sits beside + // the name the request reaches, as the ES module resolver looks for it. let source; if (resolvesSourceFirst(request, parent) && !inNodeModules(resolved)) { - source = sourceBeside(resolved) ?? extensionlessSource(request, resolved); + const named = requested(request, parent.filename, resolved); + source = + sourceBeside(named) ?? + extensionlessSource(request, named) ?? + sourceBeside(resolved) ?? + extensionlessSource(request, resolved); } else if (compiledFromEsm(request, parent) && !inNodeModules(resolved)) { - source = extensionlessSource(request, resolved); + const named = requested(request, parent.filename, resolved); + source = extensionlessSource(request, named) ?? extensionlessSource(request, resolved); + } else if (mapsToProjectFile(request, parent, resolved)) { + // A package `imports` entry or a self-reference that maps to compiled output beside + // its source: the ES module resolver tried the source first after mapping. + source = sourceBeside(resolved); } // Node.js' own resolution gives the source its real path, as it gave `resolved`, so a // symlinked source is one module whichever name reaches it. @@ -336,6 +348,50 @@ function resolvesSourceFirst(request, parent) { ); } +/** + * The file a relative or absolute `request` from `filename` names before Node.js follows + * a symlink to `resolved`: the request itself, or with the extension Node.js added to + * it. `resolved` when the request reached it some other way, such as a directory. + * + * @param {string} request + * @param {string} filename + * @param {string} resolved + * @returns {string} + */ +function requested(request, filename, resolved) { + const path = resolvePath(dirname(filename), request); + if (path === resolved) { + return resolved; + } + if (extname(path) !== "" && isFile(path)) { + return path; + } + const withExtension = path + extname(resolved); + return isFile(withExtension) ? withExtension : resolved; +} + +/** + * Whether a bare `request` from a TypeScript or JSX `parent` outside `node_modules` + * mapped to a file of the project, through a package's `imports` or `exports`. + * + * @param {string} request + * @param {{ filename?: string | null } | null | undefined} parent + * @param {string} resolved + * @returns {boolean} + */ +function mapsToProjectFile(request, parent, resolved) { + const filename = parent?.filename; + return ( + typeof filename === "string" && + /\.(?:[cm]?ts|tsx|jsx)$/.test(filename) && + !inNodeModules(filename) && + !PATH_REQUEST.test(request) && + !isBuiltin(request) && + isAbsolute(resolved) && + !inNodeModules(resolved) + ); +} + /** * Whether a JavaScript `parent` was compiled to CommonJS from an ES module, and asks for * a relative or absolute `request`. Its first statement marks `exports` with diff --git a/packages/integrate-commonjs/src/commonjs.test.ts b/packages/integrate-commonjs/src/commonjs.test.ts index 4ef08eee..bfd21f1b 100644 --- a/packages/integrate-commonjs/src/commonjs.test.ts +++ b/packages/integrate-commonjs/src/commonjs.test.ts @@ -8,7 +8,7 @@ import { aliased } from "@alias/value"; import { readB, seenDuringLoad } from "./cycle-a"; import { parent } from "./barrel/inner/use"; import * as cycle from "./cycle-c"; -import { second, tagged } from "./declarators"; +import { fourth, second, tagged } from "./declarators"; import * as Klass from "./klass.cjs"; import { compute, esmValue } from "./output-names"; import { where as shadowedByOutputName } from "./shadowed.js"; @@ -77,6 +77,7 @@ void test("`..` finds the TypeScript file before compiled output next to it", () void test("an import cycle sees an export from an earlier declarator", () => { assert.equal(second, 1); + assert.equal(fourth, 3); }); // `tsc` calls a tag as `(0, exports.tag)`, without `exports` as `this`. diff --git a/packages/integrate-commonjs/src/declarators.ts b/packages/integrate-commonjs/src/declarators.ts index 51e4fb7b..8660a8cb 100644 --- a/packages/integrate-commonjs/src/declarators.ts +++ b/packages/integrate-commonjs/src/declarators.ts @@ -4,6 +4,10 @@ import * as self from "./declarators"; export const first = 1, second = self.first; +// …and each binding of a pattern before the next default runs. +const none: number[] = []; +export const [third = 3, fourth = self.third] = none; + function initial(_strings: TemplateStringsArray): string { return "initial"; } diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index 43eba81d..881ec23b 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -204,6 +204,20 @@ describe("a TypeScript file in a CommonJS package", () => { expect(run(root, "./main.ts")).toContain("value: ts"); }); + test.each([ + ["a package `imports` entry", "#value", { imports: { "#value": "./value.js" } }], + ["a self-reference through `exports`", "fx/value", { exports: { "./value": "./value.js" } }], + ])("resolves %s to its source before compiled output beside it", (_what, specifier, fields) => { + // The ES module resolver tried `value.ts` before `value.js` after mapping it. + const root = fixture({ + "package.json": JSON.stringify({ ...JSON.parse(COMMONJS), ...fields }), + "value.js": 'exports.value = "js";\n', + "value.ts": 'export const value: string = "ts";\n', + "main.ts": `import { value } from ${JSON.stringify(specifier)};\nconsole.log("value:", value);\n`, + }); + expect(run(root, "./main.ts")).toContain("value: ts"); + }); + test("resolves a package `imports` entry with the active conditions", () => { // Node.js picks `./dev.js` under `--conditions=development`, so its source is // `dev.ts`, not the `default` target's. @@ -261,4 +275,21 @@ describe("a TypeScript file in a CommonJS package", () => { expect(run(root, "./main.ts")).toContain("same: true"); }, ); + + // Windows lets only elevated processes create file symlinks. + test.skipIf(process.platform === "win32")( + "finds the source beside a symlinked output, not beside its target", + () => { + const root = fixture({ + "build/real.js": 'exports.where = "build/real.js";\n', + "alias.ts": 'export const where: string = "alias.ts";\n', + "main.ts": + 'import { where } from "./alias.js";\n' + + 'import { where as extensionless } from "./alias";\n' + + 'console.log("where:", where, extensionless);\n', + }); + symlinkSync(join("build", "real.js"), join(root, "alias.js")); + expect(run(root, "./main.ts")).toContain("where: alias.ts alias.ts"); + }, + ); }); diff --git a/src/lib.rs b/src/lib.rs index fa1b9464..ed3fdaae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,12 +18,13 @@ use oxc::{ allocator::{Allocator, CloneIn, TakeIn}, ast::{ ast::{ - Argument, ArrowFunctionExpression, AssignmentTarget, AssignmentTargetMaybeDefault, - AssignmentTargetProperty, AwaitExpression, CallExpression, Class, Declaration, - Decorator, Directive, ExportDefaultDeclarationKind, Expression, ForOfStatement, - FunctionType, IdentifierReference, ImportDeclarationSpecifier, ObjectProperty, - ObjectPropertyKind, Program, SimpleAssignmentTarget, Statement, StringLiteral, - TSExternalModuleDeclaration, TSGlobalDeclaration, TSModuleReference, + Argument, ArrowFunctionExpression, AssignmentOperator, AssignmentTarget, + AssignmentTargetMaybeDefault, AssignmentTargetProperty, AssignmentTargetRest, + AwaitExpression, BindingPattern, BindingRestElement, CallExpression, Class, + Declaration, Decorator, Directive, ExportDefaultDeclarationKind, Expression, + ForOfStatement, FunctionType, IdentifierReference, ImportDeclarationSpecifier, + ObjectProperty, ObjectPropertyKind, Program, SimpleAssignmentTarget, Statement, + StringLiteral, TSExternalModuleDeclaration, TSGlobalDeclaration, TSModuleReference, TSNamespaceDeclaration, TaggedTemplateExpression, TemplateLiteral, VariableDeclaration, }, builder::AstBuilder, @@ -3049,9 +3050,20 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< } } let mut plain_declarations: HashMap<&str, bool> = HashMap::new(); + // `export const [a = 1, b = f()] = v;` exports `a` before `f()` runs. `tsc` makes it + // `[exports.a = 1, exports.b = f()] = v;`, which takes every binding of the pattern + // being exported, each under one name. + let mut destructured: HashSet<&str> = HashSet::new(); for statement in program.body.iter().skip(hoisted) { let Statement::VariableDeclaration(declaration) = statement else { continue }; for declarator in &declaration.declarations { + let names = declarator.id.get_binding_identifiers(); + if declarator.id.get_binding_identifier().is_none() + && declarator.init.is_some() + && names.iter().all(|ident| export_names.get(ident.name.as_str()) == Some(&1)) + { + destructured.extend(names.iter().map(|ident| ident.name.as_str())); + } let plain = declarator.id.get_binding_identifier().is_some() && !declarator .init @@ -3092,6 +3104,10 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< }; prelude.push_str(&format!("{member} = {local};\n")); } + (Some(local), Some((_, false)), Some(symbol)) if destructured.contains(local) => { + cleared.push(member.clone()); + members.insert(symbol, member); + } (Some(local), Some((at, false)), _) if !written => { cleared.push(member.clone()); assigned_after.entry(at).or_default().push_str(&format!("{member} = {local};\n")); @@ -3223,7 +3239,9 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< None => match statement { Statement::VariableDeclaration(declaration) if declaration.declarations.iter().any(|declarator| { - binding_symbol(declarator).is_some_and(|s| members.contains_key(&s)) + declarator.id.get_binding_identifiers().iter().any(|ident| { + ident.symbol_id.get().is_some_and(|s| members.contains_key(&s)) + }) }) => { body.extend(exported_declarations(allocator, declaration.unbox(), &members)); @@ -3308,6 +3326,26 @@ fn exported_declarations<'a>( let kind = declaration.kind.as_str(); let mut statements = Vec::new(); for mut declarator in declaration.declarations { + if declarator.id.get_binding_identifier().is_none() + && let Some(init) = declarator.init.take() + { + // Every binding of the pattern is a member: `[exports.a = 1] = v;`. + let builder = AstBuilder::new(allocator); + let target = pattern_target(allocator, declarator.id, members); + let assignment = Expression::new_assignment_expression( + declarator.span, + AssignmentOperator::Assign, + target, + init, + &builder, + ); + statements.push(Statement::new_expression_statement( + declarator.span, + assignment, + &builder, + )); + continue; + } match binding_symbol(&declarator).and_then(|symbol| members.get(&symbol)) { Some(member) => { let Some(init) = declarator.init.take() else { continue }; @@ -3337,6 +3375,90 @@ fn exported_declarations<'a>( statements } +/// `pattern` as an assignment target, its bindings replaced by the `exports` members +/// they became. +fn pattern_target<'a>( + allocator: &'a Allocator, + pattern: BindingPattern<'a>, + members: &HashMap, +) -> AssignmentTarget<'a> { + let builder = AstBuilder::new(allocator); + let maybe_default = |pattern: BindingPattern<'a>| match pattern { + BindingPattern::AssignmentPattern(assignment) => { + let assignment = assignment.unbox(); + AssignmentTargetMaybeDefault::new_assignment_target_with_default( + assignment.span, + pattern_target(allocator, assignment.left, members), + assignment.right, + &builder, + ) + } + pattern => AssignmentTargetMaybeDefault::from(pattern_target(allocator, pattern, members)), + }; + let rest = |rest: Option>>| { + rest.map(|rest| { + let rest = rest.unbox(); + AssignmentTargetRest::boxed( + rest.span, + pattern_target(allocator, rest.argument, members), + &builder, + ) + }) + }; + match pattern { + BindingPattern::BindingIdentifier(ident) => { + let member = ident + .symbol_id + .get() + .and_then(|symbol| members.get(&symbol)) + .expect("every binding of the pattern is a member"); + let (_, mut body) = parse_generated(allocator, &format!("{member} = 0;")); + match body.pop() { + Some(Statement::ExpressionStatement(statement)) => { + match statement.unbox().expression { + Expression::AssignmentExpression(assignment) => assignment.unbox().left, + _ => unreachable!("an assignment"), + } + } + _ => unreachable!("an expression statement"), + } + } + BindingPattern::ArrayPattern(array) => { + let array = array.unbox(); + let mut elements = + oxc::allocator::Vec::with_capacity_in(array.elements.len(), &allocator); + elements.extend(array.elements.into_iter().map(|element| element.map(maybe_default))); + AssignmentTarget::new_array_assignment_target( + array.span, + elements, + rest(array.rest), + &builder, + ) + } + BindingPattern::ObjectPattern(object) => { + let object = object.unbox(); + let mut properties = + oxc::allocator::Vec::with_capacity_in(object.properties.len(), &allocator); + properties.extend(object.properties.into_iter().map(|property| { + AssignmentTargetProperty::new_assignment_target_property_property( + property.span, + property.key, + maybe_default(property.value), + property.computed, + &builder, + ) + })); + AssignmentTarget::new_object_assignment_target( + object.span, + properties, + rest(object.rest), + &builder, + ) + } + BindingPattern::AssignmentPattern(_) => unreachable!("only inside an array or object"), + } +} + /// Reads and writes of a binding that became `exports.name` go through `exports.name`. struct ExportedBindings<'s, 'a> { allocator: &'a Allocator, @@ -6023,6 +6145,21 @@ mod tests { assert!(code.find("exports.a = a;") < code.find("b = a + 1"), "{code}"); assert!(code.contains("(0, exports.tag)`x`"), "{code}"); + // A destructuring export assigns `exports` members as the pattern runs, as `tsc` + // emits it, and the pattern's own reads go through them. + let code = run( + "cjs/patterns.ts", + "const v: number[] = [];\nexport const [a = 1, b = a] = v;\n\ + export const { c, d: [e = c], ...f } = { c: 1, d: [] as number[] };\n", + ); + assert!(lowered(&code), "{code}"); + assert!(code.contains("[exports.a = 1, exports.b = exports.a] = v;"), "{code}"); + assert!( + code.contains("({c: exports.c, d: [exports.e = exports.c], ...exports.f} ="), + "{code}" + ); + assert!(!code.contains("const [a"), "{code}"); + // A side-effect import is a bare `require()`, as `tsc` emits it. let code = run("cjs/side.ts", "import './b';\nexport const n = 1;\n"); assert!(lowered(&code) && code.contains("require(\"./b\");"), "{code}"); From 6d218b8eca2bae3600d7ae38f862e58b4f623455 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:57:46 +0800 Subject: [PATCH 08/23] fix(core): keep files that declare Object or Symbol ES modules, and try a directory's main source - The compiled output's prelude and helpers read `Object` and `Symbol`. A file that declares either at its top level would hand them its own binding before it is initialized, so such a file stays an ES module, as one declaring `require` or a helper's name does. - For `./lib` whose `package.json` `main` names missing output, the source of `main` (`src/main.ts` for `./src/main.js`) is tried before the `index.js` Node.js falls back to, as the ES module resolver tried it. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++-- packages/core/register.mjs | 40 +++++++++++++++++-- .../__tests__/commonjs-lowering.spec.ts | 12 ++++++ src/lib.rs | 14 +++++-- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index fbab09f1..c8fac8e3 100644 --- a/README.md +++ b/README.md @@ -383,10 +383,11 @@ project relying on Node.js' syntax detection keeps working. A file also stays an ES module when it uses `import.meta` or top-level await, which have no CommonJS form, or declares a value named like one the CommonJS wrapper or the compiled output uses (`exports`, `require`, `module`, `__filename`, `__dirname`, -`__importDefault`, `__importStar`, `__exportStar`); a type of that name is fine. -Removing `"type": "commonjs"` turns the compilation off. Outside that combination — no `"type"`, -`"type": "module"`, or an ES `module` setting — a file with module syntax runs as -an ES module, as before. A `.cts` file is not compiled this way; see +`__importDefault`, `__importStar`, `__exportStar`, `Object`, `Symbol`); a type of +that name is fine. Removing `"type": "commonjs"` turns the compilation off. +Outside that combination — no `"type"`, `"type": "module"`, or an ES `module` +setting — a file with module syntax runs as an ES module, as before. A `.cts` +file is not compiled this way; see [Programmatic transformation](#programmatic-transformation). With `node16`, `node18`, `node20` or `nodenext`, the package's `"type"` decides the diff --git a/packages/core/register.mjs b/packages/core/register.mjs index c3269cf1..e1ba4768 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; -import { statSync } from "node:fs"; +import { readFileSync, statSync } from "node:fs"; import * as NodeModule from "node:module"; -import { basename, dirname, extname, isAbsolute, resolve as resolvePath } from "node:path"; +import { basename, dirname, extname, isAbsolute, join, resolve as resolvePath } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { isMainThread } from "node:worker_threads"; @@ -238,13 +238,17 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o if (resolvesSourceFirst(request, parent) && !inNodeModules(resolved)) { const named = requested(request, parent.filename, resolved); source = + directoryMainSource(request, parent.filename, resolved) ?? sourceBeside(named) ?? extensionlessSource(request, named) ?? sourceBeside(resolved) ?? extensionlessSource(request, resolved); } else if (compiledFromEsm(request, parent) && !inNodeModules(resolved)) { const named = requested(request, parent.filename, resolved); - source = extensionlessSource(request, named) ?? extensionlessSource(request, resolved); + source = + directoryMainSource(request, parent.filename, resolved) ?? + extensionlessSource(request, named) ?? + extensionlessSource(request, resolved); } else if (mapsToProjectFile(request, parent, resolved)) { // A package `imports` entry or a self-reference that maps to compiled output beside // its source: the ES module resolver tried the source first after mapping. @@ -370,6 +374,36 @@ function requested(request, filename, resolved) { return isFile(withExtension) ? withExtension : resolved; } +/** + * The source of a directory's `package.json` `main` when Node.js found that output + * missing and fell back to the directory's `index` file, as it does for `./lib` with + * `"main": "./src/main.js"` and only `src/main.ts`. The ES module resolver tried the + * source of `main` before the index. + * + * @param {string} request + * @param {string} filename + * @param {string} resolved + * @returns {string | undefined} + */ +function directoryMainSource(request, filename, resolved) { + const directory = resolvePath(dirname(filename), request); + if (dirname(resolved) !== directory || !basename(resolved).startsWith("index.")) { + return undefined; + } + let main; + try { + main = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")).main; + } catch { + // No `package.json` there, or one Node.js would have rejected already. + return undefined; + } + if (typeof main !== "string" || main === "") { + return undefined; + } + const target = resolvePath(directory, main); + return isFile(target) ? undefined : sourcesFor(target, MISSING_OUTPUT_SOURCES).find(isFile); +} + /** * Whether a bare `request` from a TypeScript or JSX `parent` outside `node_modules` * mapped to a file of the project, through a package's `imports` or `exports`. diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index 881ec23b..4d85ddbe 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -218,6 +218,18 @@ describe("a TypeScript file in a CommonJS package", () => { expect(run(root, "./main.ts")).toContain("value: ts"); }); + test("resolves a directory's `main` to its source before the directory's index", () => { + // Node.js finds `main` missing and falls back to `index.js`; the ES module resolver + // tried `src/main.ts` for it first. + const root = fixture({ + "lib/package.json": JSON.stringify({ type: "commonjs", main: "./src/main.js" }), + "lib/src/main.ts": 'export const where: string = "src/main.ts";\n', + "lib/index.js": 'exports.where = "index.js";\n', + "main.ts": 'import { where } from "./lib";\nconsole.log("where:", where);\n', + }); + expect(run(root, "./main.ts")).toContain("where: src/main.ts"); + }); + test("resolves a package `imports` entry with the active conditions", () => { // Node.js picks `./dev.js` under `--conditions=development`, so its source is // `dev.ts`, not the `default` target's. diff --git a/src/lib.rs b/src/lib.rs index ed3fdaae..12fb53cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2759,10 +2759,12 @@ fn hoisted_export<'s, 'a>(statement: &'s Statement<'a>) -> Option<(&'s str, &'s const SSR_EXPORT_ALL: &str = "__vite_ssr_exportAll__"; const SSR_DYNAMIC_IMPORT: &str = "__vite_ssr_dynamic_import__"; -/// The CommonJS wrapper's parameters, and the helpers [`lower_runner_to_commonjs`] -/// declares. An ES module may declare any of them at its top level; as CommonJS that -/// would redeclare a parameter or a helper, so such a module stays an ES module. -const COMMONJS_RESERVED: [&str; 8] = [ +/// The CommonJS wrapper's parameters, the helpers [`lower_runner_to_commonjs`] +/// declares, and the globals they and its prelude read. An ES module may declare any of +/// them at its top level; as CommonJS that would redeclare a parameter or a helper, or +/// hand the helpers the module's own `Object` or `Symbol` before it is initialized, so +/// such a module stays an ES module. +const COMMONJS_RESERVED: [&str; 10] = [ "exports", "require", "module", @@ -2771,6 +2773,8 @@ const COMMONJS_RESERVED: [&str; 8] = [ "__importDefault", "__importStar", "__exportStar", + "Object", + "Symbol", ]; /// `tsc`'s helpers, as it emits them without `importHelpers`. A module is taken as-is @@ -6203,6 +6207,8 @@ mod tests { ("cjs/filename.ts", "export function __filename() {}\n"), ("cjs/dirname.ts", "export const __dirname = '.';\n"), ("cjs/helper.ts", "export class __importStar {}\n"), + ("cjs/symbol.ts", "import * as fs from 'node:fs';\nexport class Symbol {}\nvoid fs;\n"), + ("cjs/object.ts", "export const Object = 1;\n"), // `tsc` itself emits an ES module for these. ("esnext/lib.ts", "export const n = 1;\n"), ("preserve/lib.ts", "export const n = 1;\n"), From cd3b19f4eb8ec9a4d841670083ea60bc71938733 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 22:24:42 +0800 Subject: [PATCH 09/23] fix(core): resolve directory indexes and extensionless requests in the ES module order - `./barrel` that Node.js resolves to `barrel/index.json` finds `barrel/index.ts` beside it first, as the ES module resolver did. - An extensionless request takes the ES module resolver's extension order before the aliases of the extension Node.js added, so `./dep` beside `dep.ts`, `dep.tsx` and `dep.jsx` finds `dep.ts`, not `dep.tsx`. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 27 ++++++++++++++++--- .../__tests__/commonjs-lowering.spec.ts | 25 +++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/core/register.mjs b/packages/core/register.mjs index e1ba4768..9a5a2150 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -237,16 +237,20 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o let source; if (resolvesSourceFirst(request, parent) && !inNodeModules(resolved)) { const named = requested(request, parent.filename, resolved); + // An extensionless request takes the ES module resolver's extension order first; + // the aliases of the extension Node.js added come after it. source = directoryMainSource(request, parent.filename, resolved) ?? - sourceBeside(named) ?? + directoryIndexSource(request, parent.filename, resolved) ?? extensionlessSource(request, named) ?? - sourceBeside(resolved) ?? - extensionlessSource(request, resolved); + sourceBeside(named) ?? + extensionlessSource(request, resolved) ?? + sourceBeside(resolved); } else if (compiledFromEsm(request, parent) && !inNodeModules(resolved)) { const named = requested(request, parent.filename, resolved); source = directoryMainSource(request, parent.filename, resolved) ?? + directoryIndexSource(request, parent.filename, resolved) ?? extensionlessSource(request, named) ?? extensionlessSource(request, resolved); } else if (mapsToProjectFile(request, parent, resolved)) { @@ -404,6 +408,23 @@ function directoryMainSource(request, filename, resolved) { return isFile(target) ? undefined : sourcesFor(target, MISSING_OUTPUT_SOURCES).find(isFile); } +/** + * The index file the ES module resolver would find for a directory `request` that + * Node.js resolved to an index with an extension it tries later — `index.json` beside + * `index.ts`, say — when one exists. + * + * @param {string} request + * @param {string} filename + * @param {string} resolved + * @returns {string | undefined} + */ +function directoryIndexSource(request, filename, resolved) { + const directory = resolvePath(dirname(filename), request); + return dirname(resolved) === directory && basename(resolved).startsWith("index.") + ? extensionlessSource("index", join(directory, basename(resolved))) + : undefined; +} + /** * Whether a bare `request` from a TypeScript or JSX `parent` outside `node_modules` * mapped to a file of the project, through a package's `imports` or `exports`. diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index 4d85ddbe..c1ab5974 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -230,6 +230,31 @@ describe("a TypeScript file in a CommonJS package", () => { expect(run(root, "./main.ts")).toContain("where: src/main.ts"); }); + test("resolves a directory import to `index.ts` beside `index.json`", () => { + // Node.js tries `index.json` before the extensions oxc-node registers. + const root = fixture({ + "barrel/index.ts": 'export const where: string = "index.ts";\n', + "barrel/index.json": JSON.stringify({ where: "index.json" }), + "main.ts": 'import { where } from "./barrel";\nconsole.log("where:", where);\n', + }); + expect(run(root, "./main.ts")).toContain("where: index.ts"); + }); + + test("resolves an extensionless import in the ES module resolver's order", () => { + // Node.js tries the registered extensions in its own order and finds `dep.jsx`; + // the ES module resolver found `dep.ts` first. + const root = fixture( + { + "dep.ts": 'export const where: string = "dep.ts";\n', + "dep.tsx": 'export const where: string = "dep.tsx";\n', + "dep.jsx": 'export const where = "dep.jsx";\n', + "main.ts": 'import { where } from "./dep";\nconsole.log("where:", where);\n', + }, + { ...NODENEXT, allowJs: true, jsx: "react-jsx" }, + ); + expect(run(root, "./main.ts")).toMatch(/^where: dep\.ts$/m); + }); + test("resolves a package `imports` entry with the active conditions", () => { // Node.js picks `./dev.js` under `--conditions=development`, so its source is // `dev.ts`, not the `default` target's. From 60c0f7996a7cdddfc7eb4cba6072866663a63ac6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 22:40:06 +0800 Subject: [PATCH 10/23] fix(core): keep a directory's main when it names the index file `./lib` with `"main": "./index.cjs"` resolves to `index.cjs` because `main` names it, not as a fallback, so `index.ts` beside it no longer replaces it. The index is re-probed only when Node.js fell back to it. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 34 ++++++++++++++----- .../__tests__/commonjs-lowering.spec.ts | 11 ++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/core/register.mjs b/packages/core/register.mjs index 9a5a2150..88efa11e 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -394,6 +394,19 @@ function directoryMainSource(request, filename, resolved) { if (dirname(resolved) !== directory || !basename(resolved).startsWith("index.")) { return undefined; } + const target = directoryMain(directory); + return target === undefined || isFile(target) + ? undefined + : sourcesFor(target, MISSING_OUTPUT_SOURCES).find(isFile); +} + +/** + * The file a directory's `package.json` names as its `main`, if it names one. + * + * @param {string} directory + * @returns {string | undefined} + */ +function directoryMain(directory) { let main; try { main = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")).main; @@ -401,11 +414,7 @@ function directoryMainSource(request, filename, resolved) { // No `package.json` there, or one Node.js would have rejected already. return undefined; } - if (typeof main !== "string" || main === "") { - return undefined; - } - const target = resolvePath(directory, main); - return isFile(target) ? undefined : sourcesFor(target, MISSING_OUTPUT_SOURCES).find(isFile); + return typeof main === "string" && main !== "" ? resolvePath(directory, main) : undefined; } /** @@ -420,9 +429,18 @@ function directoryMainSource(request, filename, resolved) { */ function directoryIndexSource(request, filename, resolved) { const directory = resolvePath(dirname(filename), request); - return dirname(resolved) === directory && basename(resolved).startsWith("index.") - ? extensionlessSource("index", join(directory, basename(resolved))) - : undefined; + if (dirname(resolved) !== directory || !basename(resolved).startsWith("index.")) { + return undefined; + } + // A `main` that names the index file Node.js found is that file, not a fallback. + const main = directoryMain(directory); + if ( + main !== undefined && + (main === resolved || (extname(main) === "" && resolved.startsWith(`${main}.`))) + ) { + return undefined; + } + return extensionlessSource("index", join(directory, basename(resolved))); } /** diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index c1ab5974..b78e5258 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -240,6 +240,17 @@ describe("a TypeScript file in a CommonJS package", () => { expect(run(root, "./main.ts")).toContain("where: index.ts"); }); + test("keeps a directory's `main` when it names the index file", () => { + // `index.cjs` is the entry `main` names, not a fallback; `.cjs` aliases only `.cts`. + const root = fixture({ + "lib/package.json": JSON.stringify({ main: "./index.cjs" }), + "lib/index.cjs": 'exports.where = "index.cjs";\n', + "lib/index.ts": 'export const where: string = "index.ts";\n', + "main.ts": 'import { where } from "./lib";\nconsole.log("where:", where);\n', + }); + expect(run(root, "./main.ts")).toMatch(/^where: index\.cjs$/m); + }); + test("resolves an extensionless import in the ES module resolver's order", () => { // Node.js tries the registered extensions in its own order and finds `dep.jsx`; // the ES module resolver found `dep.ts` first. From 1e2ad66a78b76e403b102d05d0e54a3c31928936 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 22:59:10 +0800 Subject: [PATCH 11/23] fix(core): order a directory's extensionless main, and keep `#` in require() file names - `./lib` whose `package.json` `main` is `./src/entry` finds `entry.ts` before the `entry.json` Node.js completes it with, as the ES module resolver did. - The native fallback for a relative request Node.js cannot find skips requests with `#`, `?` or `%`: those are part of a CommonJS file name, and the ES module resolver would read a fragment, query or escape and find another file. `require("./dep.js#missing")` fails as it does under Node.js. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 24 +++++++++++++++++++ .../__tests__/commonjs-lowering.spec.ts | 23 ++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/core/register.mjs b/packages/core/register.mjs index 88efa11e..d940b51e 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -241,6 +241,7 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o // the aliases of the extension Node.js added come after it. source = directoryMainSource(request, parent.filename, resolved) ?? + directoryMainOrderSource(request, parent.filename, resolved) ?? directoryIndexSource(request, parent.filename, resolved) ?? extensionlessSource(request, named) ?? sourceBeside(named) ?? @@ -250,6 +251,7 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o const named = requested(request, parent.filename, resolved); source = directoryMainSource(request, parent.filename, resolved) ?? + directoryMainOrderSource(request, parent.filename, resolved) ?? directoryIndexSource(request, parent.filename, resolved) ?? extensionlessSource(request, named) ?? extensionlessSource(request, resolved); @@ -443,6 +445,23 @@ function directoryIndexSource(request, filename, resolved) { return extensionlessSource("index", join(directory, basename(resolved))); } +/** + * The file the ES module resolver would find for a directory whose `package.json` names + * an extensionless `main` that Node.js completed with an extension it tries later — + * `./src/entry` to `entry.json` beside `entry.ts`, say — when one exists. + * + * @param {string} request + * @param {string} filename + * @param {string} resolved + * @returns {string | undefined} + */ +function directoryMainOrderSource(request, filename, resolved) { + const main = directoryMain(resolvePath(dirname(filename), request)); + return main !== undefined && extname(main) === "" && resolved.startsWith(`${main}.`) + ? extensionlessSource(main, resolved) + : undefined; +} + /** * Whether a bare `request` from a TypeScript or JSX `parent` outside `node_modules` * mapped to a file of the project, through a package's `imports` or `exports`. @@ -510,6 +529,11 @@ function mappedFile(error, request) { * @returns {string | undefined} */ function resolveNative(request, filename) { + // `#`, `?` and `%` are part of a CommonJS file name, but the ES module resolver reads + // a fragment, a query or an escape there, and would find another file. + if (/[#?%]/.test(request)) { + return undefined; + } try { const { url } = /** @type {{ url: string }} */ ( createResolve( diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index b78e5258..a3fa760d 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -251,6 +251,29 @@ describe("a TypeScript file in a CommonJS package", () => { expect(run(root, "./main.ts")).toMatch(/^where: index\.cjs$/m); }); + test("resolves a directory's extensionless `main` in the ES module resolver's order", () => { + // Node.js completes `./src/entry` with `.json` before the extensions oxc-node + // registers; the ES module resolver found `entry.ts` first. + const root = fixture({ + "lib/package.json": JSON.stringify({ type: "commonjs", main: "./src/entry" }), + "lib/src/entry.ts": 'export const where: string = "entry.ts";\n', + "lib/src/entry.json": JSON.stringify({ where: "entry.json" }), + "main.ts": 'import { where } from "./lib";\nconsole.log("where:", where);\n', + }); + expect(run(root, "./main.ts")).toMatch(/^where: entry\.ts$/m); + }); + + test("keeps `#` in a require()'s file name, as Node.js does", () => { + // `dep.js#missing` names a file that is not there, not `dep.js` with a fragment. + const root = fixture({ + "dep.js": 'exports.where = "dep.js";\n', + "main.cts": + 'try { console.log("where:", require("./dep.js#missing").where); }\n' + + 'catch (error) { console.log("code:", (error as { code?: string }).code); }\n', + }); + expect(run(root, "./main.cts")).toMatch(/^code: MODULE_NOT_FOUND$/m); + }); + test("resolves an extensionless import in the ES module resolver's order", () => { // Node.js tries the registered extensions in its own order and finds `dep.jsx`; // the ES module resolver found `dep.ts` first. From 0219f213d1016765c008ae0c306292829561a8df Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 23:17:02 +0800 Subject: [PATCH 12/23] fix(core): keep `./foo/` on the directory, and order a directory-valued main's index - A request ending in `/` names the directory only. It is no longer taken for `foo.js` beside it, which sent `./foo/` to `foo.ts` instead of `foo/index.js`. - `./lib` whose `main` is the directory `./src` finds `src/index.ts` before the `src/index.json` Node.js picks, as the ES module resolver did. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 15 +++++++++++-- .../__tests__/commonjs-lowering.spec.ts | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/core/register.mjs b/packages/core/register.mjs index d940b51e..41d5faa5 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -369,6 +369,10 @@ function resolvesSourceFirst(request, parent) { * @returns {string} */ function requested(request, filename, resolved) { + // `./foo/` names the directory only, never a `foo.js` beside it. + if (/[\\/]$/.test(request)) { + return resolved; + } const path = resolvePath(dirname(filename), request); if (path === resolved) { return resolved; @@ -457,8 +461,15 @@ function directoryIndexSource(request, filename, resolved) { */ function directoryMainOrderSource(request, filename, resolved) { const main = directoryMain(resolvePath(dirname(filename), request)); - return main !== undefined && extname(main) === "" && resolved.startsWith(`${main}.`) - ? extensionlessSource(main, resolved) + if (main === undefined || extname(main) !== "") { + return undefined; + } + if (resolved.startsWith(`${main}.`)) { + return extensionlessSource(main, resolved); + } + // A `main` that names a directory, completed with that directory's index. + return dirname(resolved) === main && basename(resolved).startsWith("index.") + ? extensionlessSource("index", resolved) : undefined; } diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index a3fa760d..eee53e9f 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -263,6 +263,27 @@ describe("a TypeScript file in a CommonJS package", () => { expect(run(root, "./main.ts")).toMatch(/^where: entry\.ts$/m); }); + test("resolves a directory-valued `main` to its index in the ES module resolver's order", () => { + const root = fixture({ + "lib/package.json": JSON.stringify({ type: "commonjs", main: "./src" }), + "lib/src/index.ts": 'export const where: string = "src/index.ts";\n', + "lib/src/index.json": JSON.stringify({ where: "src/index.json" }), + "main.ts": 'import { where } from "./lib";\nconsole.log("where:", where);\n', + }); + expect(run(root, "./main.ts")).toMatch(/^where: src\/index\.ts$/m); + }); + + test("keeps `./foo/` on the directory, not a `foo.ts` beside it", () => { + // A trailing slash names the directory only, for Node.js and `tsc` alike. + const root = fixture({ + "foo.ts": 'export const where: string = "foo.ts";\n', + "foo.js": 'exports.where = "foo.js";\n', + "foo/index.js": 'exports.where = "foo/index.js";\n', + "main.ts": 'import { where } from "./foo/";\nconsole.log("where:", where);\n', + }); + expect(run(root, "./main.ts")).toMatch(/^where: foo\/index\.js$/m); + }); + test("keeps `#` in a require()'s file name, as Node.js does", () => { // `dep.js#missing` names a file that is not there, not `dep.js` with a fragment. const root = fixture({ From 9ac9bbd8b041883dcd1ab4fb5f2bc1e67d260cd9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 23:41:34 +0800 Subject: [PATCH 13/23] fix(core): publish a redeclared var where it is set, and use the project's baseUrl for aliases - A `var` declared more than once is `exports.x` itself, as tsc emits it, so `var x = 1; export { x }; ...; var x;` exports `x` at its initializer, not after the last, empty declaration. - With an explicitly named tsconfig that references a subproject, the `baseUrl` fallback after an alias's files are missing reads that subproject's `baseUrl`, as the alias candidates do, not the root's. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/commonjs-lowering.spec.ts | 33 ++++++++++++++++++- src/lib.rs | 20 +++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index eee53e9f..bb32c114 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -49,7 +49,12 @@ function fixture( return root; } -function run(root: string, entry: string, nodeArgs: string[] = []): string { +function run( + root: string, + entry: string, + nodeArgs: string[] = [], + env: Record = {}, +): string { const result = spawnSync( process.execPath, [...nodeArgs, "--import", "@oxc-node/core/register", entry], @@ -63,6 +68,7 @@ function run(root: string, entry: string, nodeArgs: string[] = []): string { OXC_TRANSFORM_ALL: undefined, TS_NODE_PROJECT: undefined, OXC_TSCONFIG_PATH: undefined, + ...env, }, timeout: 30_000, }, @@ -284,6 +290,31 @@ describe("a TypeScript file in a CommonJS package", () => { expect(run(root, "./main.ts")).toMatch(/^where: foo\/index\.js$/m); }); + test("falls back to the referenced project's `baseUrl` when an alias's files are missing", () => { + // An explicitly named tsconfig maps the aliases of the project it references that + // holds the file, and so takes that project's `baseUrl` too. + const root = fixture({ + "tsconfig.json": JSON.stringify({ + compilerOptions: NODENEXT, + files: [], + references: [{ path: "./app" }], + }), + "app/tsconfig.json": JSON.stringify({ + compilerOptions: { + ...NODENEXT, + composite: true, + baseUrl: ".", + paths: { "lib/*": ["./missing/*"] }, + }, + }), + "app/lib/value.ts": 'export const where: string = "app/lib/value.ts";\n', + "app/main.ts": 'import { where } from "lib/value";\nconsole.log("where:", where);\n', + }); + expect(run(root, "./app/main.ts", [], { OXC_TSCONFIG_PATH: "tsconfig.json" })).toMatch( + /^where: app\/lib\/value\.ts$/m, + ); + }); + test("keeps `#` in a require()'s file name, as Node.js does", () => { // `dep.js#missing` names a file that is not there, not `dep.js` with a fragment. const root = fixture({ diff --git a/src/lib.rs b/src/lib.rs index 12fb53cb..f3812d00 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3006,11 +3006,14 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< // Where each top-level binding is declared, and the imports by binding. let mut declared_at = HashMap::new(); + // A `var` declared more than once is written by each initializer. + let mut declarations: HashMap<&str, usize> = HashMap::new(); let mut imports = HashMap::new(); for (index, statement) in program.body.iter().enumerate().skip(hoisted) { let (names, hoisted_function) = top_level_bindings(statement); for name in names { declared_at.insert(name, (index, hoisted_function)); + *declarations.entry(name).or_default() += 1; } if let Statement::VariableDeclaration(declaration) = statement && let [declarator] = declaration.declarations.as_slice() @@ -3095,9 +3098,12 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< _ => None, }; let symbol = local.and_then(|local| scoping.get_root_binding(local.into())); + // A redeclared `var` is reassigned too: `tsc` makes it `exports.x` itself, so + // each initializer publishes it where it runs. let written = symbol.is_some_and(|symbol| { scoping.get_resolved_references(symbol).any(|reference| reference.is_write()) - }); + }) || local + .is_some_and(|local| declarations.get(local).is_some_and(|&n| n > 1)); let declared = local.and_then(|local| declared_at.get(local).copied()); match (local, declared, symbol) { (Some(local), Some((_, true)), _) if !written => { @@ -3866,7 +3872,7 @@ fn path_alias_candidates(cwd: PathBuf, parent: &Path, specifier: &str) -> Vec Date: Tue, 10 Nov 2026 23:58:10 +0800 Subject: [PATCH 14/23] fix(core): re-probe a symlinked directory's index under its real path Node.js follows `./alias` to `real/` before it picks `index.json`, so the requested directory is compared, and its `main` read, under the spelling `resolved` has: the real path, or the link with `--preserve-symlinks`. `index.ts` there now wins, as under the ES module resolver. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 42 ++++++++++++++++--- .../__tests__/commonjs-lowering.spec.ts | 16 +++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/core/register.mjs b/packages/core/register.mjs index 41d5faa5..2560d1a6 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -1,7 +1,15 @@ import { createHash } from "node:crypto"; -import { readFileSync, statSync } from "node:fs"; +import { readFileSync, realpathSync, statSync } from "node:fs"; import * as NodeModule from "node:module"; -import { basename, dirname, extname, isAbsolute, join, resolve as resolvePath } from "node:path"; +import { + basename, + dirname, + extname, + isAbsolute, + join, + resolve as resolvePath, + sep, +} from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { isMainThread } from "node:worker_threads"; @@ -396,7 +404,7 @@ function requested(request, filename, resolved) { * @returns {string | undefined} */ function directoryMainSource(request, filename, resolved) { - const directory = resolvePath(dirname(filename), request); + const directory = directoryNamed(request, filename, resolved); if (dirname(resolved) !== directory || !basename(resolved).startsWith("index.")) { return undefined; } @@ -406,6 +414,30 @@ function directoryMainSource(request, filename, resolved) { : sourcesFor(target, MISSING_OUTPUT_SOURCES).find(isFile); } +/** + * The directory a relative or absolute `request` from `filename` names, spelled as + * `resolved` spells it: its real path when Node.js followed a symlink to reach + * `resolved`, as it does unless `--preserve-symlinks` is set. + * + * @param {string} request + * @param {string} filename + * @param {string} resolved + * @returns {string} + */ +function directoryNamed(request, filename, resolved) { + const path = resolvePath(dirname(filename), request); + if (resolved.startsWith(path + sep)) { + return path; + } + try { + const real = realpathSync(path); + return resolved.startsWith(real + sep) ? real : path; + } catch { + // Not there at all; nothing below it was resolved either. + return path; + } +} + /** * The file a directory's `package.json` names as its `main`, if it names one. * @@ -434,7 +466,7 @@ function directoryMain(directory) { * @returns {string | undefined} */ function directoryIndexSource(request, filename, resolved) { - const directory = resolvePath(dirname(filename), request); + const directory = directoryNamed(request, filename, resolved); if (dirname(resolved) !== directory || !basename(resolved).startsWith("index.")) { return undefined; } @@ -460,7 +492,7 @@ function directoryIndexSource(request, filename, resolved) { * @returns {string | undefined} */ function directoryMainOrderSource(request, filename, resolved) { - const main = directoryMain(resolvePath(dirname(filename), request)); + const main = directoryMain(directoryNamed(request, filename, resolved)); if (main === undefined || extname(main) !== "") { return undefined; } diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index bb32c114..e7a27a14 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -279,6 +279,22 @@ describe("a TypeScript file in a CommonJS package", () => { expect(run(root, "./main.ts")).toMatch(/^where: src\/index\.ts$/m); }); + test("resolves a symlinked directory's index in the ES module resolver's order", () => { + // Node.js follows `alias` to `real` before it picks `index.json`. + const root = fixture({ + "real/index.ts": 'export const where: string = "index.ts";\n', + "real/index.json": JSON.stringify({ where: "index.json" }), + "main.ts": 'import { where } from "./alias";\nconsole.log("where:", where);\n', + }); + // `junction` is the only link type Windows allows without elevated privileges. + symlinkSync( + join(root, "real"), + join(root, "alias"), + process.platform === "win32" ? "junction" : "dir", + ); + expect(run(root, "./main.ts")).toMatch(/^where: index\.ts$/m); + }); + test("keeps `./foo/` on the directory, not a `foo.ts` beside it", () => { // A trailing slash names the directory only, for Node.js and `tsc` alike. const root = fixture({ From 0b9367a33b4b1f1e78ad322210b0ed112bff3879 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 11 Nov 2026 00:19:30 +0800 Subject: [PATCH 15/23] fix(core): keep a var a nested declaration sets live, and order a dotted directory main A top-level `var` that a loop header or block declares again is set there too, so it stays a live getter instead of becoming `exports.x` only at the top level. A directory's `main` with a dot in its name (`./src/entry.prod`, `./src.v1`) is not a file, so its extension order and index are probed the way the ES module resolver probes them. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 6 ++- .../integrate-commonjs/src/commonjs.test.ts | 6 ++- .../integrate-commonjs/src/declarators.ts | 8 ++++ .../__tests__/commonjs-lowering.spec.ts | 16 ++++++++ src/lib.rs | 38 ++++++++++++++++++- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/packages/core/register.mjs b/packages/core/register.mjs index 2560d1a6..fc315ea1 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -474,7 +474,7 @@ function directoryIndexSource(request, filename, resolved) { const main = directoryMain(directory); if ( main !== undefined && - (main === resolved || (extname(main) === "" && resolved.startsWith(`${main}.`))) + (main === resolved || (!isFile(main) && resolved.startsWith(`${main}.`))) ) { return undefined; } @@ -493,7 +493,9 @@ function directoryIndexSource(request, filename, resolved) { */ function directoryMainOrderSource(request, filename, resolved) { const main = directoryMain(directoryNamed(request, filename, resolved)); - if (main === undefined || extname(main) !== "") { + // A `main` that is a file is what Node.js loaded; one it completed is not, dots in + // its name (`./src/entry.prod`) or not. + if (main === undefined || isFile(main)) { return undefined; } if (resolved.startsWith(`${main}.`)) { diff --git a/packages/integrate-commonjs/src/commonjs.test.ts b/packages/integrate-commonjs/src/commonjs.test.ts index bfd21f1b..35a9da42 100644 --- a/packages/integrate-commonjs/src/commonjs.test.ts +++ b/packages/integrate-commonjs/src/commonjs.test.ts @@ -8,7 +8,7 @@ import { aliased } from "@alias/value"; import { readB, seenDuringLoad } from "./cycle-a"; import { parent } from "./barrel/inner/use"; import * as cycle from "./cycle-c"; -import { fourth, second, tagged } from "./declarators"; +import { fourth, looped, second, tagged } from "./declarators"; import * as Klass from "./klass.cjs"; import { compute, esmValue } from "./output-names"; import { where as shadowedByOutputName } from "./shadowed.js"; @@ -80,6 +80,10 @@ void test("an import cycle sees an export from an earlier declarator", () => { assert.equal(fourth, 3); }); +void test("a `var` a loop declares again stays live", () => { + assert.equal(looped, 3); +}); + // `tsc` calls a tag as `(0, exports.tag)`, without `exports` as `this`. void test("a reassigned export used as a template tag gets no receiver", () => { assert.equal(tagged, "unbound"); diff --git a/packages/integrate-commonjs/src/declarators.ts b/packages/integrate-commonjs/src/declarators.ts index 8660a8cb..35543262 100644 --- a/packages/integrate-commonjs/src/declarators.ts +++ b/packages/integrate-commonjs/src/declarators.ts @@ -17,3 +17,11 @@ tag = function (this: unknown, _strings: TemplateStringsArray): string { return this === undefined ? "unbound" : "bound"; }; export const tagged = tag`x`; + +// A `var` the loop header declares again is the same binding, and stays live. +var looped = 0; +export { looped }; +for (var looped of [1, 2]) { + void looped; +} +looped++; diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index e7a27a14..ef3bbfb1 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -331,6 +331,22 @@ describe("a TypeScript file in a CommonJS package", () => { ); }); + test.each([ + ["a file with a dot in its name", "./src/entry.prod", "src/entry.prod"], + ["a directory with a dot in its name", "./src.v1", "src.v1/index"], + ])( + "resolves a directory's `main` naming %s in the ES module resolver's order", + (_what, main, stem) => { + const root = fixture({ + "lib/package.json": JSON.stringify({ type: "commonjs", main }), + [`lib/${stem}.ts`]: `export const where: string = "${stem}.ts";\n`, + [`lib/${stem}.json`]: JSON.stringify({ where: `${stem}.json` }), + "main.ts": 'import { where } from "./lib";\nconsole.log("where:", where);\n', + }); + expect(run(root, "./main.ts")).toContain(`where: ${stem}.ts\n`); + }, + ); + test("keeps `#` in a require()'s file name, as Node.js does", () => { // `dep.js#missing` names a file that is not there, not `dep.js` with a fragment. const root = fixture({ diff --git a/src/lib.rs b/src/lib.rs index f3812d00..37e56b4c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3083,6 +3083,10 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< } } + // How many times each symbol is declared anywhere in the file. + let mut bindings = BindingCount::default(); + bindings.visit_program(program); + let mut prelude = String::from("\"use strict\";\n"); prelude.push_str("Object.defineProperty(exports, \"__esModule\", { value: true });\n"); let mut cleared = Vec::new(); @@ -3104,6 +3108,13 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< scoping.get_resolved_references(symbol).any(|reference| reference.is_write()) }) || local .is_some_and(|local| declarations.get(local).is_some_and(|&n| n > 1)); + // A `var` also declared inside a block or loop header (`for (var x of …)`) is set + // there, where only its own declaration can be: it stays live through a getter. + let nested = symbol.is_some_and(|symbol| { + bindings.0.get(&symbol).copied().unwrap_or(0) + > local.and_then(|local| declarations.get(local).copied()).unwrap_or(0) + }); + let written = written || nested; let declared = local.and_then(|local| declared_at.get(local).copied()); match (local, declared, symbol) { (Some(local), Some((_, true)), _) if !written => { @@ -3114,7 +3125,9 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< }; prelude.push_str(&format!("{member} = {local};\n")); } - (Some(local), Some((_, false)), Some(symbol)) if destructured.contains(local) => { + (Some(local), Some((_, false)), Some(symbol)) + if destructured.contains(local) && !nested => + { cleared.push(member.clone()); members.insert(symbol, member); } @@ -3124,7 +3137,8 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< } (Some(local), Some((_, false)), Some(symbol)) if export_names.get(local) == Some(&1) - && plain_declarations.get(local) == Some(&true) => + && plain_declarations.get(local) == Some(&true) + && !nested => { cleared.push(member.clone()); members.insert(symbol, member); @@ -3469,6 +3483,18 @@ fn pattern_target<'a>( } } +/// How many declarations each symbol has. +#[derive(Default)] +struct BindingCount(HashMap); + +impl<'a> Visit<'a> for BindingCount { + fn visit_binding_identifier(&mut self, ident: &oxc::ast::ast::BindingIdentifier<'a>) { + if let Some(symbol) = ident.symbol_id.get() { + *self.0.entry(symbol).or_default() += 1; + } + } +} + /// Reads and writes of a binding that became `exports.name` go through `exports.name`. struct ExportedBindings<'s, 'a> { allocator: &'a Allocator, @@ -6180,6 +6206,14 @@ mod tests { assert!(code.contains("const y = exports.x;"), "{code}"); assert!(!code.contains("var x"), "{code}"); + // A `var` also declared in a loop header is set there too, so it stays live + // through a getter rather than becoming `exports.x` only at the top level. + let code = + run("cjs/loop-var.ts", "var x = 0;\nexport { x };\nfor (var x of [1, 2]) {}\nx++;\n"); + assert!(lowered(&code), "{code}"); + assert!(code.contains("for (var x of"), "{code}"); + assert!(code.contains("get: function() { return x; }"), "{code}"); + // A side-effect import is a bare `require()`, as `tsc` emits it. let code = run("cjs/side.ts", "import './b';\nexport const n = 1;\n"); assert!(lowered(&code) && code.contains("require(\"./b\");"), "{code}"); From 08788696bf317add8d48b3bbadcc27488fb7d892 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 11 Nov 2026 00:40:23 +0800 Subject: [PATCH 16/23] fix(core): keep a directory main that shares the request's name, and skip globals for default_N `./lib` resolving to `lib/src/lib.cjs` named the directory, so the extensionless probes no longer read `.cjs` as an extension Node.js added and swap in `lib.ts`. The name for an anonymous default function skips names the module uses as globals, so `default_1()` inside it still calls the global, as `tsc` picks `default_2`. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 25 ++++++++++++++++--- .../__tests__/commonjs-lowering.spec.ts | 10 ++++++++ src/lib.rs | 18 ++++++++++++- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/core/register.mjs b/packages/core/register.mjs index fc315ea1..9a66a20b 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -245,24 +245,27 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o let source; if (resolvesSourceFirst(request, parent) && !inNodeModules(resolved)) { const named = requested(request, parent.filename, resolved); + const file = !throughDirectory(request, parent.filename, resolved); // An extensionless request takes the ES module resolver's extension order first; // the aliases of the extension Node.js added come after it. source = directoryMainSource(request, parent.filename, resolved) ?? directoryMainOrderSource(request, parent.filename, resolved) ?? directoryIndexSource(request, parent.filename, resolved) ?? - extensionlessSource(request, named) ?? + (file ? extensionlessSource(request, named) : undefined) ?? sourceBeside(named) ?? - extensionlessSource(request, resolved) ?? + (file ? extensionlessSource(request, resolved) : undefined) ?? sourceBeside(resolved); } else if (compiledFromEsm(request, parent) && !inNodeModules(resolved)) { const named = requested(request, parent.filename, resolved); + const file = !throughDirectory(request, parent.filename, resolved); source = directoryMainSource(request, parent.filename, resolved) ?? directoryMainOrderSource(request, parent.filename, resolved) ?? directoryIndexSource(request, parent.filename, resolved) ?? - extensionlessSource(request, named) ?? - extensionlessSource(request, resolved); + (file + ? (extensionlessSource(request, named) ?? extensionlessSource(request, resolved)) + : undefined); } else if (mapsToProjectFile(request, parent, resolved)) { // A package `imports` entry or a self-reference that maps to compiled output beside // its source: the ES module resolver tried the source first after mapping. @@ -438,6 +441,20 @@ function directoryNamed(request, filename, resolved) { } } +/** + * Whether Node.js resolved `request` as a directory, to its `main` or index: no file + * the request names with an extension added was there, since Node.js tries those + * first. `./lib` reaching `lib/src/lib.cjs` named the directory, not `lib.cjs`. + * + * @param {string} request + * @param {string} filename + * @param {string} resolved + * @returns {boolean} + */ +function throughDirectory(request, filename, resolved) { + return resolved.startsWith(directoryNamed(request, filename, resolved) + sep); +} + /** * The file a directory's `package.json` names as its `main`, if it names one. * diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index ef3bbfb1..9650de39 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -347,6 +347,16 @@ describe("a TypeScript file in a CommonJS package", () => { }, ); + test("keeps a directory's `main` whose name starts with the request's, as the ES module resolver does", () => { + const root = fixture({ + "lib/package.json": JSON.stringify({ main: "./src/lib.cjs" }), + "lib/src/lib.cjs": 'exports.where = "lib.cjs";\n', + "lib/src/lib.ts": 'export const where: string = "lib.ts";\n', + "main.ts": 'import { where } from "./lib";\nconsole.log("where:", where);\n', + }); + expect(run(root, "./main.ts")).toContain("where: lib.cjs\n"); + }); + test("keeps `#` in a require()'s file name, as Node.js does", () => { // `dep.js#missing` names a file that is not there, not `dep.js` with a fragment. const root = fixture({ diff --git a/src/lib.rs b/src/lib.rs index 37e56b4c..6d36f2e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3043,7 +3043,13 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< declared_at.insert(SSR_EXPORT_DEFAULT, (at, true)); (1..) .map(|n| format!("default_{n}")) - .find(|name| scoping.get_root_binding(name.as_str().into()).is_none()) + // A global the module calls by that name (`default_1()`) would be captured too. + .find(|name| { + scoping.get_root_binding(name.as_str().into()).is_none() + && !scoping + .root_unresolved_references() + .contains_key(&oxc::str::Ident::from(name.as_str())) + }) .expect("an unused name") }); @@ -6227,6 +6233,16 @@ mod tests { assert!(code.contains("exports.default = default_1;"), "{code}"); assert!(code.find("exports.default = default_1;") < code.find("exports.n = n;"), "{code}"); + // A global the function calls by that name keeps it: `tsc` picks `default_2`. + let code = run( + "cjs/global-name.ts", + "declare global { function default_1(): number; }\nexport default function () { return default_1(); }\n", + ); + assert!(lowered(&code), "{code}"); + assert!(code.contains("function default_2()"), "{code}"); + assert!(code.contains("return default_1();"), "{code}"); + assert!(code.contains("exports.default = default_2;"), "{code}"); + // Only a value binding clashes with the CommonJS wrapper: a type, an ambient // declaration or a type-only import of the same name is erased and lowers. for (name, source) in [ From 36ea0e045c1116c0fb7c62c6ef2d88a2b1ff1523 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 11 Nov 2026 01:26:43 +0800 Subject: [PATCH 17/23] fix(core): rewrite a destructured export only when every binding can be a member, and leave require.resolve paths to Node.js A pattern is rewritten whole, so its names become `exports` members only when each one can: not when another pattern binds a name nothing exports, a block or a function declaration declares one again, or a function or class takes its name from one (`var x = function () {}`, `[f = () => {}]`). `var [x] = [1]; var [x, local] = [2, 3]` no longer panics. `require.resolve(request, { paths })` returns what Node.js finds in those directories: no tsconfig alias before it, and no source beside the caller when nothing is found. `.` and `..` name the directory only. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- packages/core/register.mjs | 21 ++- .../integrate-commonjs/src/commonjs.test.ts | 25 +++- .../integrate-commonjs/src/declarators.ts | 18 +++ .../__tests__/commonjs-lowering.spec.ts | 46 +++++++ src/lib.rs | 120 +++++++++++++++++- 6 files changed, 220 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c8fac8e3..3cfabb9f 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,8 @@ source is looked for beside the name a request reaches, before a symlink there i followed. The `require()` calls of every TypeScript file outside `node_modules` resolve this way, and so do the extensionless ones of a `.js` file compiled from an ES module; -a file a package ships keeps its compiled `.js`. +a file a package ships keeps its compiled `.js`. `require.resolve()` with a +`paths` option returns what Node.js finds in those directories. Stack traces report the source's lines; for a file Node.js loads through the module hooks — the entry point, or one an ES module imports — a column can be a diff --git a/packages/core/register.mjs b/packages/core/register.mjs index 9a66a20b..01f3f37b 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -175,7 +175,11 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o // request does here too: an ES module compiled to CommonJS finds what it imported as // an ES module (#905). Each file an alias maps to resolves through this function, so // the rules below apply to it as well. - if (mapsThroughTsconfig(request, parent)) { + // `require.resolve(request, { paths })` searches other directories, which the ES + // module resolver has no notion of: what Node.js finds there is the answer, with no + // tsconfig alias before it and no source beside the caller after it. + const searchedElsewhere = Array.isArray(options?.paths); + if (!searchedElsewhere && mapsThroughTsconfig(request, parent)) { const candidates = tsconfigPathCandidates(process.cwd(), parent.filename, request); // Finding the importer's tsconfig reads it. if (watch) { @@ -209,7 +213,12 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o } } const filename = parent?.filename; - if (typeof filename === "string" && !inNodeModules(filename) && !isBuiltin(request)) { + if ( + !searchedElsewhere && + typeof filename === "string" && + !inNodeModules(filename) && + !isBuiltin(request) + ) { const found = PATH_REQUEST.test(request) ? // The ES module resolver's other candidates — `index.ts` in a directory, say // — and its report of them to watch mode, so creating one restarts the app. @@ -243,7 +252,9 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o // The output may be a symlink: Node.js has followed it, and the source sits beside // the name the request reaches, as the ES module resolver looks for it. let source; - if (resolvesSourceFirst(request, parent) && !inNodeModules(resolved)) { + if (searchedElsewhere) { + // Nothing to probe beside the caller. + } else if (resolvesSourceFirst(request, parent) && !inNodeModules(resolved)) { const named = requested(request, parent.filename, resolved); const file = !throughDirectory(request, parent.filename, resolved); // An extensionless request takes the ES module resolver's extension order first; @@ -380,8 +391,8 @@ function resolvesSourceFirst(request, parent) { * @returns {string} */ function requested(request, filename, resolved) { - // `./foo/` names the directory only, never a `foo.js` beside it. - if (/[\\/]$/.test(request)) { + // `./foo/`, `.` and `..` name the directory only, never a `foo.js` beside it. + if (/(?:[\\/]|(?:^|[\\/])\.\.?)$/.test(request)) { return resolved; } const path = resolvePath(dirname(filename), request); diff --git a/packages/integrate-commonjs/src/commonjs.test.ts b/packages/integrate-commonjs/src/commonjs.test.ts index 35a9da42..54cac386 100644 --- a/packages/integrate-commonjs/src/commonjs.test.ts +++ b/packages/integrate-commonjs/src/commonjs.test.ts @@ -8,7 +8,19 @@ import { aliased } from "@alias/value"; import { readB, seenDuringLoad } from "./cycle-a"; import { parent } from "./barrel/inner/use"; import * as cycle from "./cycle-c"; -import { fourth, looped, second, tagged } from "./declarators"; +import { + again, + beside, + blocked, + byDefault, + fourth, + fromPattern, + DefaultClass, + looped, + named, + second, + tagged, +} from "./declarators"; import * as Klass from "./klass.cjs"; import { compute, esmValue } from "./output-names"; import { where as shadowedByOutputName } from "./shadowed.js"; @@ -84,6 +96,17 @@ void test("a `var` a loop declares again stays live", () => { assert.equal(looped, 3); }); +void test("a pattern that binds a name no member can take stays live", () => { + assert.deepEqual([again, fromPattern, blocked, beside], [2, 3, 3, 2]); +}); + +void test("a function or class named by its binding keeps that name", () => { + assert.deepEqual( + [named.name, byDefault.name, DefaultClass.name], + ["named", "byDefault", "DefaultClass"], + ); +}); + // `tsc` calls a tag as `(0, exports.tag)`, without `exports` as `this`. void test("a reassigned export used as a template tag gets no receiver", () => { assert.equal(tagged, "unbound"); diff --git a/packages/integrate-commonjs/src/declarators.ts b/packages/integrate-commonjs/src/declarators.ts index 35543262..99a1117a 100644 --- a/packages/integrate-commonjs/src/declarators.ts +++ b/packages/integrate-commonjs/src/declarators.ts @@ -25,3 +25,21 @@ for (var looped of [1, 2]) { void looped; } looped++; + +// A pattern is rewritten whole: one that also binds a name nothing exports, or a name a +// block declares again, keeps every name it binds live. +var [again] = [1]; +var [again, unexported] = [2, 3]; +export { again }; +export const fromPattern = unexported; +var [blocked, beside] = [1, 2]; +{ + var blocked = 3; +} +export { blocked, beside }; + +// A function or class named by its binding keeps that name. +var [named]: Array<() => void> = []; +var named = function () {}; +export { named }; +export const [byDefault = function () {}, DefaultClass = class {}] = []; diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index 9650de39..ef0a1580 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -347,6 +347,52 @@ describe("a TypeScript file in a CommonJS package", () => { }, ); + test("leaves require.resolve() with `paths` to Node.js, which searches there", () => { + const root = fixture( + { + "dep.js": 'exports.where = "local dep.js";\n', + "dep.ts": 'export const where: string = "local dep.ts";\n', + "other/dep.js": 'exports.where = "other dep.js";\n', + "only.ts": "export {};\n", + "empty/.keep": "", + "src/x.ts": "export {};\n", + "other/node_modules/@lib/x/package.json": JSON.stringify({ main: "index.js" }), + "other/node_modules/@lib/x/index.js": 'exports.where = "other @lib/x";\n', + "main.cts": [ + 'const other = __dirname + "/other";', + "const where = (request: string, paths: string[]) => {", + " try {", + " return require(require.resolve(request, { paths })).where;", + " } catch (error) {", + " return (error as { code?: string }).code;", + " }", + "};", + 'console.log("where:", where("./dep.js", [other]));', + // No source beside the caller when Node.js searched elsewhere. + 'console.log("missing:", where("./only.js", []), where("./only.js", [__dirname + "/empty"]));', + // No tsconfig alias before the directories it names. + 'console.log("bare:", where("@lib/x", [other]));', + "export {};", + "", + ].join("\n"), + }, + { ...NODENEXT, paths: { "@lib/*": ["./src/*"] } }, + ); + expect(run(root, "./main.cts")).toBe( + "where: other dep.js\nmissing: MODULE_NOT_FOUND MODULE_NOT_FOUND\nbare: other @lib/x\n", + ); + }); + + test("resolves `.` as the directory only, never a file beside it", () => { + const root = fixture({ + "proj/main.ts": 'import { where } from ".";\nconsole.log("where:", where);\n', + "proj/index.js": 'exports.where = "proj/index.js";\n', + "proj.js": 'exports.where = "proj.js";\n', + "proj.ts": 'export const where: string = "proj.ts";\n', + }); + expect(run(root, "./proj/main.ts")).toContain("where: proj/index.js\n"); + }); + test("keeps a directory's `main` whose name starts with the request's, as the ES module resolver does", () => { const root = fixture({ "lib/package.json": JSON.stringify({ main: "./src/lib.cjs" }), diff --git a/src/lib.rs b/src/lib.rs index 6d36f2e8..4b1573bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3067,10 +3067,23 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< // `[exports.a = 1, exports.b = f()] = v;`, which takes every binding of the pattern // being exported, each under one name. let mut destructured: HashSet<&str> = HashSet::new(); + // A function or class named by its binding (`var x = function () {}`, or a default + // `[f = () => {}]`) would lose that name were the binding a member. + let mut names_a_function: HashSet<&str> = HashSet::new(); for statement in program.body.iter().skip(hoisted) { let Statement::VariableDeclaration(declaration) = statement else { continue }; for declarator in &declaration.declarations { let names = declarator.id.get_binding_identifiers(); + let named = match declarator.id.get_binding_identifier() { + Some(_) => declarator + .init + .as_ref() + .is_some_and(Expression::is_anonymous_function_definition), + None => names_a_default(&declarator.id), + }; + if named { + names_a_function.extend(names.iter().map(|ident| ident.name.as_str())); + } if declarator.id.get_binding_identifier().is_none() && declarator.init.is_some() && names.iter().all(|ident| export_names.get(ident.name.as_str()) == Some(&1)) @@ -3092,6 +3105,44 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< // How many times each symbol is declared anywhere in the file. let mut bindings = BindingCount::default(); bindings.visit_program(program); + // A `var` also declared inside a block or loop header (`for (var x of …)`) is set + // there, where only its own declaration can be: it stays live through a getter. + let nested_name = |name: &str| { + scoping.get_root_binding(name.into()).is_some_and(|symbol| { + bindings.0.get(&symbol).copied().unwrap_or(0) + > declarations.get(name).copied().unwrap_or(0) + }) + }; + // A pattern is rewritten whole, so every binding of every pattern that declares a + // destructured name must become a member too: `var [x] = a; var [x, local] = b;` + // keeps `x` live through a getter, and with it the other names those patterns bind. + // Each name must also take the member arm below: declared by a `var`, `let` or + // `const` last, not by a function declaration (`var [x] = a; function x() {}`). + destructured.retain(|name| { + !nested_name(name) + && !names_a_function.contains(name) + && matches!(declared_at.get(name), Some((_, false))) + }); + loop { + let before = destructured.len(); + for statement in program.body.iter().skip(hoisted) { + let Statement::VariableDeclaration(declaration) = statement else { continue }; + for declarator in &declaration.declarations { + if declarator.id.get_binding_identifier().is_some() { + continue; + } + let names = declarator.id.get_binding_identifiers(); + if names.iter().any(|ident| !destructured.contains(ident.name.as_str())) { + for ident in &names { + destructured.remove(ident.name.as_str()); + } + } + } + } + if destructured.len() == before { + break; + } + } let mut prelude = String::from("\"use strict\";\n"); prelude.push_str("Object.defineProperty(exports, \"__esModule\", { value: true });\n"); @@ -3114,12 +3165,7 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< scoping.get_resolved_references(symbol).any(|reference| reference.is_write()) }) || local .is_some_and(|local| declarations.get(local).is_some_and(|&n| n > 1)); - // A `var` also declared inside a block or loop header (`for (var x of …)`) is set - // there, where only its own declaration can be: it stays live through a getter. - let nested = symbol.is_some_and(|symbol| { - bindings.0.get(&symbol).copied().unwrap_or(0) - > local.and_then(|local| declarations.get(local).copied()).unwrap_or(0) - }); + let nested = local.is_some_and(nested_name); let written = written || nested; let declared = local.and_then(|local| declared_at.get(local).copied()); match (local, declared, symbol) { @@ -3405,6 +3451,27 @@ fn exported_declarations<'a>( statements } +/// Whether `pattern` gives a binding a default that takes its name +/// (`[f = function () {}]`). +fn names_a_default(pattern: &BindingPattern<'_>) -> bool { + match pattern { + BindingPattern::BindingIdentifier(_) => false, + BindingPattern::AssignmentPattern(assignment) => { + (assignment.left.get_binding_identifier().is_some() + && assignment.right.is_anonymous_function_definition()) + || names_a_default(&assignment.left) + } + BindingPattern::ArrayPattern(array) => { + array.elements.iter().flatten().any(names_a_default) + || array.rest.as_ref().is_some_and(|rest| names_a_default(&rest.argument)) + } + BindingPattern::ObjectPattern(object) => { + object.properties.iter().any(|property| names_a_default(&property.value)) + || object.rest.as_ref().is_some_and(|rest| names_a_default(&rest.argument)) + } + } +} + /// `pattern` as an assignment target, its bindings replaced by the `exports` members /// they became. fn pattern_target<'a>( @@ -6220,6 +6287,47 @@ mod tests { assert!(code.contains("for (var x of"), "{code}"); assert!(code.contains("get: function() { return x; }"), "{code}"); + // A pattern is rewritten whole, so one that binds a name nothing exports, or a + // name a block declares again, keeps its declaration and every name a getter. + for (name, source, pattern) in [ + ( + "cjs/pattern-local.ts", + "var [x] = [1];\nvar [x, local] = [2, 3];\nexport { x };\n", + "var [x, local] = [2, 3];", + ), + ( + "cjs/pattern-block.ts", + "var [x, y] = [1, 2];\n{ var x = 3; }\nexport { x, y };\n", + "var [x, y] = [1, 2];", + ), + ( + "cjs/pattern-function.ts", + "var [x, y] = [1, 2];\nfunction x() {}\nexport { x, y };\n", + "var [x, y] = [1, 2];", + ), + // A member would not name the function, as `x` does. + ( + "cjs/pattern-named.ts", + "var [x] = [0];\nvar x = function () {};\nexport { x };\n", + "var [x] = [0];", + ), + ( + "cjs/pattern-default.ts", + "export const [x = function () {}, y = class {}] = [];\n", + "const [x = function() {}, y = class {}] = [];", + ), + ] { + let code = run(name, source); + assert!(lowered(&code), "{code}"); + assert!(code.contains(pattern), "{code}"); + assert!(!code.contains("[exports."), "{code}"); + // A getter when `x` is written again, else set once it is declared. + assert!( + code.contains("get: function() { return x; }") || code.contains("exports.x = x;"), + "{code}" + ); + } + // A side-effect import is a bare `require()`, as `tsc` emits it. let code = run("cjs/side.ts", "import './b';\nexport const n = 1;\n"); assert!(lowered(&code) && code.contains("require(\"./b\");"), "{code}"); From 3f0888a7bf374cb5bd82de1a5e0d77393888afdc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 11 Nov 2026 01:33:11 +0800 Subject: [PATCH 18/23] test(core): let the require.resolve paths test pass with runtime warnings The output also carries stderr, where the WASI target prints an `ExperimentalWarning`, so the test checks what the output contains. Co-Authored-By: Claude Opus 5.5 --- packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index ef0a1580..80211107 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -378,7 +378,7 @@ describe("a TypeScript file in a CommonJS package", () => { }, { ...NODENEXT, paths: { "@lib/*": ["./src/*"] } }, ); - expect(run(root, "./main.cts")).toBe( + expect(run(root, "./main.cts")).toContain( "where: other dep.js\nmissing: MODULE_NOT_FOUND MODULE_NOT_FOUND\nbare: other @lib/x\n", ); }); From fe3a3c0bad3816102e8c5e85006a1569a2a3566e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 11 Nov 2026 01:53:57 +0800 Subject: [PATCH 19/23] fix(core): keep a module's own __vite_ssr_exports__ binding, and find ./foo.js's source before a foo.js directory Only the runner's free `__vite_ssr_exports__` becomes `exports`; a parameter or variable of that name keeps its reads. `./foo.js` that Node.js completed with a `foo.js/` directory index finds `foo.ts` first, as the ES module resolver does. `./foo.js/` still names the directory. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 11 +++++++-- .../__tests__/commonjs-lowering.spec.ts | 12 ++++++++++ src/lib.rs | 24 +++++++++++++++---- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/core/register.mjs b/packages/core/register.mjs index 01f3f37b..af3d9411 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -258,8 +258,12 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o const named = requested(request, parent.filename, resolved); const file = !throughDirectory(request, parent.filename, resolved); // An extensionless request takes the ES module resolver's extension order first; - // the aliases of the extension Node.js added come after it. + // the aliases of the extension Node.js added come after it. `./foo.js` that Node.js + // completed with a `foo.js/` directory still finds `foo.ts` first. source = + (file || DIRECTORY_ONLY.test(request) + ? undefined + : sourceBeside(resolvePath(dirname(parent.filename), request))) ?? directoryMainSource(request, parent.filename, resolved) ?? directoryMainOrderSource(request, parent.filename, resolved) ?? directoryIndexSource(request, parent.filename, resolved) ?? @@ -327,6 +331,9 @@ const SOURCE_EXTENSIONS = [ // `.` and `..` themselves are relative too: `require("..")` names the parent directory. const PATH_REQUEST = /^(?:\.{1,2}(?:[\\/]|$)|[\\/]|[a-zA-Z]:[\\/])/; +/** A request that names a directory only: `./foo/`, `.`, `..`. */ +const DIRECTORY_ONLY = /(?:[\\/]|(?:^|[\\/])\.\.?)$/; + /** * @param {string} path * @returns {boolean} @@ -392,7 +399,7 @@ function resolvesSourceFirst(request, parent) { */ function requested(request, filename, resolved) { // `./foo/`, `.` and `..` name the directory only, never a `foo.js` beside it. - if (/(?:[\\/]|(?:^|[\\/])\.\.?)$/.test(request)) { + if (DIRECTORY_ONLY.test(request)) { return resolved; } const path = resolvePath(dirname(filename), request); diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index 80211107..451e5472 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -383,6 +383,18 @@ describe("a TypeScript file in a CommonJS package", () => { ); }); + test("finds the source `./foo.js` names before a `foo.js` directory", () => { + const root = fixture({ + "foo.js/index.js": 'exports.where = "foo.js/index.js";\n', + "foo.ts": 'export const where: string = "foo.ts";\n', + "main.ts": + 'import { where } from "./foo.js";\n' + + 'import { where as directory } from "./foo.js/";\n' + + 'console.log("where:", where, directory);\n', + }); + expect(run(root, "./main.ts")).toContain("where: foo.ts foo.js/index.js\n"); + }); + test("resolves `.` as the directory only, never a file beside it", () => { const root = fixture({ "proj/main.ts": 'import { where } from ".";\nconsole.log("where:", where);\n', diff --git a/src/lib.rs b/src/lib.rs index 4b1573bf..4a24b868 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2945,11 +2945,12 @@ fn top_level_bindings<'s>(statement: &'s Statement<'_>) -> (Vec<&'s str>, bool) /// Runs after the runner transform has hoisted the imports and exports and dropped the /// imports' awaits. The caller has ruled out `import.meta` and top-level await. fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program<'a>) { - struct Rewrite<'a> { + struct Rewrite<'a, 's> { ast: AstBuilder<'a>, + scoping: &'s Scoping, } - impl<'a> VisitMut<'a> for Rewrite<'a> { + impl<'a> VisitMut<'a> for Rewrite<'a, '_> { fn visit_expression(&mut self, expression: &mut Expression<'a>) { walk_mut::walk_expression(self, expression); if let Expression::CallExpression(call) = expression @@ -2969,7 +2970,12 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< } fn visit_identifier_reference(&mut self, ident: &mut IdentifierReference<'a>) { - if ident.name == SSR_EXPORTS { + // The runner's exports object is a free variable; a binding of the module's own + // under that name (`function f(__vite_ssr_exports__) {}`) keeps it. + let bound = ident.reference_id.get().is_some_and(|reference| { + self.scoping.get_reference(reference).symbol_id().is_some() + }); + if ident.name == SSR_EXPORTS && !bound { ident.name = "exports".into(); } } @@ -3339,7 +3345,7 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< if let Some(default_name) = &default_name { RenameDefault { name: allocator.alloc_str(default_name) }.visit_program(program); } - Rewrite { ast: AstBuilder::new(allocator) }.visit_program(program); + Rewrite { ast: AstBuilder::new(allocator), scoping: &scoping }.visit_program(program); } const SSR_EXPORT_DEFAULT: &str = "__vite_ssr_export_default__"; @@ -6328,6 +6334,16 @@ mod tests { ); } + // A binding of the module's own named like the runner's exports object keeps it. + let code = run( + "cjs/shadow.ts", + "export function echo(__vite_ssr_exports__: number) { return __vite_ssr_exports__; }\n", + ); + // `lowered` counts any `__vite_ssr_` name as runner output left behind. + assert!(code.contains("exports, \"__esModule\""), "{code}"); + assert!(code.contains("return __vite_ssr_exports__;"), "{code}"); + assert!(code.contains("exports.echo = echo;"), "{code}"); + // A side-effect import is a bare `require()`, as `tsc` emits it. let code = run("cjs/side.ts", "import './b';\nexport const n = 1;\n"); assert!(lowered(&code) && code.contains("require(\"./b\");"), "{code}"); From 6a1da334da3fb7a6a54d99df6755b39bd139fde7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 11 Nov 2026 02:18:07 +0800 Subject: [PATCH 20/23] fix(core): anchor a redeclared var's getter at its first declaration, and three resolution and naming cases - A `var` declared again gets its getter after the first declaration, so an import cycle reads what the first initializer set. - A parameter named `__vite_ssr_export_default__` keeps its reads. - A directory's `main` that is a symlink finds the source beside the name `main` gives, as the ES module resolver does. - An extensionless entry compiled to CommonJS (`bin/run`) resolves its extensionless requests TypeScript first, like a `.js` one. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 27 +++++++++++- .../__tests__/commonjs-lowering.spec.ts | 27 ++++++++++++ src/lib.rs | 42 ++++++++++++++++--- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/packages/core/register.mjs b/packages/core/register.mjs index af3d9411..385a0629 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -264,6 +264,7 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o (file || DIRECTORY_ONLY.test(request) ? undefined : sourceBeside(resolvePath(dirname(parent.filename), request))) ?? + directoryMainLinkSource(request, parent.filename, resolved) ?? directoryMainSource(request, parent.filename, resolved) ?? directoryMainOrderSource(request, parent.filename, resolved) ?? directoryIndexSource(request, parent.filename, resolved) ?? @@ -435,6 +436,29 @@ function directoryMainSource(request, filename, resolved) { : sourcesFor(target, MISSING_OUTPUT_SOURCES).find(isFile); } +/** + * The source beside a directory's `package.json` `main` that is a symlink Node.js + * followed: `"main": "./src/entry.js"` linking to `../dist/real.js` finds + * `src/entry.ts`, as the ES module resolver looks beside the name `main` gives. + * + * @param {string} request + * @param {string} filename + * @param {string} resolved + * @returns {string | undefined} + */ +function directoryMainLinkSource(request, filename, resolved) { + // A request that names a file has no `package.json` below it. + const main = directoryMain(resolvePath(dirname(filename), request)); + if (main === undefined || main === resolved || !isFile(main)) { + return undefined; + } + try { + return realpathSync(main) === resolved ? sourceBeside(main) : undefined; + } catch { + return undefined; + } +} + /** * The directory a relative or absolute `request` from `filename` names, spelled as * `resolved` spells it: its real path when Node.js followed a symlink to reach @@ -578,7 +602,8 @@ function compiledFromEsm(request, parent) { const exports = parent?.exports; return ( typeof filename === "string" && - filename.endsWith(".js") && + // An extensionless entry (`bin/run`) is JavaScript too. + (filename.endsWith(".js") || extname(filename) === "") && !inNodeModules(filename) && PATH_REQUEST.test(request) && exports != null && diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index 451e5472..c82409cd 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -383,6 +383,33 @@ describe("a TypeScript file in a CommonJS package", () => { ); }); + // Windows lets only elevated processes create file symlinks. + test.skipIf(process.platform === "win32")( + "finds the source beside a directory's `main` that is a symlink", + () => { + const root = fixture({ + "lib/package.json": JSON.stringify({ type: "commonjs", main: "./src/entry.js" }), + "lib/dist/real.js": 'exports.where = "dist/real.js";\n', + "lib/src/entry.ts": 'export const where: string = "src/entry.ts";\n', + "main.ts": 'import { where } from "./lib";\nconsole.log("where:", where);\n', + }); + symlinkSync(join("..", "dist", "real.js"), join(root, "lib", "src", "entry.js")); + expect(run(root, "./main.ts")).toContain("where: src/entry.ts\n"); + }, + ); + + test("resolves an extensionless entry compiled to CommonJS TypeScript first", () => { + const root = fixture({ + "tsconfig.cjs.json": JSON.stringify({ compilerOptions: { module: "commonjs" } }), + "dep.js": 'exports.where = "stale dep.js";\n', + "dep.ts": 'export const where: string = "dep.ts";\n', + "bin/run": 'import { where } from "../dep";\nconsole.log("where:", where);\n', + }); + expect(run(root, "./bin/run", [], { OXC_TSCONFIG_PATH: "./tsconfig.cjs.json" })).toContain( + "where: dep.ts\n", + ); + }); + test("finds the source `./foo.js` names before a `foo.js` directory", () => { const root = fixture({ "foo.js/index.js": 'exports.where = "foo.js/index.js";\n', diff --git a/src/lib.rs b/src/lib.rs index 4a24b868..6ecb101a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3012,6 +3012,8 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< // Where each top-level binding is declared, and the imports by binding. let mut declared_at = HashMap::new(); + // A `var` declared again is there from its first declaration on. + let mut first_declared: HashMap<&str, (usize, bool)> = HashMap::new(); // A `var` declared more than once is written by each initializer. let mut declarations: HashMap<&str, usize> = HashMap::new(); let mut imports = HashMap::new(); @@ -3019,6 +3021,7 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< let (names, hoisted_function) = top_level_bindings(statement); for name in names { declared_at.insert(name, (index, hoisted_function)); + first_declared.entry(name).or_insert((index, hoisted_function)); *declarations.entry(name).or_default() += 1; } if let Statement::VariableDeclaration(declaration) = statement @@ -3211,8 +3214,10 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< // Defined once what it reads is initialized, as `tsc` places a // re-export after its `require()`: an import cycle that reads it earlier // gets `undefined`, not a TDZ error. + // After the first declaration of a `var` declared again, where an + // earlier initializer has already set it. let anchor = getter_object(value) - .and_then(|object| declared_at.get(object)) + .and_then(|object| first_declared.get(object)) .filter(|&&(_, hoisted_function)| !hoisted_function); match anchor { Some(&(at, _)) => { @@ -3343,7 +3348,8 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< ExportedBindings { allocator, scoping: &scoping, members: &members }.visit_program(program); } if let Some(default_name) = &default_name { - RenameDefault { name: allocator.alloc_str(default_name) }.visit_program(program); + RenameDefault { name: allocator.alloc_str(default_name), scoping: &scoping } + .visit_program(program); } Rewrite { ast: AstBuilder::new(allocator), scoping: &scoping }.visit_program(program); } @@ -3682,13 +3688,20 @@ impl<'a> VisitMut<'a> for ExportedBindings<'_, 'a> { } /// References to the default export's binding name its hoisted declaration instead. -struct RenameDefault<'a> { +struct RenameDefault<'a, 's> { name: &'a str, + scoping: &'s Scoping, } -impl<'a> VisitMut<'a> for RenameDefault<'a> { +impl<'a> VisitMut<'a> for RenameDefault<'a, '_> { fn visit_identifier_reference(&mut self, ident: &mut IdentifierReference<'a>) { - if ident.name == SSR_EXPORT_DEFAULT { + // A binding of the module's own under that name (a parameter, say) keeps it. + let generated = self.scoping.get_root_binding(SSR_EXPORT_DEFAULT.into()); + let shadowed = ident.reference_id.get().is_some_and(|reference| { + let symbol = self.scoping.get_reference(reference).symbol_id(); + symbol.is_some() && symbol != generated + }); + if ident.name == SSR_EXPORT_DEFAULT && !shadowed { ident.name = self.name.into(); } } @@ -6344,6 +6357,25 @@ mod tests { assert!(code.contains("return __vite_ssr_exports__;"), "{code}"); assert!(code.contains("exports.echo = echo;"), "{code}"); + // A `var` declared again has its getter after the first declaration, where an + // import cycle can read what the first initializer set. + let code = run( + "cjs/var-again.ts", + "var x = 1;\nexport { x, x as alias };\nexport const seen = 0;\nvar x;\n", + ); + assert!(lowered(&code), "{code}"); + let getter = code.find("get: function() { return x; }").expect("a getter for x"); + assert!(code.find("var x = 1;") < Some(getter), "{code}"); + assert!(Some(getter) < code.find("var x;"), "{code}"); + + // A parameter named like the runner's default binding keeps its reads. + let code = run( + "cjs/default-shadow.ts", + "export default function (__vite_ssr_export_default__: number) {\n return __vite_ssr_export_default__;\n}\n", + ); + assert!(code.contains("function default_1(__vite_ssr_export_default__)"), "{code}"); + assert!(code.contains("return __vite_ssr_export_default__;"), "{code}"); + // A side-effect import is a bare `require()`, as `tsc` emits it. let code = run("cjs/side.ts", "import './b';\nexport const n = 1;\n"); assert!(lowered(&code) && code.contains("require(\"./b\");"), "{code}"); From 4d48275323cf1ecb42dd08d11434e636105d34fa Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 11 Nov 2026 02:40:20 +0800 Subject: [PATCH 21/23] fix(core): keep a file naming `exports` anywhere an ES module, and two more shadowing and symlink cases - A value named `exports` in any scope, a parameter included, keeps the file an ES module: the compiled `exports.x` inside a function would go to it. - A parameter named `__vite_ssr_dynamic_import__` is called, not turned into `import()`. - `./dep` whose `dep.js` links to `build/real.cjs` finds `dep.ts`, from the extension Node.js added rather than the link target's. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- packages/core/register.mjs | 17 +++++++- .../__tests__/commonjs-lowering.spec.ts | 14 +++++++ src/lib.rs | 41 +++++++++++++++---- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3cfabb9f..778519cd 100644 --- a/README.md +++ b/README.md @@ -383,8 +383,8 @@ project relying on Node.js' syntax detection keeps working. A file also stays an ES module when it uses `import.meta` or top-level await, which have no CommonJS form, or declares a value named like one the CommonJS wrapper or the compiled output uses (`exports`, `require`, `module`, `__filename`, `__dirname`, -`__importDefault`, `__importStar`, `__exportStar`, `Object`, `Symbol`); a type of -that name is fine. Removing `"type": "commonjs"` turns the compilation off. +`__importDefault`, `__importStar`, `__exportStar`, `Object`, `Symbol`) at its top +level, or `exports` anywhere, a parameter included; a type of that name is fine. Removing `"type": "commonjs"` turns the compilation off. Outside that combination — no `"type"`, `"type": "module"`, or an ES `module` setting — a file with module syntax runs as an ES module, as before. A `.cts` file is not compiled this way; see diff --git a/packages/core/register.mjs b/packages/core/register.mjs index 385a0629..5d14b2ab 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -411,7 +411,22 @@ function requested(request, filename, resolved) { return path; } const withExtension = path + extname(resolved); - return isFile(withExtension) ? withExtension : resolved; + if (isFile(withExtension)) { + return withExtension; + } + // A symlink Node.js followed: `./dep` found `dep.js`, linking to `build/real.cjs`. The + // extension it added is the first, in its order, whose file reaches `resolved`. + for (const extension of Object.keys(Module._extensions)) { + const candidate = path + extension; + try { + if (isFile(candidate) && realpathSync(candidate) === resolved) { + return candidate; + } + } catch { + // A dangling link; Node.js did not take it either. + } + } + return resolved; } /** diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index c82409cd..ff9d0951 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -398,6 +398,20 @@ describe("a TypeScript file in a CommonJS package", () => { }, ); + // Windows lets only elevated processes create file symlinks. + test.skipIf(process.platform === "win32")( + "finds the source for `./dep` whose `dep.js` links to another extension", + () => { + const root = fixture({ + "build/real.cjs": 'exports.where = "build/real.cjs";\n', + "dep.ts": 'export const where: string = "dep.ts";\n', + "main.ts": 'import { where } from "./dep";\nconsole.log("where:", where);\n', + }); + symlinkSync(join("build", "real.cjs"), join(root, "dep.js")); + expect(run(root, "./main.ts")).toContain("where: dep.ts\n"); + }, + ); + test("resolves an extensionless entry compiled to CommonJS TypeScript first", () => { const root = fixture({ "tsconfig.cjs.json": JSON.stringify({ compilerOptions: { module: "commonjs" } }), diff --git a/src/lib.rs b/src/lib.rs index 6ecb101a..b0fd3087 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2800,8 +2800,9 @@ const EXPORT_STAR_HELPER: &str = "var __exportStar = function (m, exports) { for /// Node.js' syntax detection runs it. The caller checks the extension is an ambiguous /// one. A module that cannot be CommonJS stays an ES module: one using `import.meta` or /// top-level await, or declaring a value named as the CommonJS wrapper's parameters or -/// its helpers are (see [`COMMONJS_RESERVED`]). A `declare`d or type-only declaration -/// of such a name is erased, so it does not count. +/// its helpers are (see [`COMMONJS_RESERVED`]), or one naming a value `exports` in any +/// scope. A `declare`d or type-only declaration of such a name is erased, so it does not +/// count. fn lowers_to_commonjs( path: &Path, compiler_options: Option<&CompilerOptions>, @@ -2821,12 +2822,18 @@ fn lowers_to_commonjs( && !has_top_level_await(program) && { let scoping = SemanticBuilder::new().build(program).semantic.into_scoping(); - COMMONJS_RESERVED.iter().all(|&name| { - scoping.get_root_binding(name.into()).is_none_or(|symbol| { - let flags = scoping.symbol_flags(symbol); - flags.is_ambient() || flags.is_type_import() || !flags.is_value() - }) - }) + let erased = |symbol| { + let flags = scoping.symbol_flags(symbol); + flags.is_ambient() || flags.is_type_import() || !flags.is_value() + }; + COMMONJS_RESERVED + .iter() + .all(|&name| scoping.get_root_binding(name.into()).is_none_or(erased)) + // `exports.x` is written inside functions too, where a parameter or local + // named `exports` would take it. + && scoping + .symbol_ids() + .all(|symbol| scoping.symbol_name(symbol) != "exports" || erased(symbol)) } } @@ -2955,6 +2962,11 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< walk_mut::walk_expression(self, expression); if let Expression::CallExpression(call) = expression && call.callee.is_specific_id(SSR_DYNAMIC_IMPORT) + // The runner's helper is a free variable; a parameter of that name is not it. + && !matches!(&call.callee, Expression::Identifier(callee) + if callee.reference_id.get().is_some_and(|reference| { + self.scoping.get_reference(reference).symbol_id().is_some() + })) && let Some(source) = call.arguments.first_mut().and_then(Argument::as_expression_mut) { @@ -6376,6 +6388,14 @@ mod tests { assert!(code.contains("function default_1(__vite_ssr_export_default__)"), "{code}"); assert!(code.contains("return __vite_ssr_export_default__;"), "{code}"); + // A parameter named like the runner's `import()` helper is called, not imported. + let code = run( + "cjs/import-shadow.ts", + "export function f(__vite_ssr_dynamic_import__: any) {\n return __vite_ssr_dynamic_import__(\"node:path\");\n}\n", + ); + assert!(code.contains("exports, \"__esModule\""), "{code}"); + assert!(code.contains("return __vite_ssr_dynamic_import__(\"node:path\");"), "{code}"); + // A side-effect import is a bare `require()`, as `tsc` emits it. let code = run("cjs/side.ts", "import './b';\nexport const n = 1;\n"); assert!(lowered(&code) && code.contains("require(\"./b\");"), "{code}"); @@ -6431,6 +6451,11 @@ mod tests { ("cjs/helper.ts", "export class __importStar {}\n"), ("cjs/symbol.ts", "import * as fs from 'node:fs';\nexport class Symbol {}\nvoid fs;\n"), ("cjs/object.ts", "export const Object = 1;\n"), + // `exports.n` written inside `inc` would go to its parameter. + ( + "cjs/nested-exports.ts", + "export let n = 0;\nexport function inc(exports: { n: number }) { n++; }\n", + ), // `tsc` itself emits an ES module for these. ("esnext/lib.ts", "export const n = 1;\n"), ("preserve/lib.ts", "export const n = 1;\n"), From 121732ff656e77cad84a7da0615ec25a99bea081 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 11 Nov 2026 03:54:48 +0800 Subject: [PATCH 22/23] =?UTF-8?q?fix(core):=20keep=20a=20file=20naming=20a?= =?UTF-8?q?=20value=20`=5F=5Fvite=5Fssr=5F=E2=80=A6`=20an=20ES=20module,?= =?UTF-8?q?=20and=20find=20the=20source=20beside=20a=20symlinked=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compiled output finds the module runner's helpers by their `__vite_ssr_` names, so a file declaring a value of such a name in any scope stays an ES module, as one declaring `exports` does. This covers `const __vite_ssr_import__ = …` and replaces the per-name checks for `__vite_ssr_exports__`, `__vite_ssr_export_default__` and `__vite_ssr_dynamic_import__`. A directory's index file that is a symlink Node.js followed finds the source beside it (`lib/index.ts`), as a symlinked `main` already does. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- packages/core/register.mjs | 38 +++++--- .../__tests__/commonjs-lowering.spec.ts | 14 +++ src/lib.rs | 90 +++++++------------ 4 files changed, 75 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 778519cd..be189736 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,9 @@ ES module when it uses `import.meta` or top-level await, which have no CommonJS form, or declares a value named like one the CommonJS wrapper or the compiled output uses (`exports`, `require`, `module`, `__filename`, `__dirname`, `__importDefault`, `__importStar`, `__exportStar`, `Object`, `Symbol`) at its top -level, or `exports` anywhere, a parameter included; a type of that name is fine. Removing `"type": "commonjs"` turns the compilation off. +level, or `exports` or a name starting with `__vite_ssr_` anywhere, a parameter +included; a type of that name is fine. Removing `"type": "commonjs"` turns the +compilation off. Outside that combination — no `"type"`, `"type": "module"`, or an ES `module` setting — a file with module syntax runs as an ES module, as before. A `.cts` file is not compiled this way; see diff --git a/packages/core/register.mjs b/packages/core/register.mjs index 5d14b2ab..b0058eb6 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -264,7 +264,7 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o (file || DIRECTORY_ONLY.test(request) ? undefined : sourceBeside(resolvePath(dirname(parent.filename), request))) ?? - directoryMainLinkSource(request, parent.filename, resolved) ?? + directoryLinkSource(request, parent.filename, resolved) ?? directoryMainSource(request, parent.filename, resolved) ?? directoryMainOrderSource(request, parent.filename, resolved) ?? directoryIndexSource(request, parent.filename, resolved) ?? @@ -452,26 +452,40 @@ function directoryMainSource(request, filename, resolved) { } /** - * The source beside a directory's `package.json` `main` that is a symlink Node.js - * followed: `"main": "./src/entry.js"` linking to `../dist/real.js` finds - * `src/entry.ts`, as the ES module resolver looks beside the name `main` gives. + * The source beside a directory's `main` or index file that is a symlink Node.js + * followed, as the ES module resolver looks beside the name the directory gives: + * `"main": "./src/entry.js"` linking to `../dist/real.js` finds `src/entry.ts`, and + * `index.js` linking to `../build/real.js` finds `index.ts`. * * @param {string} request * @param {string} filename * @param {string} resolved * @returns {string | undefined} */ -function directoryMainLinkSource(request, filename, resolved) { - // A request that names a file has no `package.json` below it. - const main = directoryMain(resolvePath(dirname(filename), request)); - if (main === undefined || main === resolved || !isFile(main)) { +function directoryLinkSource(request, filename, resolved) { + const directory = resolvePath(dirname(filename), request); + if (!statSync(directory, { throwIfNoEntry: false })?.isDirectory()) { return undefined; } - try { - return realpathSync(main) === resolved ? sourceBeside(main) : undefined; - } catch { - return undefined; + /** @param {string} file */ + const linked = (file) => { + try { + return file !== resolved && isFile(file) && realpathSync(file) === resolved; + } catch { + return false; + } + }; + const main = directoryMain(directory); + if (main !== undefined && linked(main)) { + return sourceBeside(main); } + // The index Node.js took, in its extension order. + const index = Object.keys(Module._extensions) + .map((extension) => join(directory, `index${extension}`)) + .find(linked); + return index === undefined + ? undefined + : (extensionlessSource("index", index) ?? sourceBeside(index)); } /** diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index ff9d0951..0a7e4a94 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -398,6 +398,20 @@ describe("a TypeScript file in a CommonJS package", () => { }, ); + // Windows lets only elevated processes create file symlinks. + test.skipIf(process.platform === "win32")( + "finds the source beside a directory's index file that is a symlink", + () => { + const root = fixture({ + "build/real.js": 'exports.where = "build/real.js";\n', + "lib/index.ts": 'export const where: string = "lib/index.ts";\n', + "main.ts": 'import { where } from "./lib";\nconsole.log("where:", where);\n', + }); + symlinkSync(join("..", "build", "real.js"), join(root, "lib", "index.js")); + expect(run(root, "./main.ts")).toContain("where: lib/index.ts\n"); + }, + ); + // Windows lets only elevated processes create file symlinks. test.skipIf(process.platform === "win32")( "finds the source for `./dep` whose `dep.js` links to another extension", diff --git a/src/lib.rs b/src/lib.rs index b0fd3087..310e51ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2800,8 +2800,8 @@ const EXPORT_STAR_HELPER: &str = "var __exportStar = function (m, exports) { for /// Node.js' syntax detection runs it. The caller checks the extension is an ambiguous /// one. A module that cannot be CommonJS stays an ES module: one using `import.meta` or /// top-level await, or declaring a value named as the CommonJS wrapper's parameters or -/// its helpers are (see [`COMMONJS_RESERVED`]), or one naming a value `exports` in any -/// scope. A `declare`d or type-only declaration of such a name is erased, so it does not +/// its helpers are (see [`COMMONJS_RESERVED`]), or one naming a value `exports` or +/// `__vite_ssr_…` (the module runner's names) in any scope. A `declare`d or type-only declaration of such a name is erased, so it does not /// count. fn lowers_to_commonjs( path: &Path, @@ -2831,9 +2831,11 @@ fn lowers_to_commonjs( .all(|&name| scoping.get_root_binding(name.into()).is_none_or(erased)) // `exports.x` is written inside functions too, where a parameter or local // named `exports` would take it. - && scoping - .symbol_ids() - .all(|symbol| scoping.symbol_name(symbol) != "exports" || erased(symbol)) + && scoping.symbol_ids().all(|symbol| { + let name = scoping.symbol_name(symbol); + // The runner's helpers are found by their `__vite_ssr_` names. + (name != "exports" && !name.starts_with("__vite_ssr_")) || erased(symbol) + }) } } @@ -2952,21 +2954,15 @@ fn top_level_bindings<'s>(statement: &'s Statement<'_>) -> (Vec<&'s str>, bool) /// Runs after the runner transform has hoisted the imports and exports and dropped the /// imports' awaits. The caller has ruled out `import.meta` and top-level await. fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program<'a>) { - struct Rewrite<'a, 's> { + struct Rewrite<'a> { ast: AstBuilder<'a>, - scoping: &'s Scoping, } - impl<'a> VisitMut<'a> for Rewrite<'a, '_> { + impl<'a> VisitMut<'a> for Rewrite<'a> { fn visit_expression(&mut self, expression: &mut Expression<'a>) { walk_mut::walk_expression(self, expression); if let Expression::CallExpression(call) = expression && call.callee.is_specific_id(SSR_DYNAMIC_IMPORT) - // The runner's helper is a free variable; a parameter of that name is not it. - && !matches!(&call.callee, Expression::Identifier(callee) - if callee.reference_id.get().is_some_and(|reference| { - self.scoping.get_reference(reference).symbol_id().is_some() - })) && let Some(source) = call.arguments.first_mut().and_then(Argument::as_expression_mut) { @@ -2982,12 +2978,7 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< } fn visit_identifier_reference(&mut self, ident: &mut IdentifierReference<'a>) { - // The runner's exports object is a free variable; a binding of the module's own - // under that name (`function f(__vite_ssr_exports__) {}`) keeps it. - let bound = ident.reference_id.get().is_some_and(|reference| { - self.scoping.get_reference(reference).symbol_id().is_some() - }); - if ident.name == SSR_EXPORTS && !bound { + if ident.name == SSR_EXPORTS { ident.name = "exports".into(); } } @@ -3360,10 +3351,9 @@ fn lower_runner_to_commonjs<'a>(allocator: &'a Allocator, program: &mut Program< ExportedBindings { allocator, scoping: &scoping, members: &members }.visit_program(program); } if let Some(default_name) = &default_name { - RenameDefault { name: allocator.alloc_str(default_name), scoping: &scoping } - .visit_program(program); + RenameDefault { name: allocator.alloc_str(default_name) }.visit_program(program); } - Rewrite { ast: AstBuilder::new(allocator), scoping: &scoping }.visit_program(program); + Rewrite { ast: AstBuilder::new(allocator) }.visit_program(program); } const SSR_EXPORT_DEFAULT: &str = "__vite_ssr_export_default__"; @@ -3700,20 +3690,13 @@ impl<'a> VisitMut<'a> for ExportedBindings<'_, 'a> { } /// References to the default export's binding name its hoisted declaration instead. -struct RenameDefault<'a, 's> { +struct RenameDefault<'a> { name: &'a str, - scoping: &'s Scoping, } -impl<'a> VisitMut<'a> for RenameDefault<'a, '_> { +impl<'a> VisitMut<'a> for RenameDefault<'a> { fn visit_identifier_reference(&mut self, ident: &mut IdentifierReference<'a>) { - // A binding of the module's own under that name (a parameter, say) keeps it. - let generated = self.scoping.get_root_binding(SSR_EXPORT_DEFAULT.into()); - let shadowed = ident.reference_id.get().is_some_and(|reference| { - let symbol = self.scoping.get_reference(reference).symbol_id(); - symbol.is_some() && symbol != generated - }); - if ident.name == SSR_EXPORT_DEFAULT && !shadowed { + if ident.name == SSR_EXPORT_DEFAULT { ident.name = self.name.into(); } } @@ -6359,16 +6342,6 @@ mod tests { ); } - // A binding of the module's own named like the runner's exports object keeps it. - let code = run( - "cjs/shadow.ts", - "export function echo(__vite_ssr_exports__: number) { return __vite_ssr_exports__; }\n", - ); - // `lowered` counts any `__vite_ssr_` name as runner output left behind. - assert!(code.contains("exports, \"__esModule\""), "{code}"); - assert!(code.contains("return __vite_ssr_exports__;"), "{code}"); - assert!(code.contains("exports.echo = echo;"), "{code}"); - // A `var` declared again has its getter after the first declaration, where an // import cycle can read what the first initializer set. let code = run( @@ -6380,22 +6353,6 @@ mod tests { assert!(code.find("var x = 1;") < Some(getter), "{code}"); assert!(Some(getter) < code.find("var x;"), "{code}"); - // A parameter named like the runner's default binding keeps its reads. - let code = run( - "cjs/default-shadow.ts", - "export default function (__vite_ssr_export_default__: number) {\n return __vite_ssr_export_default__;\n}\n", - ); - assert!(code.contains("function default_1(__vite_ssr_export_default__)"), "{code}"); - assert!(code.contains("return __vite_ssr_export_default__;"), "{code}"); - - // A parameter named like the runner's `import()` helper is called, not imported. - let code = run( - "cjs/import-shadow.ts", - "export function f(__vite_ssr_dynamic_import__: any) {\n return __vite_ssr_dynamic_import__(\"node:path\");\n}\n", - ); - assert!(code.contains("exports, \"__esModule\""), "{code}"); - assert!(code.contains("return __vite_ssr_dynamic_import__(\"node:path\");"), "{code}"); - // A side-effect import is a bare `require()`, as `tsc` emits it. let code = run("cjs/side.ts", "import './b';\nexport const n = 1;\n"); assert!(lowered(&code) && code.contains("require(\"./b\");"), "{code}"); @@ -6456,6 +6413,23 @@ mod tests { "cjs/nested-exports.ts", "export let n = 0;\nexport function inc(exports: { n: number }) { n++; }\n", ), + // The compiled output finds the runner's helpers by their names. + ( + "cjs/runner-import.ts", + "const __vite_ssr_import__ = (_: string) => 42;\nexport const answer = __vite_ssr_import__('node:path');\n", + ), + ( + "cjs/runner-exports.ts", + "export function echo(__vite_ssr_exports__: number) { return __vite_ssr_exports__; }\n", + ), + ( + "cjs/runner-default.ts", + "export default function (__vite_ssr_export_default__: number) { return 1; }\n", + ), + ( + "cjs/runner-dynamic.ts", + "export function f(__vite_ssr_dynamic_import__: any) { return __vite_ssr_dynamic_import__('x'); }\n", + ), // `tsc` itself emits an ES module for these. ("esnext/lib.ts", "export const n = 1;\n"), ("preserve/lib.ts", "export const n = 1;\n"), From 868725a173554210bdcec005a66b5e41815dbbd7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 11 Nov 2026 04:12:44 +0800 Subject: [PATCH 23/23] fix(core): find the source an explicit `./dep.js` names before any completion Node.js finds `./dep.js` finds `dep.ts` first whatever Node.js completed it with, a `dep.js.json` file as well as a `dep.js/` directory, as the ES module resolver does. Co-Authored-By: Claude Opus 5.5 --- packages/core/register.mjs | 7 ++++--- .../integrate-vitest/__tests__/commonjs-lowering.spec.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/core/register.mjs b/packages/core/register.mjs index b0058eb6..3636329e 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -258,10 +258,11 @@ Module._resolveFilename = function oxcResolveFilename(request, parent, isMain, o const named = requested(request, parent.filename, resolved); const file = !throughDirectory(request, parent.filename, resolved); // An extensionless request takes the ES module resolver's extension order first; - // the aliases of the extension Node.js added come after it. `./foo.js` that Node.js - // completed with a `foo.js/` directory still finds `foo.ts` first. + // the aliases of the extension Node.js added come after it. `./foo.js` finds + // `foo.ts` first, whatever Node.js completed it with: a `foo.js/` directory, or a + // `foo.js.json` file. source = - (file || DIRECTORY_ONLY.test(request) + (DIRECTORY_ONLY.test(request) ? undefined : sourceBeside(resolvePath(dirname(parent.filename), request))) ?? directoryLinkSource(request, parent.filename, resolved) ?? diff --git a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts index 0a7e4a94..5fa15bce 100644 --- a/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts +++ b/packages/integrate-vitest/__tests__/commonjs-lowering.spec.ts @@ -450,6 +450,15 @@ describe("a TypeScript file in a CommonJS package", () => { expect(run(root, "./main.ts")).toContain("where: foo.ts foo.js/index.js\n"); }); + test("finds the source `./dep.js` names before a `dep.js.json` Node.js completes it with", () => { + const root = fixture({ + "dep.js.json": JSON.stringify({ where: "dep.js.json" }), + "dep.ts": 'export const where: string = "dep.ts";\n', + "main.ts": 'import { where } from "./dep.js";\nconsole.log("where:", where);\n', + }); + expect(run(root, "./main.ts")).toContain("where: dep.ts\n"); + }); + test("resolves `.` as the directory only, never a file beside it", () => { const root = fixture({ "proj/main.ts": 'import { where } from ".";\nconsole.log("where:", where);\n',