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

49 lines
1.3 KiB
TypeScript

'use client'
import { useEffect, useState, useRef } from 'react'
import { valueA } from './dep-a'
import { valueB } from './dep-b'
const evaluatedAt = Date.now()
export default function DepAcceptArrayPage() {
const [depAValue, setDepAValue] = useState(valueA)
const [depBValue, setDepBValue] = useState(valueB)
const [acceptCallCount, setAcceptCallCount] = useState(0)
const [evalTime, setEvalTime] = useState<number | null>(null)
const mountedRef = useRef(false)
useEffect(() => {
mountedRef.current = true
setEvalTime(evaluatedAt)
return () => {
mountedRef.current = false
}
}, [])
useEffect(() => {
if (import.meta.turbopackHot) {
import.meta.turbopackHot.accept(['./dep-a', './dep-b'], () => {
// ESM bindings are automatically re-imported before this callback runs,
// so `valueA` and `valueB` already reflect the updated modules.
if (mountedRef.current) {
setDepAValue(valueA)
setDepBValue(valueB)
setAcceptCallCount((c) => c + 1)
}
})
}
}, [])
return (
<div>
<p id="dep-a-value">{depAValue}</p>
<p id="dep-b-value">{depBValue}</p>
{evalTime !== null && (
<p id="parent-eval-time">Parent Evaluated At: {evalTime}</p>
)}
<p id="accept-call-count">Accept Calls: {acceptCallCount}</p>
</div>
)
}