Bug report (original): microsoft/playwright#41371
Minimal reproduction of two consecutive Playwright regressions in the
synchronous ESM loader (module.registerHooks()) affecting pnpm workspace
monorepos.
Upstream issue: microsoft/playwright#41371
Status: Fixed in 1.61.1
The new synchronous ESM loader (module.registerHooks(), introduced in
PR #40891) broke module
resolution for extensionless TypeScript subpath imports across pnpm
workspace symlinks.
When an ESM workspace package ("type": "module") does an extensionless
subpath import of a sibling workspace package (e.g.
import { greet } from '@repro/shared/lib/text.utils'), the sync loader
resolves the importing file to its real path and passes a file:// URL to
Node's native ESM resolver. That resolver only tries .js/.mjs/.cjs — it
never adds .ts — so the import fails even though the .ts file exists.
Error: Cannot find module '.../packages/core/node_modules/@repro/shared/lib/text.utils'
imported from .../packages/core/lib/conversations.ts
Did you mean to import "file:///.../packages/shared/lib/text.utils.ts"?
Status: Not yet fixed at time of writing
When playwright.config.mts (explicit ESM via .mts extension) imports
from a workspace package that is also transitively required in CJS context
(e.g. from global-setup.ts), the shared compilation cache returns the
ESM-compiled module source for the later CJS require() call.
Node then tries to execute ESM syntax (export, import) inside a CommonJS
context and throws a SyntaxError.
In packages/playwright/src/transform/compilationCache.ts:
memoryCache = new Map<filename, { codePath }>();
The in-process memoryCache is keyed only by filename. The on-disk cache
uses a hash that includes an "esm" vs "no_esm" flag, so disk entries are
distinct. But once an ESM-compiled result is read from disk and stored in
memoryCache, all subsequent calls to getFromCompilationCache() for that
filename — regardless of whether CJS or ESM compilation was requested — hit the
memory cache and return the wrong (ESM) source.
Sequence of events:
playwright.config.mtsis loaded as ESM viaeval(import(...)).- Node's ESM resolver resolves
@repro/env→ follows the pnpm symlink → real pathpackages/env/index.ts(nonode_modulesin path). shouldTransform('packages/env/index.ts')returnstrue.- Playwright's
loadhook compiles it as ESM (babel withmoduleUrlset), writes to disk at<esm-hash>.js, and inserts intomemoryCache['packages/env/index.ts']. global-setup.ts(CJS —apps/e2ehas no"type": "module") is loaded. It transitively requires@repro/env.- Playwright's
loadhook is called again with CJS conditions (require, module-sync). getFromCompilationCache('packages/env/index.ts', cjsHash)checksmemoryCachefirst → cache hit → returns the ESM-compiled source.loadreturns{ format: "commonjs", source: <ESM code> }.- Node tries to execute
export const env = …in CJS mode →SyntaxError: Unexpected token 'export'(orCannot use import statement outside a moduleif the cached code hasimportstatements).
Warning: Failed to load the ES module: .../packages/env/index.ts.
Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension.
SyntaxError: Unexpected token 'export'
at ../../../packages/core/lib/conversations.ts:1
> 1 | import { env } from '@repro/env';
| ^
The memoryCache key should include the compilation mode (ESM vs CJS), e.g.:
// Instead of:
memoryCache.set(filename, entry);
// Use:
const cacheKey = `${filename}:${moduleUrl ? 'esm' : 'cjs'}`;
memoryCache.set(cacheKey, entry);| Version | Command | Result |
|---|---|---|
1.61.0 |
mise run test |
❌ Cannot find module (Bug 1) |
1.61.0 |
mise run test-workaround |
✅ passes |
1.61.1 |
mise run test |
❌ SyntaxError: Unexpected token 'export' (Bug 2) |
1.61.1 |
mise run test-workaround |
✅ passes |
All of the following must be true simultaneously:
- Playwright
1.61.1on Node 22+ (somodule.registerHooksis used). playwright.config.mts(or.mjs) explicitly imports from a workspace package — the import must not be stripped by the TypeScript/babel transform (i.e. the import must actually be used in the config body).- The imported workspace package has
"type": "module"in itspackage.json. - The same workspace package is also transitively required (CJS context)
from
globalSetup. - A pnpm workspace where the package is symlinked directly to
packages/<name>(real path has nonode_modulessegment), soshouldTransform()returnstrueand the ESM load is intercepted.
.
├── mise.toml # node 24.16.0, pnpm 11.5.3
├── package.json # workspace root ("type": "module")
├── pnpm-workspace.yaml
├── packages/
│ ├── shared/ # @repro/shared ("type": "module")
│ │ └── lib/text.utils.ts # exports greet()
│ ├── core/ # @repro/core ("type": "module")
│ │ └── lib/conversations.ts # imports from @repro/shared & @repro/env
│ └── env/ # @repro/env ("type": "module", exports map)
│ └── index.ts # exports { env } — no workspace deps
└── apps/
└── e2e/ # @repro/e2e (CJS — no "type")
├── playwright.config.mts # imports { env } from @repro/env (used in baseURL)
├── global-setup.ts # imports { dbUrl } from @repro/core → @repro/env
└── tests/basic.spec.ts
Why this triggers the cache collision:
- Config (
playwright.config.mts) → ESM import →@repro/envcompiled as ESM → cached - Global setup (
global-setup.ts) → CJS require chain →@repro/env→ cache hit returns ESM code → SyntaxError
mise install # installs node 24.16.0 and pnpm 11.5.3
pnpm installDo not use npx, pnpm exec, or pnpm run. Run commands directly via
mise run or mise exec --.
mise run testFails with SyntaxError: Unexpected token 'export'.
mise run test-workaroundRuns with PLAYWRIGHT_FORCE_ASYNC_LOADER=1 (reverts to the old async loader),
which passes because the esmLoader worker uses a separate memoryCache from
the main thread, so the ESM-compiled code never contaminates the CJS require
chain.
- Original regression issue: microsoft/playwright#41371
- Sync loader PR: microsoft/playwright#40891
This reproduction and README were produced by an AI agent.