Skip to content

[Regression]: Sync ESM loader (registerHooks) fails to resolve extensionless .ts subpath imports across pnpm workspace symlinks #41371

Description

@AllySummers

Note: An AI agent (Cursor) helped me create the reproduction and this regression report. I have read this report in its entirety and reviewed the code in the repro repo.

Last Good Version

1.60.0

First Bad Version

1.61.0 (latest at time of writing)

Steps to reproduce

Minimal reproduction repo: https://github.com/AllySummers/playwright-symlink-bug

The repo is a pnpm workspace with three packages and the import chain
apps/e2e/tests/basic.spec.ts@repro/core/lib/conversations@repro/shared/lib/text.utils.

  1. Clone the repo.
  2. Install Node 24.16.0 and pnpm 11.5.3 via mise: mise install
  3. pnpm install
  4. mise run test
  5. The run fails during module loading (before any browser launches).

The conditions that together trigger the bug (removing any one hides it):

  • @playwright/test@1.61.0 on Node 22+ (so the synchronous
    module.registerHooks() loader activates).
  • The intermediate/leaf workspace packages are ESM ("type": "module").
  • They are imported via extensionless subpath specifiers pointing at raw
    .ts files (e.g. import { greet } from '@repro/shared/lib/text.utils'),
    with no exports/main field and no build step.
  • It's a pnpm workspace, so each package is a symlink whose real path
    differs from the symlink path.

Key files:

// packages/shared/package.json
{ "name": "@repro/shared", "private": true, "type": "module" }
// packages/shared/lib/text.utils.ts
export function greet(name: string) {
  return `Hello, ${name}`;
}
// packages/core/package.json
{
  "name": "@repro/core",
  "private": true,
  "type": "module",
  "dependencies": { "@repro/shared": "workspace:*" }
}
// packages/core/lib/conversations.ts
export { greet } from '@repro/shared/lib/text.utils';
// apps/e2e/tests/basic.spec.ts
import { test, expect } from '@playwright/test';
import { greet } from '@repro/core/lib/conversations';

test('greet returns expected string', () => {
  expect(greet('world')).toBe('Hello, world');
});

Workaround that pins the cause

Forcing the previous async loader makes the same tree pass:
(uses the seemingly undocumented PLAYWRIGHT_FORCE_ASYNC_LOADER=1 environment variable)

mise run test-workaround   # passes

Expected behavior

playwright test resolves the extensionless .ts subpath import across the
pnpm workspace symlink and runs the test — exactly as it did in 1.60.0 and as
it still does with PLAYWRIGHT_FORCE_ASYNC_LOADER=1. The test passes:

  ✓  1 apps/e2e/tests/basic.spec.ts:4:5 › greet returns expected string

  1 passed

Actual behavior

On 1.61.0 with the default synchronous loader, module resolution fails:

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"?

   at basic.spec.ts:2

  1 | import { test, expect } from '@playwright/test';
> 2 | import { greet } from '@repro/core/lib/conversations';
    | ^
  3 |
  4 | test('greet returns expected string', () => {
  5 |   expect(greet('world')).toBe('Hello, world');
    at Object.<anonymous> (/.../apps/e2e/tests/basic.spec.ts:2:1)
Error: No tests found

Note Playwright/Node's own "Did you mean…?" hint correctly identifies the
existing .ts file — resolution found the symlinked package, but the ESM
resolver never tries the .ts extension.

Result matrix

Configuration Result
@playwright/test@1.61.0, default sync loader ❌ fails (Cannot find module … /lib/text.utils)
@playwright/test@1.61.0 + PLAYWRIGHT_FORCE_ASYNC_LOADER=1 ✅ passes
@playwright/test@1.60.0, default loader ✅ passes

Reproduces on both macOS (arm64) and Linux CI (GitHub Actions, Ubuntu 24.04).

Additional context

The regression was introduced when the loader switched from the asynchronous
module.register() (out-of-process worker) to the synchronous
module.registerHooks({ resolve, load }) in PR
#40891 / commit
c758b150.

Likely cause: for a bare/subpath specifier the loader's resolve hook
(playwright/lib/common/index.js) returns undefined and defers to
nextResolve. Because the importing module is ESM, the importer is resolved to
its real path (packages/core/lib/conversations.ts) and Node's native ESM
resolver is handed a file:// URL; that resolver only attempts
.js/.mjs/.cjs and never .ts, so the extensionless TypeScript subpath
import fails. The async loader (still reachable via
PLAYWRIGHT_FORCE_ASYNC_LOADER=1) resolved these TypeScript subpath imports
correctly, which is why 1.60.0 and the forced-async path both pass.

The loader does perform TypeScript extension resolution for relative specifiers
(resolveImportSpecifierAfterMapping), but bare/subpath workspace specifiers
bypass that and fall through to Node's resolver.

Environment

System:
  OS: macOS 26.5.1
  CPU: (14) arm64 Apple M4 Pro
  Memory: 9.33 GB / 48.00 GB
Binaries:
  Node: 24.16.0 - mise (node 24.16.0)
  npm: 11.13.0
  pnpm: 11.5.3 - mise (pnpm 11.5.3)
IDEs:
  VSCode: 1.121.0
Languages:
  Bash: 5.3.9
npmPackages:
  @playwright/test: 1.61.0 => 1.61.0


Also reproduced on Linux CI (GitHub Actions, `ubuntu-2404` runner), Node `24.16.0`, pnpm `11.5.3`.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions