mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
### 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'])`
279 lines
9.2 KiB
TypeScript
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)
|
|
})
|
|
}
|
|
)
|
|
})
|
|
})
|