Files
73e654f678 fix(turbopack): preserve resolveExtensions priority in read_matches fast path (#91856)
### What?

Fix a bug where Turbopack ignored the priority order of
`resolveExtensions` when both a platform-specific variant (e.g.
`Component.web.tsx`) and a default variant (`Component.tsx`) exist for
the same module path. The first-listed extension should always win, but
it often didn't.

### Why?

Turbopack's `read_matches` function iterates over constant alternatives
extracted from a resolve pattern (one per extension in
`resolveExtensions`). It uses `enumerate()` to assign a priority `index`
to each alternative — lower index = higher priority — so the correct
file is picked after sorting.

The `until_end=true` branch (direct file existence check) correctly
threaded `index` through. But the `until_end=false` branch (subdirectory
descent, the path taken when the pattern contains a `/` before the
dynamic suffix) had a **hardcoded `0`** instead of `index`:

```rust
// before — all alternatives get the same priority
nested.push((0, read_matches(fs_path.clone(), ...)));

// after — priority matches the extension's position in resolveExtensions
nested.push((index, read_matches(fs_path.clone(), ...)));
```

With all alternatives at priority 0, the tie-breaking sort was
effectively arbitrary. In practice `Component.tsx` frequently beat
`Component.web.tsx` even when `.web.tsx` was listed first, breaking
platform-specific module overrides — a common React Native Web / Expo
Web pattern.

### How?

**One-line Rust fix** in
`turbopack/crates/turbopack-core/src/resolve/pattern.rs`: replace the
hardcoded `0` with the loop variable `index` in the `until_end=false`
branch of the fast path.

**Turbopack unit tests** (`turbopack-core`):
- `test_custom_extensions_web_before_default` — `.web.tsx` wins when
listed first in `resolveExtensions`
- `test_custom_extensions_fallback_when_web_missing` — correctly falls
back to `.tsx` when `.web.tsx` is absent
- Extended `test_read_matches` with `extension_ordering` and
`subpath_ordering` cases covering both the `until_end=true` and
`until_end=false` branches

**Next.js e2e test** (extended `test/e2e/app-dir/resolve-extensions/`):
- Added `PlatformComponent.web.tsx` (renders `"hello web platform"`) and
`PlatformComponent.tsx` (renders `"hello default platform"`)
- `.web.tsx` is placed before `.tsx` in `resolveExtensions` in
`next.config.js` (for both Turbopack and webpack)
- New assertion: SSR'd HTML must contain `"hello web platform"` and must
**not** contain `"hello default platform"`

Fixes #91117

- [x] Tests added. See:
https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs

---------

Co-authored-by: Tobias Koppers <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-03-26 11:28:06 -07:00

36 lines
1.3 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
describe('turbo-resolve-extensions', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should SSR', async () => {
const res = await next.fetch('/')
const html = await res.text()
expect(html).toContain('hello world')
expect(html).toContain('hello server')
expect(html).toContain('hello image 1')
expect(html).toContain('hello image 2')
})
it('should work using browser', async () => {
const browser = await next.browser('/')
const text = await browser.elementByCss('body').text()
expect(text).toContain('hello world')
expect(text).toContain('hello client')
})
it('should respect resolveExtensions priority (.web.tsx before .tsx)', async () => {
// When both PlatformComponent.web.tsx and PlatformComponent.tsx exist,
// the .web.tsx variant must win because it appears first in resolveExtensions.
// This guards against a Turbopack bug where the alternative index was
// hardcoded to 0 in the fast path, collapsing all alternatives to the same
// priority and making the winner non-deterministic (usually wrong).
const res = await next.fetch('/')
const html = await res.text()
expect(html).toContain('hello web platform')
expect(html).not.toContain('hello default platform')
})
})