Files
Tobias KoppersandGitHub 014b9987fa Turbopack: implement module.hot.accept(deps, cb) and module.hot.decline(deps) (#90443)
### What?

Implements dependency-level HMR accept and decline for Turbopack,
covering both ESM (`import.meta.turbopackHot`) and CJS (`module.hot`)
modules.

Previously Turbopack only supported self-accept (`module.hot.accept()`
with no arguments) and self-decline. This PR adds the full
dependency-targeted API:

- `module.hot.accept(dep, cb)` / `import.meta.turbopackHot.accept(dep,
cb)` — single dep
- `module.hot.accept([depA, depB], cb)` /
`import.meta.turbopackHot.accept([depA, depB], cb)` — array of deps
- `module.hot.decline(dep)` / `import.meta.turbopackHot.decline(dep)` —
single dep
- `module.hot.decline([depA, depB])` /
`import.meta.turbopackHot.decline([depA, depB])` — array of deps

### Why?

Libraries like `react-refresh` and user code often need to accept
updates for specific dependencies rather than self-accepting the entire
module. Without this, those HMR patterns fall back to full page reloads
in Turbopack.

### How?

**Runtime** (`turbopack-ecmascript-runtime`):
- Extended `HotState` with `acceptedDependencies`,
`acceptedErrorHandlers`, and `declinedDependencies` maps (keyed by
`ModuleId`)
- Updated `hot.accept()` and `hot.decline()` to handle string and array
signatures
- Updated `getAffectedModuleEffects` to check accepted/declined
dependencies when propagating updates through the module graph
- Added `'declined'` effect type that throws an `UpdateApplyError`
- Track `outdatedDependencies` (map of parent → set of updated deps)
alongside `outdatedModules`
- In the apply phase, invoke per-dependency accept callbacks with the
correct outdated deps
- Use `Set` for `outdatedDependencies` dedup (O(1) vs O(n) lookups)

**Compiler** (`turbopack-ecmascript`):
- Added `ModuleHotReferenceAssetReference` — a single asset reference
type for both accept and decline deps, with shared resolve logic for ESM
(`esm_resolve`) and CJS (`cjs_resolve`)
- Added `ModuleHotReferenceCodeGen` — generates code that replaces dep
string literals with resolved module IDs at compile time
- ESM binding auto-update: when an ESM module accepts a dependency that
it also `import`s, the compiler wraps the accept callback to re-import
the namespace variable (`__TURBOPACK__imported__module__<id> =
__turbopack_import__(<id>)`) before the user callback runs, so ESM
bindings reflect updated values without needing `require()`
- Added `import.meta.turbopackHot` as the ESM equivalent of
`module.hot`, with TypeScript type declarations in
`packages/next/types/global.d.ts`
- Static analysis extracts dep strings from
`module.hot.accept`/`decline` calls; non-analyzable deps emit a warning
with distinct error codes (`TP1204` for accept, `TP1205` for decline)

**HMR gating** (`CompileTimeInfo`):
- Added `hot_module_replacement_enabled` flag to `CompileTimeInfo` to
gate recognition of `module.hot` and `import.meta.turbopackHot` as
well-known objects
- Without this flag, production builds would recognize
`module.hot.accept(...)` and generate HMR-specific code, leading to
runtime errors
- Flag set to `true` for dev servers and `false` for production builds
across Next.js and turbopack-cli entry points
- `import.meta.turbopackHot` getter is only emitted when HMR is enabled

**Server HMR** (`next-api`, `next-core`):
- Server-side `compile_time_info` now also sets
`hot_module_replacement_enabled` so that `module.hot` /
`import.meta.turbopackHot` are recognized during analysis of server
modules
- Server HMR requires the `--experimental-server-fast-refresh` CLI flag;
the flag is passed through `ProjectOptions` to the Rust side so
`server_compile_time_info` only enables HMR when appropriate

**Tests** (`test/development/app-dir/hmr-dep-accept/`):
- ESM single dep accept — verifies parent module is not re-evaluated,
accept callback fires, ESM bindings auto-update
- ESM array dep accept — same as above with `accept(['./dep-a',
'./dep-b'], cb)`
- CJS `module.hot.accept` — pure CJS dep observer pattern with `.cjs`
files
- Single dep decline — verifies full page reload occurs
- Array dep decline — verifies full page reload with
`decline(['./dep-a', './dep-b'])`
2026-03-09 10:51:17 -07:00

279 lines
9.2 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('hmr-dep-accept', () => {
const { next, isTurbopack, isNextDev } = nextTestSetup({
files: __dirname,
})
// Dependency accept/decline requires Turbopack dev mode
const itTurbopackDev = isTurbopack && isNextDev ? it : it.skip
describe('dependency accept', () => {
itTurbopackDev(
'parent accepts child dependency update without re-evaluating',
async () => {
const browser = await next.browser('/dep-accept')
// Wait for initial render and hydration (eval time only appears after useEffect)
await retry(async () => {
const text = await browser.elementByCss('#dep-value').text()
expect(text).toBe('initial')
})
await retry(async () => {
const text = await browser.elementByCss('#parent-eval-time').text()
expect(text).toMatch(/Parent Evaluated At: \d+/)
})
// Capture the parent evaluation timestamp
const parentEvalTime = await browser
.elementByCss('#parent-eval-time')
.text()
// Verify initial accept call count
const initialCallCount = await browser
.elementByCss('#accept-call-count')
.text()
expect(initialCallCount).toBe('Accept Calls: 0')
// Patch the dependency to change its exported value
await next.patchFile('app/dep-accept/dep.ts', (content) =>
content.replace("'initial'", "'updated'")
)
// Wait for the accept callback to fire and update the UI
await retry(async () => {
const text = await browser.elementByCss('#dep-value').text()
expect(text).toBe('updated')
})
// The accept callback should have been called
await retry(async () => {
const callCount = await browser
.elementByCss('#accept-call-count')
.text()
expect(callCount).toBe('Accept Calls: 1')
})
// The parent module should NOT have been re-evaluated
const newParentEvalTime = await browser
.elementByCss('#parent-eval-time')
.text()
expect(newParentEvalTime).toBe(parentEvalTime)
}
)
})
describe('dependency accept with array', () => {
itTurbopackDev(
'parent accepts multiple child dependencies via array',
async () => {
const browser = await next.browser('/dep-accept-array')
// Wait for initial render
await retry(async () => {
const text = await browser.elementByCss('#dep-a-value').text()
expect(text).toBe('initial-a')
})
await retry(async () => {
const text = await browser.elementByCss('#dep-b-value').text()
expect(text).toBe('initial-b')
})
await retry(async () => {
const text = await browser.elementByCss('#parent-eval-time').text()
expect(text).toMatch(/Parent Evaluated At: \d+/)
})
const parentEvalTime = await browser
.elementByCss('#parent-eval-time')
.text()
// Patch dep-a
await next.patchFile('app/dep-accept-array/dep-a.ts', (content) =>
content.replace("'initial-a'", "'updated-a'")
)
await retry(async () => {
const text = await browser.elementByCss('#dep-a-value').text()
expect(text).toBe('updated-a')
})
await retry(async () => {
const callCount = await browser
.elementByCss('#accept-call-count')
.text()
expect(callCount).toBe('Accept Calls: 1')
})
// Parent should NOT have been re-evaluated
const evalTimeAfterA = await browser
.elementByCss('#parent-eval-time')
.text()
expect(evalTimeAfterA).toBe(parentEvalTime)
// Patch dep-b
await next.patchFile('app/dep-accept-array/dep-b.ts', (content) =>
content.replace("'initial-b'", "'updated-b'")
)
await retry(async () => {
const text = await browser.elementByCss('#dep-b-value').text()
expect(text).toBe('updated-b')
})
await retry(async () => {
const callCount = await browser
.elementByCss('#accept-call-count')
.text()
expect(callCount).toBe('Accept Calls: 2')
})
// Parent should still NOT have been re-evaluated
const evalTimeAfterB = await browser
.elementByCss('#parent-eval-time')
.text()
expect(evalTimeAfterB).toBe(parentEvalTime)
}
)
})
describe('dependency accept via module.hot (CJS)', () => {
itTurbopackDev(
'CJS module registers module.hot.accept and receives dep updates',
async () => {
const browser = await next.browser('/dep-accept-cjs')
// Wait for initial render
await retry(async () => {
const text = await browser.elementByCss('#dep-value').text()
expect(text).toBe('initial')
})
await retry(async () => {
const text = await browser.elementByCss('#parent-eval-time').text()
expect(text).toMatch(/Parent Evaluated At: \d+/)
})
const parentEvalTime = await browser
.elementByCss('#parent-eval-time')
.text()
expect(await browser.elementByCss('#accept-call-count').text()).toBe(
'Accept Calls: 0'
)
// Patch the dependency
await next.patchFile('app/dep-accept-cjs/dep.cjs', (content) =>
content.replace("'initial'", "'updated'")
)
// dep-observer.cjs handles the update via module.hot.accept
await retry(async () => {
const text = await browser.elementByCss('#dep-value').text()
expect(text).toBe('updated')
})
await retry(async () => {
const callCount = await browser
.elementByCss('#accept-call-count')
.text()
expect(callCount).toBe('Accept Calls: 1')
})
// page.tsx should NOT have been re-evaluated (no full reload)
const newParentEvalTime = await browser
.elementByCss('#parent-eval-time')
.text()
expect(newParentEvalTime).toBe(parentEvalTime)
}
)
})
describe('dependency decline', () => {
itTurbopackDev(
'declining a dependency triggers full reload on update',
async () => {
const browser = await next.browser('/dep-decline')
// Wait for initial render
await retry(async () => {
const text = await browser.elementByCss('#dep-value').text()
expect(text).toBe('initial')
})
await retry(async () => {
const text = await browser.elementByCss('#parent-eval-time').text()
expect(text).toMatch(/Parent Evaluated At: \d+/)
})
const parentEvalTime = await browser
.elementByCss('#parent-eval-time')
.text()
// Patch the declined dependency
await next.patchFile('app/dep-decline/dep.ts', (content) =>
content.replace("'initial'", "'updated'")
)
// Since the dep is declined, the update should cause a full page reload.
// After reload, the page re-evaluates with the new dep value.
await retry(async () => {
const text = await browser.elementByCss('#dep-value').text()
expect(text).toBe('updated')
})
// The parent module SHOULD have been re-evaluated (full reload)
await retry(async () => {
const newParentEvalTime = await browser
.elementByCss('#parent-eval-time')
.text()
expect(newParentEvalTime).not.toBe(parentEvalTime)
})
}
)
})
describe('dependency decline with array', () => {
itTurbopackDev(
'declining multiple dependencies via array triggers full reload',
async () => {
const browser = await next.browser('/dep-decline-array')
// Wait for initial render
await retry(async () => {
const text = await browser.elementByCss('#dep-a-value').text()
expect(text).toBe('initial-a')
})
await retry(async () => {
const text = await browser.elementByCss('#dep-b-value').text()
expect(text).toBe('initial-b')
})
await retry(async () => {
const text = await browser.elementByCss('#parent-eval-time').text()
expect(text).toMatch(/Parent Evaluated At: \d+/)
})
const parentEvalTime = await browser
.elementByCss('#parent-eval-time')
.text()
// Patch dep-a (declined) — should trigger full reload
await next.patchFile('app/dep-decline-array/dep-a.ts', (content) =>
content.replace("'initial-a'", "'updated-a'")
)
await retry(async () => {
const text = await browser.elementByCss('#dep-a-value').text()
expect(text).toBe('updated-a')
})
// The parent module SHOULD have been re-evaluated (full reload)
await retry(async () => {
const newParentEvalTime = await browser
.elementByCss('#parent-eval-time')
.text()
expect(newParentEvalTime).not.toBe(parentEvalTime)
})
}
)
})
})