From 5282f0d394fa9495eea22fb317a17ee2075b6693 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 18 May 2026 13:57:20 +0100 Subject: [PATCH 1/6] fix(test): use synchronous module customization hooks Node 26 deprecates `module.register()` in favour of the synchronous `module.registerHooks()` API. Prefer the synchronous hooks when available: they run in-process, need no preflight round-trip and no port transport. Fall back to the asynchronous loader on older Node, and expose `PLAYWRIGHT_FORCE_ASYNC_LOADER` as an opt-out. Fixes: https://github.com/microsoft/playwright/issues/40868 --- .../playwright/src/common/esmLoaderHost.ts | 18 +++- .../playwright/src/transform/esmLoaderSync.ts | 84 +++++++++++++++++++ .../playwright/src/transform/transform.ts | 24 ++++-- tests/playwright-test/loader.spec.ts | 5 +- 4 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 packages/playwright/src/transform/esmLoaderSync.ts diff --git a/packages/playwright/src/common/esmLoaderHost.ts b/packages/playwright/src/common/esmLoaderHost.ts index b426810e4d50f..6821bb565f184 100644 --- a/packages/playwright/src/common/esmLoaderHost.ts +++ b/packages/playwright/src/common/esmLoaderHost.ts @@ -18,9 +18,11 @@ import url from 'url'; import { addToCompilationCache, serializeCompilationCache } from '../transform/compilationCache'; import { PortTransport } from '../transform/portTransport'; -import { singleTSConfig, transformConfig } from '../transform/transform'; +import * as esmLoaderSync from '../transform/esmLoaderSync'; +import { setNeedsPreflightAndPirates, singleTSConfig, transformConfig } from '../transform/transform'; let loaderChannel: PortTransport | undefined; +let esmLoaderRegistered = false; export function registerESMLoader() { // Opt-out switch. @@ -32,10 +34,18 @@ export function registerESMLoader() { if ('Bun' in globalThis) return true; - if (loaderChannel) + if (esmLoaderRegistered) return true; - const register = require('node:module').register; + const nodeModule = require('node:module'); + + if (nodeModule.registerHooks && !process.env.PLAYWRIGHT_FORCE_ASYNC_LOADER) { + nodeModule.registerHooks({ resolve: esmLoaderSync.resolve, load: esmLoaderSync.load }); + esmLoaderRegistered = true; + return true; + } + + const register = nodeModule.register; if (!register) return false; @@ -46,6 +56,8 @@ export function registerESMLoader() { transferList: [port2], }); loaderChannel = createPortTransport(port1); + esmLoaderRegistered = true; + setNeedsPreflightAndPirates(); return true; } diff --git a/packages/playwright/src/transform/esmLoaderSync.ts b/packages/playwright/src/transform/esmLoaderSync.ts new file mode 100644 index 0000000000000..13457a7979fce --- /dev/null +++ b/packages/playwright/src/transform/esmLoaderSync.ts @@ -0,0 +1,84 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import url from 'url'; + +import { currentFileDepsCollector } from './compilationCache'; +import { resolveHook, shouldTransform, transformHook } from './transform'; +import { fileIsModule } from '../util'; + +export function resolve(specifier: string, context: { parentURL?: string }, nextResolve: Function) { + if (context.parentURL && context.parentURL.startsWith('file://')) { + const filename = url.fileURLToPath(context.parentURL); + const resolved = resolveHook(filename, specifier); + // Pass the resolved absolute path (not a file:// URL) to nextResolve: unlike the + // ESM-only asynchronous loader, these hooks also serve require(), whose default + // resolver rejects file:// specifiers but accepts absolute paths for both kinds. + if (resolved !== undefined) + specifier = resolved; + } + const result = nextResolve(specifier, context); + if (result?.url && result.url.startsWith('file://')) + currentFileDepsCollector()?.add(url.fileURLToPath(result.url)); + return result; +} + +// non-js files have undefined +// some js files have null +// {module/commonjs}-typescript are changed to {module,commonjs} because we handle typescript ourselves +// plain 'typescript' (a require()-d .ts with module kind not yet determined) maps to null, +// so the module kind is computed below via fileIsModule(). +const kSupportedFormats = new Map([ + ['commonjs', 'commonjs'], + ['module', 'module'], + ['commonjs-typescript', 'commonjs'], + ['module-typescript', 'module'], + ['typescript', null], + [null, null], + [undefined, undefined] +]); + +export function load(moduleUrl: string, context: { format?: string }, nextLoad: Function) { + // Bail out for wasm, json, etc. + if (!kSupportedFormats.has(context.format)) + return nextLoad(moduleUrl, context); + + // Bail for built-in modules. + if (!moduleUrl.startsWith('file://')) + return nextLoad(moduleUrl, context); + + const filename = url.fileURLToPath(moduleUrl); + + // Bail for node_modules. + if (!shouldTransform(filename)) + return nextLoad(moduleUrl, context); + + // Output format is required, so we determine it manually when unknown. + const format = kSupportedFormats.get(context.format) || (fileIsModule(filename) ? 'module' : 'commonjs'); + + const code = fs.readFileSync(filename, 'utf-8'); + // Pass `moduleUrl` only for ESM. For CommonJS we omit it so that babel + // down-transpiles `import`/`export` to `require`/`exports`. + const transformed = transformHook(code, filename, format === 'module' ? moduleUrl : undefined); + + // shortCircuit is required to designate no more loaders should be called. + return { + format, + source: transformed.code, + shortCircuit: true, + }; +} diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 9811d97eabedf..9f8445c903ebc 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -61,6 +61,12 @@ export function setTransformConfig(config: TransformConfig) { _externalMatcher = createFileMatcher(_transformConfig.external); } +let _needsPreflightAndPirates = false; + +export function setNeedsPreflightAndPirates() { + _needsPreflightAndPirates = true; +} + export function transformConfig(): TransformConfig { return _transformConfig; } @@ -276,11 +282,15 @@ export async function requireOrImport(file: string) { const fileName = url.pathToFileURL(file); const esmImport = () => eval(`import(${JSON.stringify(fileName)})`); - // For ESM imports, issue a preflight to populate the compilation cache with the - // source maps. This allows inline test() calls to resolve wrapFunctionWithLocation. - await eval(`import(${JSON.stringify(fileName + '.esm.preflight')})`) - .catch((error: any) => debugTest('Failed to load preflight for ' + file + ', source maps may be missing for errors thrown during loading.', error)) - .finally(nextTask); + // For ESM imports handled by the asynchronous loader, issue a preflight to populate + // the compilation cache with the source maps. This allows inline test() calls to + // resolve wrapFunctionWithLocation. The synchronous loader populates the cache + // in-process, so no preflight is needed. + if (_needsPreflightAndPirates) { + await eval(`import(${JSON.stringify(fileName + '.esm.preflight')})`) + .catch((error: any) => debugTest('Failed to load preflight for ' + file + ', source maps may be missing for errors thrown during loading.', error)) + .finally(nextTask); + } // Compilation cache, which includes source maps, is populated in a post task. // When importing a module results in an error, the very next access to `error.stack` @@ -308,6 +318,9 @@ function installTransformIfNeeded() { installSourceMapSupport(); + if (!_needsPreflightAndPirates) + return; + const originalResolveFilename = (Module as any)._resolveFilename; function resolveFilename(this: any, specifier: string, parent: Module, ...rest: any[]) { if (parent) { @@ -319,7 +332,6 @@ function installTransformIfNeeded() { } (Module as any)._resolveFilename = resolveFilename; - // Hopefully, one day we can migrate to synchronous loader hooks instead, similar to our esmLoader... addHook((code, filename) => { return transformHook(code, filename).code; }, shouldTransform, ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cjs', '.cts']); diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts index fcc56e0c79dfa..97c0f364101cf 100644 --- a/tests/playwright-test/loader.spec.ts +++ b/tests/playwright-test/loader.spec.ts @@ -1196,7 +1196,7 @@ test('should compose with a custom ESM loader before playwright', { expect(1 + 1).toBe(2); }); `, - }); + }, {}, { PLAYWRIGHT_FORCE_ASYNC_LOADER: '1' }); expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); @@ -1259,6 +1259,7 @@ test('should compose with a custom ESM loader after playwright', { `, }, {}, { NODE_OPTIONS: `--import ${url.pathToFileURL(testInfo.outputPath('register-loader.mjs')).toString()}`, + PLAYWRIGHT_FORCE_ASYNC_LOADER: '1', }); expect(result.exitCode).toBe(0); @@ -1309,7 +1310,7 @@ test('preflight should survive faulty ESM loader ahead of playwright', { expect(1 + 1).toBe(2); }); `, - }, {}, { DEBUG: 'pw:test' }); + }, {}, { DEBUG: 'pw:test', PLAYWRIGHT_FORCE_ASYNC_LOADER: '1' }); expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); From efebb01bfb9ef66647986b365a1257f876f8c356 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 18 May 2026 16:02:49 +0100 Subject: [PATCH 2/6] fix(test): resolve TypeScript reporters with synchronous loader The synchronous module customization hooks do not intercept the `require.resolve(id, { paths })` form, so `require.resolve()` of a TypeScript reporter (or any extensionless TypeScript specifier) failed. Register dummy loaders for our extensions so the default CommonJS resolver considers them; the `load` hook still does the transformation. Fixes: https://github.com/microsoft/playwright/issues/40868 --- packages/playwright/src/transform/transform.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 9f8445c903ebc..df835a5b646b2 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -318,8 +318,16 @@ function installTransformIfNeeded() { installSourceMapSupport(); - if (!_needsPreflightAndPirates) + if (!_needsPreflightAndPirates) { + // The synchronous module customization hooks intercept `require()`, but not the + // `require.resolve(id, { paths })` form. The mere presence of these dummy loaders + // teaches the default resolver that our extensions should be considered. + // Hopefully, one day `registerHooks({ resolve })` will also handle `require.resolve()`. + const extensions = (Module as any)._extensions; + for (const ext of ['.ts', '.cts', '.tsx', '.jsx']) + extensions[ext] = extensions['.js']; return; + } const originalResolveFilename = (Module as any)._resolveFilename; function resolveFilename(this: any, specifier: string, parent: Module, ...rest: any[]) { From 21f374309b3b081bd3d3e9026a362d4f2923777d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 19 May 2026 11:25:17 +0100 Subject: [PATCH 3/6] fix(test): avoid recording package.json as a test dependency The synchronous module customization hooks intercept require(), so the require() of package.json in folderIsModule() was recorded as a dependency of the test file being loaded, confusing --only-changed. Read and parse package.json directly instead. --- packages/playwright/src/util.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index f21353c8796be..6f6063de5295a 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -274,12 +274,23 @@ export function fileIsModule(file: string): boolean { return folderIsModule(folder); } +const packageJsonIsModuleCache = new Map(); + function folderIsModule(folder: string): boolean { const packageJsonPath = getPackageJsonPath(folder); if (!packageJsonPath) return false; - // Rely on `require` internal caching logic. - return require(packageJsonPath).type === 'module'; + // Note: do not `require()` the package.json here to avoid running + // our resolve hook from inside itself. + if (!packageJsonIsModuleCache.has(packageJsonPath)) { + let isModule = false; + try { + isModule = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).type === 'module'; + } catch { + } + packageJsonIsModuleCache.set(packageJsonPath, isModule); + } + return packageJsonIsModuleCache.get(packageJsonPath)!; } const packageJsonMainFieldCache = new Map(); From c478875a1309f3ea3262bee54449cce7f1046e4f Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 20 May 2026 14:48:35 +0100 Subject: [PATCH 4/6] fix(test): collect CJS deps into a fresh set then merge The synchronous module customization hooks' resolve hook pre-populates the global deps collector, so collectCJSDependencies, which uses the collector itself as its visited set, would skip transitive deps under any child the hook had already added. To make matters worse, Node short- circuits the resolve hook for already-resolved (parent_dir, request) pairs via its relativeResolveCache, so transitive deps shared between sibling test files are silently missed. Walk the CJS module tree into a fresh set first, then merge into the global collector. --- packages/playwright/src/transform/transform.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index df835a5b646b2..84f36db334b82 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -303,8 +303,17 @@ export async function requireOrImport(file: string) { const depsCollector = currentFileDepsCollector(); if (depsCollector) { const module = require.cache[file]; - if (module) - collectCJSDependencies(module, depsCollector); + if (module) { + // Walk the CJS module tree into a fresh set, then merge into the global + // collector. We can't pass `depsCollector` directly: the sync loader's + // resolve hook pre-populates it, and Node short-circuits that hook for + // already-resolved (parent_dir, request) pairs via its relativeResolveCache, + // so the walker would skip transitive deps the hook missed. + const cjsDeps = new Set(); + collectCJSDependencies(module, cjsDeps); + for (const dep of cjsDeps) + depsCollector.add(dep); + } } return result; } From 2f317344590b417227e91285fcd9814310fc5e26 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 21 May 2026 09:19:44 +0100 Subject: [PATCH 5/6] fix(test): install pirates lazily when async loader is registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When loadReporter(null, …) runs before loadConfig — as the test server does on runGlobalSetup — installTransformIfNeeded() was called before registerESMLoader() got a chance to set _needsPreflightAndPirates. That took the sync-only branch and skipped pirates for good, since transformInstalled became true. On Node where the async loader is picked (Node 20, or PLAYWRIGHT_FORCE_ASYNC_LOADER=1), pirates never intercepted require() afterwards, so a CJS globalSetup.js with mixed `import`/`module.exports` syntax got routed through Node 23+'s require(esm) auto-detection and threw "module is not defined". Split the install into a light step (extension shortcuts + source map support) and a pirates step. Run the light step unconditionally — when pirates installs later it overrides the same extensions. Have setNeedsPreflightAndPirates() retroactively install pirates if the light step already ran. Fixes: https://github.com/microsoft/playwright/issues/40868 --- .../playwright/src/transform/transform.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 84f36db334b82..a4d3d95bdf2ea 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -65,6 +65,11 @@ let _needsPreflightAndPirates = false; export function setNeedsPreflightAndPirates() { _needsPreflightAndPirates = true; + // If the light install ran before the loader decision was settled + // (e.g. loadReporter(null, …) is called before loadConfig), pirates + // never got installed. Make sure it does now. + if (transformInstalled) + installPirates(); } export function transformConfig(): TransformConfig { @@ -319,6 +324,7 @@ export async function requireOrImport(file: string) { } let transformInstalled = false; +let piratesInstalled = false; function installTransformIfNeeded() { if (transformInstalled) @@ -327,16 +333,23 @@ function installTransformIfNeeded() { installSourceMapSupport(); - if (!_needsPreflightAndPirates) { - // The synchronous module customization hooks intercept `require()`, but not the - // `require.resolve(id, { paths })` form. The mere presence of these dummy loaders - // teaches the default resolver that our extensions should be considered. - // Hopefully, one day `registerHooks({ resolve })` will also handle `require.resolve()`. - const extensions = (Module as any)._extensions; - for (const ext of ['.ts', '.cts', '.tsx', '.jsx']) - extensions[ext] = extensions['.js']; + // The synchronous module customization hooks intercept `require()`, but not the + // `require.resolve(id, { paths })` form. The mere presence of these dummy loaders + // teaches the default resolver that our extensions should be considered. + // Hopefully, one day `registerHooks({ resolve })` will also handle `require.resolve()`. + // When pirates is installed below, it overrides these shortcuts with full transforms. + const extensions = (Module as any)._extensions; + for (const ext of ['.ts', '.cts', '.tsx', '.jsx']) + extensions[ext] = extensions['.js']; + + if (_needsPreflightAndPirates) + installPirates(); +} + +function installPirates() { + if (piratesInstalled) return; - } + piratesInstalled = true; const originalResolveFilename = (Module as any)._resolveFilename; function resolveFilename(this: any, specifier: string, parent: Module, ...rest: any[]) { From a87ebcc7178469dbd711fa506c12e5fa25532391 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 21 May 2026 11:06:33 +0100 Subject: [PATCH 6/6] refactor(test): fold esmLoaderHost into transform, push state reactively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge esmLoaderHost.ts into transform.ts so the transform layer owns the full ESM loader lifecycle: register Node hooks, push state to the worker thread, pair local deps collection with the worker notification, and pull the cache back. transform.installTransformIfNeeded() now calls registerESMLoader() itself, so any requireOrImport entry settles the sync-vs-async decision before deciding whether to install pirates — removing the ordering bug at the source. setSingleTSConfig and setTransformConfig are async and push changes to the loader thread inline. registerESMLoader seeds the worker with the current snapshot when it falls back to the async loader. loadConfig no longer needs configureESMLoader/configureESMLoaderTransformConfig — the setters do it themselves. loaderChannel is the single source of truth for "we have a worker": installTransformIfNeeded checks it to pick the CJS-hooks path, requireOrImport checks it for the preflight. --- .../playwright/src/common/configLoader.ts | 23 +-- .../playwright/src/common/esmLoaderHost.ts | 105 -------------- packages/playwright/src/common/index.ts | 1 - packages/playwright/src/common/testLoader.ts | 16 +-- packages/playwright/src/loader/loaderMain.ts | 4 +- packages/playwright/src/runner/loaderHost.ts | 4 +- .../playwright/src/transform/esmLoader.ts | 4 +- .../playwright/src/transform/transform.ts | 131 ++++++++++++------ utils/build/build.js | 9 +- 9 files changed, 110 insertions(+), 187 deletions(-) delete mode 100644 packages/playwright/src/common/esmLoaderHost.ts diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index b7bd1c9b1bbe2..0af93470ee5aa 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -20,9 +20,8 @@ import path from 'path'; import { isRegExp } from '@isomorphic/rtti'; import { requireOrImport, setSingleTSConfig, setTransformConfig } from '../transform/transform'; -import { errorWithFile, fileIsModule } from '../util'; +import { errorWithFile } from '../util'; import { FullConfigInternal } from './config'; -import { configureESMLoader, configureESMLoaderTransformConfig, registerESMLoader } from './esmLoaderHost'; import { addToCompilationCache } from '../transform/compilationCache'; import type { ConfigLocation } from './config'; @@ -101,17 +100,8 @@ async function loadUserConfig(location: ConfigLocation): Promise { } export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false, metadata?: Config['metadata']): Promise { - // 0. Setup ESM loader if needed. - if (!registerESMLoader()) { - // In Node.js < 18, complain if the config file is ESM. Historically, we would restart - // the process with --loader, but now we require newer Node.js. - if (location.resolvedConfigFile && fileIsModule(location.resolvedConfigFile)) - throw errorWithFile(location.resolvedConfigFile, `Playwright requires Node.js 18.19 or higher to load esm modules. Please update your version of Node.js.`); - } - - // 1. Setup tsconfig; configure ESM loader with tsconfig and compilation cache. - setSingleTSConfig(overrides?.tsconfig); - await configureESMLoader(); + // 1. Set the initial tsconfig before loading the config file. + await setSingleTSConfig(overrides?.tsconfig); // 2. Load and validate playwright config. const userConfig = await loadUserConfig(location); @@ -129,12 +119,9 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI const babelPlugins = (userConfig as any)['@playwright/test']?.babelPlugins || []; const external = userConfig.build?.external || []; const jsxImportSource = path.dirname(require.resolve('playwright')); - setTransformConfig({ babelPlugins, external, jsxImportSource }); + await setTransformConfig({ babelPlugins, external, jsxImportSource }); if (!overrides?.tsconfig) - setSingleTSConfig(fullConfig?.singleTSConfigPath); - - // 4. Send transform options to ESM loader. - await configureESMLoaderTransformConfig(); + await setSingleTSConfig(fullConfig?.singleTSConfigPath); return fullConfig; } diff --git a/packages/playwright/src/common/esmLoaderHost.ts b/packages/playwright/src/common/esmLoaderHost.ts deleted file mode 100644 index 6821bb565f184..0000000000000 --- a/packages/playwright/src/common/esmLoaderHost.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import url from 'url'; - -import { addToCompilationCache, serializeCompilationCache } from '../transform/compilationCache'; -import { PortTransport } from '../transform/portTransport'; -import * as esmLoaderSync from '../transform/esmLoaderSync'; -import { setNeedsPreflightAndPirates, singleTSConfig, transformConfig } from '../transform/transform'; - -let loaderChannel: PortTransport | undefined; -let esmLoaderRegistered = false; - -export function registerESMLoader() { - // Opt-out switch. - if (process.env.PW_DISABLE_TS_ESM) - return true; - - // Transpilation in `bun` is not necessary, and trying to register a hook would cause issues. - // https://github.com/oven-sh/bun/issues/8222#issuecomment-3665364677 - if ('Bun' in globalThis) - return true; - - if (esmLoaderRegistered) - return true; - - const nodeModule = require('node:module'); - - if (nodeModule.registerHooks && !process.env.PLAYWRIGHT_FORCE_ASYNC_LOADER) { - nodeModule.registerHooks({ resolve: esmLoaderSync.resolve, load: esmLoaderSync.load }); - esmLoaderRegistered = true; - return true; - } - - const register = nodeModule.register; - if (!register) - return false; - - const { port1, port2 } = new MessageChannel(); - // register will wait until the loader is initialized. - register(url.pathToFileURL(require.resolve('../transform/esmLoader.js')), { - data: { port: port2 }, - transferList: [port2], - }); - loaderChannel = createPortTransport(port1); - esmLoaderRegistered = true; - setNeedsPreflightAndPirates(); - return true; -} - -function createPortTransport(port: MessagePort) { - return new PortTransport(port, async (method, params) => { - if (method === 'pushToCompilationCache') - addToCompilationCache(params.cache); - }); -} - -export async function startCollectingFileDeps() { - if (!loaderChannel) - return; - await loaderChannel.send('startCollectingFileDeps', {}); -} - -export async function stopCollectingFileDeps(file: string) { - if (!loaderChannel) - return; - await loaderChannel.send('stopCollectingFileDeps', { file }); -} - -export async function incorporateCompilationCache() { - if (!loaderChannel) - return; - // This is needed to gather dependency information from the esm loader - // that is populated from the resolve hook. We do not need to push - // this information proactively during load, but gather it at the end. - const result = await loaderChannel.send('getCompilationCache', {}); - addToCompilationCache(result.cache); -} - -export async function configureESMLoader() { - if (!loaderChannel) - return; - await loaderChannel.send('setSingleTSConfig', { tsconfig: singleTSConfig() }); - await loaderChannel.send('addToCompilationCache', { cache: serializeCompilationCache() }); -} - -export async function configureESMLoaderTransformConfig() { - if (!loaderChannel) - return; - await loaderChannel.send('setSingleTSConfig', { tsconfig: singleTSConfig() }); - await loaderChannel.send('setTransformConfig', { config: transformConfig() }); -} diff --git a/packages/playwright/src/common/index.ts b/packages/playwright/src/common/index.ts index 225e229b8b56f..a3d8b2fdef574 100644 --- a/packages/playwright/src/common/index.ts +++ b/packages/playwright/src/common/index.ts @@ -17,7 +17,6 @@ export * as cc from '../transform/compilationCache'; export * as config from './config'; export * as configLoader from './configLoader'; -export * as esm from './esmLoaderHost'; export * as fixtures from './fixtures'; export * as ipc from './ipc'; export * as poolBuilder from './poolBuilder'; diff --git a/packages/playwright/src/common/testLoader.ts b/packages/playwright/src/common/testLoader.ts index 52eb49a5f6a6b..6e982cb220cd6 100644 --- a/packages/playwright/src/common/testLoader.ts +++ b/packages/playwright/src/common/testLoader.ts @@ -17,11 +17,9 @@ import path from 'path'; import util from 'util'; -import * as esmLoaderHost from './esmLoaderHost'; import { isWorkerProcess, setCurrentlyLoadingFileSuite } from '../globals'; import { Suite } from './test'; -import { startCollectingFileDeps, stopCollectingFileDeps } from '../transform/compilationCache'; -import { requireOrImport } from '../transform/transform'; +import { requireOrImport, startCollectingFileDeps, stopCollectingFileDeps } from '../transform/transform'; import { filterStackTrace } from '../util'; import type { TestError } from '../../types/testReporter'; @@ -42,10 +40,8 @@ export async function loadTestFile(file: string, config: FullConfigInternal, tes suite._tags = [...config.config.tags]; setCurrentlyLoadingFileSuite(suite); - if (!isWorkerProcess()) { - startCollectingFileDeps(); - await esmLoaderHost.startCollectingFileDeps(); - } + if (!isWorkerProcess()) + await startCollectingFileDeps(); try { await requireOrImport(file); cachedFileSuites.set(file, suite); @@ -55,10 +51,8 @@ export async function loadTestFile(file: string, config: FullConfigInternal, tes testErrors.push(serializeLoadError(file, e)); } finally { setCurrentlyLoadingFileSuite(undefined); - if (!isWorkerProcess()) { - stopCollectingFileDeps(file); - await esmLoaderHost.stopCollectingFileDeps(file); - } + if (!isWorkerProcess()) + await stopCollectingFileDeps(file); } { diff --git a/packages/playwright/src/loader/loaderMain.ts b/packages/playwright/src/loader/loaderMain.ts index e8f95d0918705..f8b97e29d1566 100644 --- a/packages/playwright/src/loader/loaderMain.ts +++ b/packages/playwright/src/loader/loaderMain.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { cc, configLoader, esm, FullConfigInternal, ipc, poolBuilder, ProcessRunner, testLoader } from '../common'; +import { cc, configLoader, FullConfigInternal, ipc, poolBuilder, ProcessRunner, testLoader, transform } from '../common'; import type { TestError } from '../../types/testReporter'; @@ -43,7 +43,7 @@ export class LoaderMain extends ProcessRunner { } async getCompilationCacheFromLoader() { - await esm.incorporateCompilationCache(); + await transform.incorporateCompilationCache(); return cc.serializeCompilationCache(); } } diff --git a/packages/playwright/src/runner/loaderHost.ts b/packages/playwright/src/runner/loaderHost.ts index d08587a16ca10..1f41e5d7f22e1 100644 --- a/packages/playwright/src/runner/loaderHost.ts +++ b/packages/playwright/src/runner/loaderHost.ts @@ -15,7 +15,7 @@ */ import { ProcessHost } from './processHost'; -import { cc, esm, FullConfigInternal, ipc, poolBuilder, test as testNs, testLoader } from '../common'; +import { cc, FullConfigInternal, ipc, poolBuilder, test as testNs, testLoader, transform } from '../common'; import type { TestError } from '../../types/testReporter'; @@ -40,7 +40,7 @@ export class InProcessLoaderHost { } async stop() { - await esm.incorporateCompilationCache(); + await transform.incorporateCompilationCache(); } } diff --git a/packages/playwright/src/transform/esmLoader.ts b/packages/playwright/src/transform/esmLoader.ts index 57e8ab6cbf7bc..ed90b79403b38 100644 --- a/packages/playwright/src/transform/esmLoader.ts +++ b/packages/playwright/src/transform/esmLoader.ts @@ -105,12 +105,12 @@ function initialize(data: { port: MessagePort }) { function createTransport(port: MessagePort) { return new PortTransport(port, async (method, params) => { if (method === 'setSingleTSConfig') { - setSingleTSConfig(params.tsconfig); + await setSingleTSConfig(params.tsconfig); return; } if (method === 'setTransformConfig') { - setTransformConfig(params.config); + await setTransformConfig(params.config); return; } diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index a4d3d95bdf2ea..c88c228f4188a 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -18,15 +18,15 @@ import fs from 'fs'; import Module from 'module'; import path from 'path'; import url from 'url'; - import crypto from 'crypto'; - import sourceMapSupport from 'source-map-support'; import { loadTsConfig } from './tsconfig-loader'; import { libPath, packageJSON } from '../package'; import { createFileMatcher, debugTest, fileIsModule, resolveImportSpecifierAfterMapping } from '../util'; -import { belongsToNodeModules, currentFileDepsCollector, getFromCompilationCache, installSourceMapSupport } from './compilationCache'; +import * as cc from './compilationCache'; +import * as esmLoaderSync from './esmLoaderSync'; import { addHook } from './pirates'; +import { PortTransport } from './portTransport'; import type { BabelPlugin, BabelTransformFunction } from './babelBundle'; import type { Location } from '../../types/testReporter'; @@ -56,35 +56,20 @@ let _transformConfig: TransformConfig = { let _externalMatcher: Matcher = () => false; -export function setTransformConfig(config: TransformConfig) { +export async function setTransformConfig(config: TransformConfig) { _transformConfig = config; _externalMatcher = createFileMatcher(_transformConfig.external); -} - -let _needsPreflightAndPirates = false; - -export function setNeedsPreflightAndPirates() { - _needsPreflightAndPirates = true; - // If the light install ran before the loader decision was settled - // (e.g. loadReporter(null, …) is called before loadConfig), pirates - // never got installed. Make sure it does now. - if (transformInstalled) - installPirates(); -} - -export function transformConfig(): TransformConfig { - return _transformConfig; + if (loaderChannel) + await loaderChannel.send('setTransformConfig', { config }); } let _singleTSConfigPath: string | undefined; let _singleTSConfig: ParsedTsConfigData[] | undefined; -export function setSingleTSConfig(value: string | undefined) { +export async function setSingleTSConfig(value: string | undefined) { _singleTSConfigPath = value; -} - -export function singleTSConfig(): string | undefined { - return _singleTSConfigPath; + if (loaderChannel) + await loaderChannel.send('setSingleTSConfig', { tsconfig: value }); } function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData { @@ -223,7 +208,7 @@ export function resolveHook(filename: string, specifier: string): string | undef export function shouldTransform(filename: string): boolean { if (_externalMatcher(filename)) return false; - return !belongsToNodeModules(filename); + return !cc.belongsToNodeModules(filename); } let transformData: Map; @@ -240,7 +225,7 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? const pluginsPrologue = _transformConfig.babelPlugins; const pluginsEpilogue = hasPreprocessor ? [[process.env.PW_TEST_SOURCE_TRANSFORM!]] as BabelPlugin[] : []; const hash = calculateHash(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue); - const { cachedCode, addToCache, serializedCache } = getFromCompilationCache(filename, hash, moduleUrl); + const { cachedCode, addToCache, serializedCache } = cc.getFromCompilationCache(filename, hash, moduleUrl); if (cachedCode !== undefined) return { code: cachedCode, serializedCache }; @@ -291,7 +276,7 @@ export async function requireOrImport(file: string) { // the compilation cache with the source maps. This allows inline test() calls to // resolve wrapFunctionWithLocation. The synchronous loader populates the cache // in-process, so no preflight is needed. - if (_needsPreflightAndPirates) { + if (loaderChannel) { await eval(`import(${JSON.stringify(fileName + '.esm.preflight')})`) .catch((error: any) => debugTest('Failed to load preflight for ' + file + ', source maps may be missing for errors thrown during loading.', error)) .finally(nextTask); @@ -305,7 +290,7 @@ export async function requireOrImport(file: string) { return await esmImport().finally(nextTask); } const result = require(file); - const depsCollector = currentFileDepsCollector(); + const depsCollector = cc.currentFileDepsCollector(); if (depsCollector) { const module = require.cache[file]; if (module) { @@ -324,33 +309,31 @@ export async function requireOrImport(file: string) { } let transformInstalled = false; -let piratesInstalled = false; function installTransformIfNeeded() { if (transformInstalled) return; transformInstalled = true; - installSourceMapSupport(); + registerESMLoader(); + cc.installSourceMapSupport(); - // The synchronous module customization hooks intercept `require()`, but not the - // `require.resolve(id, { paths })` form. The mere presence of these dummy loaders - // teaches the default resolver that our extensions should be considered. + // Async ESM loader ony covers "import", so install CJS hooks to cover "require". + if (loaderChannel) { + installCJSHooks(); + return; + } + + // Sync hooks intercept `require()`, but not the `require.resolve(id, { paths })` form. + // The mere presence of these dummy loaders teaches the default resolver that our extensions + // should be considered. // Hopefully, one day `registerHooks({ resolve })` will also handle `require.resolve()`. - // When pirates is installed below, it overrides these shortcuts with full transforms. const extensions = (Module as any)._extensions; for (const ext of ['.ts', '.cts', '.tsx', '.jsx']) extensions[ext] = extensions['.js']; - - if (_needsPreflightAndPirates) - installPirates(); } -function installPirates() { - if (piratesInstalled) - return; - piratesInstalled = true; - +function installCJSHooks() { const originalResolveFilename = (Module as any)._resolveFilename; function resolveFilename(this: any, specifier: string, parent: Module, ...rest: any[]) { if (parent) { @@ -369,7 +352,7 @@ function installPirates() { const collectCJSDependencies = (module: Module, dependencies: Set) => { module.children.forEach(child => { - if (!belongsToNodeModules(child.filename) && !dependencies.has(child.filename)) { + if (!cc.belongsToNodeModules(child.filename) && !dependencies.has(child.filename)) { dependencies.add(child.filename); collectCJSDependencies(child, dependencies); } @@ -408,3 +391,65 @@ function isRelativeSpecifier(specifier: string) { async function nextTask() { return new Promise(resolve => setTimeout(resolve, 0)); } + +let loaderChannel: PortTransport | undefined; + +function registerESMLoader() { + // Opt-out switch. + if (process.env.PW_DISABLE_TS_ESM) + return; + + // Transpilation in `bun` is not necessary, and trying to register a hook would cause issues. + // https://github.com/oven-sh/bun/issues/8222#issuecomment-3665364677 + if ('Bun' in globalThis) + return; + + const nodeModule = require('node:module'); + + if (nodeModule.registerHooks && !process.env.PLAYWRIGHT_FORCE_ASYNC_LOADER) { + nodeModule.registerHooks({ resolve: esmLoaderSync.resolve, load: esmLoaderSync.load }); + return; + } + + if (!nodeModule.register) + return; + + const { port1, port2 } = new MessageChannel(); + // register will wait until the loader is initialized. The path is relative to + // the bundle output layout (lib/common/index.js → ../transform/esmLoader.js), + // not the source layout — esmLoader.js is its own esbuild entry point. + nodeModule.register(url.pathToFileURL(require.resolve('../transform/esmLoader.js')), { + data: { port: port2 }, + transferList: [port2], + }); + loaderChannel = new PortTransport(port1, async (method, params) => { + if (method === 'pushToCompilationCache') + cc.addToCompilationCache(params.cache); + }); + // Seed the loader thread with the state accumulated so far. Subsequent updates + // are pushed by setSingleTSConfig() / setTransformConfig() / startCollectingFileDeps(). + void loaderChannel.send('setSingleTSConfig', { tsconfig: _singleTSConfigPath }); + void loaderChannel.send('setTransformConfig', { config: _transformConfig }); + void loaderChannel.send('addToCompilationCache', { cache: cc.serializeCompilationCache() }); +} + +export async function startCollectingFileDeps() { + cc.startCollectingFileDeps(); + if (loaderChannel) + await loaderChannel.send('startCollectingFileDeps', {}); +} + +export async function stopCollectingFileDeps(file: string) { + cc.stopCollectingFileDeps(file); + if (loaderChannel) + await loaderChannel.send('stopCollectingFileDeps', { file }); +} + +export async function incorporateCompilationCache() { + if (!loaderChannel) + return; + // Gather dependency information from the esm loader that was populated by + // its resolve hook. We don't push this proactively during load — only at end. + const result = await loaderChannel.send('getCompilationCache', {}); + cc.addToCompilationCache(result.cache); +} diff --git a/utils/build/build.js b/utils/build/build.js index 3ef3339cd13d1..964c2a620fdb8 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -645,9 +645,11 @@ function assertCoreBundleHasNoNodeModules() { steps.push(new CustomCallbackStep(assertCoreBundleHasNoNodeModules)); // playwright/lib/transform/esmLoader.js — bundled ESM loader registered by -// common/esmLoaderHost.ts via node:module register. Output sits next to -// babelBundle.js so source-relative `./babelBundle` matches the runtime -// sibling external. +// transform.ts via node:module register. Output sits next to babelBundle.js +// so source-relative `./babelBundle` matches the runtime sibling external. +// '../transform/esmLoader.js' is also external: transform.ts has a +// require.resolve() for it (dead code in this bundle, but esbuild still +// parses it). { const playwrightSrc = filePath('packages/playwright/src'); steps.push(new EsbuildStep({ @@ -659,6 +661,7 @@ steps.push(new CustomCallbackStep(assertCoreBundleHasNoNodeModules)); 'playwright-core/*', '../package', '../globals', + '../transform/esmLoader.js', ], plugins: [], }, [playwrightSrc]));