Files
next.js/test/development/app-dir/server-hmr/server-hmr.test.ts
1fe590fc5e fix(server-hmr): metadata routes overwrite page runtime HMR handler (#92273)
### What?

Fix server HMR becoming unresponsive after a metadata route is loaded in
the same Node.js process as an app page.

### Why?

Turbopack loads separate runtime chunks for app pages and metadata
routes (`robots.ts`, `sitemap.ts`, `manifest.ts`, `icon.tsx`, etc.) in
the same Node.js process. Each runtime chunk embeds `dev-nodejs.ts` and
produces a distinct `__turbopack_server_hmr_apply__` closure bound to
its own `moduleFactories` and `devModuleCache`.

Previously each runtime simply overwrote
`globalThis.__turbopack_server_hmr_apply__`, so the last chunk to load
silently won. Navigating to `/robots.txt` before an HMR update caused
the metadata route runtime to overwrite the page runtime's handler.
Subsequent HMR updates were dispatched only to the metadata route
runtime, which has no knowledge of the page module — the page appeared
frozen and stopped reflecting file changes.

### How?

Replace the bare assignment with a multicast registry:

1. Each runtime appends its own `__turbopack_server_hmr_apply__` handler
to `globalThis.__turbopack_server_hmr_handlers__[]`.
2. The first runtime to register installs a shared dispatcher as
`globalThis.__turbopack_server_hmr_apply__` that iterates all registered
handlers at call time (not install time), so newly loaded runtimes are
always included.
3. On full cache reset, `hot-reloader-turbopack.ts` resets
`__turbopack_server_hmr_handlers__` to `[]` so stale handlers from
evicted chunks don't accumulate into the next generation.

Because `dev-nodejs.ts` is embedded into the Turbopack binary via
`include_dir!`, this fix requires rebuilding the native binary.

### Tests

`test/development/app-dir/server-hmr/server-hmr.test.ts` — extended with
`metadata route hmr` tests that load a metadata route before patching a
page file, verifying HMR updates still reach the page runtime after a
second runtime chunk is loaded.

---------

Co-authored-by: Will Binns-Smith <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-04-06 18:31:59 -07:00

339 lines
11 KiB
TypeScript

import type { Response } from 'node-fetch'
import { join } from 'path'
import { nextTestSetup, FileRef } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('server-hmr', () => {
const { next, isTurbopack, isNextDev } = nextTestSetup({
files: __dirname,
})
// Server HMR is a Turbopack-only feature, only available in dev mode
const itTurbopackDev = isTurbopack && isNextDev ? it : it.skip
describe('module preservation', () => {
itTurbopackDev(
'does not re-evaluate an unmodified module when page module changes',
async () => {
const browser = await next.browser('/module-preservation')
// Wait for initial render with module timestamp
await retry(async () => {
const text = await browser.elementByCss('#module-eval-time').text()
expect(text).toMatch(/Module Evaluated At: \d+/)
})
// Capture the initial module evaluation timestamp
const initialModuleEvalTime = await browser
.elementByCss('#module-eval-time')
.text()
// Make a change to the page that doesn't affect the unmodified module
await next.patchFile('app/module-preservation/page.tsx', (content) =>
content.replace('hello world', 'hello universe')
)
// Wait for HMR to apply and verify the page updated
await retry(async () => {
const text = await browser.elementByCss('#greeting').text()
expect(text).toBe('hello universe')
})
// The unmodified module should NOT have been re-evaluated (same timestamp)
const newModuleEvalTime = await browser
.elementByCss('#module-eval-time')
.text()
expect(newModuleEvalTime).toBe(initialModuleEvalTime)
}
)
itTurbopackDev(
're-evaluates a module when the module itself changes',
async () => {
const browser = await next.browser('/module-preservation')
// Wait for initial render
await retry(async () => {
const text = await browser.elementByCss('#module-eval-time').text()
expect(text).toMatch(/Module Evaluated At: \d+/)
})
// Capture the initial module evaluation timestamp
const initialModuleEvalTime = await browser
.elementByCss('#module-eval-time')
.text()
// Make a change to the module itself to trigger re-evaluation
await next.patchFile('app/unmodified-module.ts', (content) =>
content.replace('_hmrTrigger = 0', '_hmrTrigger = 1')
)
// Wait for HMR to apply - the module should be re-evaluated
// and the timestamp should change
await retry(async () => {
// Refresh to trigger re-evaluation of changed modules
await browser.refresh()
const newModuleEvalTime = await browser
.elementByCss('#module-eval-time')
.text()
expect(newModuleEvalTime).not.toBe(initialModuleEvalTime)
})
}
)
})
describe('child module accept', () => {
itTurbopackDev(
'allows child modules to accept hot updates using module.hot.accept',
async () => {
const browser = await next.browser('/child-accept')
// Wait for initial render
await retry(async () => {
const text = await browser.elementByCss('#message').text()
expect(text).toBe('Initial message')
})
const initialEvalTime = await browser.elementByCss('#eval-time').text()
expect(initialEvalTime).toMatch(/Module evaluated at: \d+/)
// Make a change to the child module
// The child module calls module.hot.accept(), which allows it to
// accept updates. While pages auto-accept at the top level in server HMR,
// this test verifies that module.hot.accept() is available and functional
// in non-page, user-authored child modules.
await next.patchFile('app/child-module.ts', (content) =>
content.replace('Initial message', 'Updated message')
)
// Wait for HMR to apply - the child module should accept the update
await retry(async () => {
const text = await browser.elementByCss('#message').text()
expect(text).toBe('Updated message')
})
// The module should have been re-evaluated (new timestamp)
const newEvalTime = await browser.elementByCss('#eval-time').text()
expect(newEvalTime).not.toBe(initialEvalTime)
// Apply another update to verify the module continues to accept updates
await next.patchFile('app/child-module.ts', (content) =>
content.replace('Updated message', 'Second update')
)
await retry(async () => {
const text = await browser.elementByCss('#message').text()
expect(text).toBe('Second update')
})
}
)
})
describe('source maps', () => {
itTurbopackDev(
"stack frames from eval'd HMR modules point to original source locations",
async () => {
await next.fetch('/sourcemaps').catch(() => {})
await next.patchFile('app/sourcemaps/page.tsx', (content) =>
content.replace('hmr-trigger: 0', 'hmr-trigger: 1')
)
const outputLengthBeforeFetch = next.cliOutput.length
await next.fetch('/sourcemaps').catch(() => {})
await retry(async () => {
expect(next.cliOutput.slice(outputLengthBeforeFetch)).toContain(
'hmr-sourcemap-test-error'
)
})
const outputAfterHmr = next.cliOutput.slice(outputLengthBeforeFetch)
// Without proper sourcemaps, the stack frame doesn't include the accurate file number
expect(outputAfterHmr).toMatch(/page\.tsx:4:9/)
}
)
})
describe('metadata route hmr', () => {
itTurbopackDev(
'does not prevent page hmr when metadata route has been loaded',
async () => {
// Load the manifest route first. This causes the manifest runtime to
// register its __turbopack_server_hmr_apply__ on globalThis, which
// would overwrite the page's handler if the multi-cast registry is
// broken.
await next.fetch('/manifest.webmanifest')
const browser = await next.browser('/module-preservation')
// Patch the page to a known unique string regardless of prior test state
await next.patchFile('app/module-preservation/page.tsx', (content) =>
content.replace(/<p id="greeting">.*?<\/p>/, () => {
return '<p id="greeting">metadata-hmr-test-initial</p>'
})
)
await retry(async () => {
const text = await browser.elementByCss('#greeting').text()
expect(text).toBe('metadata-hmr-test-initial')
})
await next.patchFile('app/module-preservation/page.tsx', (content) =>
content.replace(
'metadata-hmr-test-initial',
'metadata-hmr-test-updated'
)
)
await retry(async () => {
const text = await browser.elementByCss('#greeting').text()
expect(text).toBe('metadata-hmr-test-updated')
})
}
)
it('reflects manifest.ts changes on fetch/refresh', async () => {
const initial = await next
.fetch('/manifest.webmanifest')
.then((res) => res.json())
expect(initial.name).toBe('Version 0')
await next.patchFile('app/manifest.ts', (content) =>
content.replace('Version 0', 'Version 1')
)
await retry(async () => {
const updated = await next
.fetch('/manifest.webmanifest')
.then((res) => res.json())
expect(updated.name).toBe('Version 1')
})
})
})
describe('route handler hmr', () => {
function getText(res: Response) {
return res.ok
? res.text()
: Promise.reject(
new Error('Failed to fetch route handler: ' + res.status)
)
}
it('reflects route handler changes on fetch/refresh', async () => {
const initial = await next.fetch('/api/hello').then(getText)
expect(initial).toBe('version: 0')
await next.patchFile('app/api/hello/route.ts', (content) =>
content.replace('version: 0', 'version: 1')
)
await retry(async () => {
const updated = await next.fetch('/api/hello').then(getText)
expect(updated).toBe('version: 1')
})
})
itTurbopackDev(
'does not re-evaluate an unmodified dependency when route changes',
async () => {
const initial = await next
.fetch('/api/with-dep')
.then((res) => res.json())
expect(initial.routeVersion).toBe('v1')
const initialDepEvaluatedAt = initial.depEvaluatedAt
// Change only the route module, not the dependency
await next.patchFile('app/api/with-dep/route.ts', (content) =>
content.replace("'v1'", "'v2'")
)
await retry(async () => {
const updated = await next
.fetch('/api/with-dep')
.then((res) => res.json())
// The route change should be reflected in the response
expect(updated.routeVersion).toBe('v2')
// The unmodified dependency should NOT have been re-evaluated
expect(updated.depEvaluatedAt).toBe(initialDepEvaluatedAt)
})
}
)
})
})
describe('server-hmr config opt-out', () => {
const { next, isTurbopack, isNextDev } = nextTestSetup({
files: {
app: new FileRef(join(__dirname, 'app')),
},
nextConfig: {
experimental: {
turbopackServerFastRefresh: false,
},
},
})
const itTurbopackDev = isTurbopack && isNextDev ? it : it.skip
itTurbopackDev(
're-evaluates unmodified dependencies when serverFastRefresh is disabled via config',
async () => {
const initial = await next
.fetch('/api/with-dep')
.then((res) => res.json())
expect(initial.routeVersion).toBe('v1')
const initialDepEvaluatedAt = initial.depEvaluatedAt
// Change only the route module, not the dependency
await next.patchFile('app/api/with-dep/route.ts', (content) =>
content.replace("'v1'", "'v2'")
)
await retry(async () => {
const updated = await next
.fetch('/api/with-dep')
.then((res) => res.json())
expect(updated.routeVersion).toBe('v2')
// With server HMR disabled, the dependency SHOULD be re-evaluated
// (full module graph is re-evaluated on changes)
expect(updated.depEvaluatedAt).not.toBe(initialDepEvaluatedAt)
})
}
)
})
describe('server-hmr CLI/config conflict warning', () => {
const { next, isNextDev } = nextTestSetup({
files: {
app: new FileRef(join(__dirname, 'app')),
},
nextConfig: {
experimental: {
turbopackServerFastRefresh: true,
},
},
startArgs: ['--no-server-fast-refresh'],
})
if (!isNextDev) {
it('should be skipped in production', () => {})
return
}
it('should warn when CLI flag conflicts with config', async () => {
// Trigger a page load so the server is fully started
await next.render('/')
expect(next.cliOutput).toContain(
'The CLI flag "--no-server-fast-refresh" conflicts with "experimental.turbopackServerFastRefresh: true" in your Next.js config. The CLI flag will take precedence.'
)
})
})