Skip to content

Commit c573e8c

Browse files
wbinnssmithclaude
andcommitted
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 <wbinnssmith@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57b8f65 commit c573e8c

File tree

6 files changed

+155
-3
lines changed

6 files changed

+155
-3
lines changed

packages/next/src/server/dev/hot-reloader-turbopack.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1854,6 +1854,11 @@ export async function createHotReloaderTurbopack(
18541854
__next__clear_chunk_cache__()
18551855
}
18561856

1857+
// Reset the server HMR handler registry. All server runtime chunks are
1858+
// cleared from require.cache above; when they're next required they'll
1859+
// re-register into this Map and reinstall the routing dispatcher.
1860+
;(globalThis as any).__turbopack_server_hmr_handlers__ = new Map()
1861+
18571862
// Clear all edge contexts
18581863
await clearAllModuleContexts()
18591864

test/development/app-dir/server-hmr/server-hmr.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,43 @@ describe('server-hmr', () => {
157157
})
158158

159159
describe('metadata route hmr', () => {
160+
itTurbopackDev(
161+
'does not prevent page hmr when metadata route has been loaded',
162+
async () => {
163+
// Load the manifest route first. This causes the manifest runtime to
164+
// register its __turbopack_server_hmr_apply__ on globalThis, which
165+
// would overwrite the page's handler if the multi-cast registry is
166+
// broken.
167+
await next.fetch('/manifest.webmanifest')
168+
169+
const browser = await next.browser('/module-preservation')
170+
171+
// Patch the page to a known unique string regardless of prior test state
172+
await next.patchFile('app/module-preservation/page.tsx', (content) =>
173+
content.replace(/<p id="greeting">.*?<\/p>/, () => {
174+
return '<p id="greeting">metadata-hmr-test-initial</p>'
175+
})
176+
)
177+
178+
await retry(async () => {
179+
const text = await browser.elementByCss('#greeting').text()
180+
expect(text).toBe('metadata-hmr-test-initial')
181+
})
182+
183+
await next.patchFile('app/module-preservation/page.tsx', (content) =>
184+
content.replace(
185+
'metadata-hmr-test-initial',
186+
'metadata-hmr-test-updated'
187+
)
188+
)
189+
190+
await retry(async () => {
191+
const text = await browser.elementByCss('#greeting').text()
192+
expect(text).toBe('metadata-hmr-test-updated')
193+
})
194+
}
195+
)
196+
160197
it('reflects manifest.ts changes on fetch/refresh', async () => {
161198
const initial = await next
162199
.fetch('/manifest.webmanifest')

turbopack/crates/turbopack-ecmascript-runtime/js/src/nodejs/dev/dev-nodejs.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,65 @@ function __turbopack_server_hmr_apply__(update: NodeJsHmrPayload): boolean {
3333
}
3434
}
3535

36-
globalThis.__turbopack_server_hmr_apply__ = __turbopack_server_hmr_apply__
36+
// Turbopack produces one server runtime per chunking context (e.g.
37+
// server/chunks/ssr/ for pages, server/chunks/ for route handlers), each with
38+
// its own moduleFactories. We keep a globalThis Map from __filename to handler
39+
// so updates are routed only to runtimes whose chunkPrefix matches the update's
40+
// chunk paths, skipping unnecessary eval() calls. Map.set() naturally replaces
41+
// stale entries when a runtime file is re-evaluated after require.cache eviction.
42+
43+
type HmrHandlerEntry = {
44+
handler: (update: NodeJsHmrPayload) => boolean
45+
/** Output directory relative to RUNTIME_ROOT, e.g. "server/chunks/ssr". */
46+
chunkPrefix: string
47+
}
48+
49+
const handlers: Map<string, HmrHandlerEntry> =
50+
globalThis.__turbopack_server_hmr_handlers__ ?? new Map()
51+
52+
const chunkPrefix = path.relative(RUNTIME_ROOT, path.dirname(__filename))
53+
54+
if (handlers.size === 0) {
55+
// First registration in this generation: install the routing dispatcher.
56+
globalThis.__turbopack_server_hmr_apply__ = (
57+
update: NodeJsHmrPayload
58+
): boolean => {
59+
const registry: Map<string, HmrHandlerEntry> =
60+
globalThis.__turbopack_server_hmr_handlers__ ?? new Map()
61+
const updateChunkPaths = Object.keys(update.instruction?.chunks ?? {})
62+
63+
const toCall: HmrHandlerEntry[] = []
64+
if (updateChunkPaths.length === 0) {
65+
for (const entry of registry.values()) toCall.push(entry)
66+
} else {
67+
const seen = new Set<string>()
68+
for (const chunkPath of updateChunkPaths) {
69+
const dir = path.dirname(chunkPath)
70+
for (const [key, entry] of registry) {
71+
if (dir === entry.chunkPrefix && !seen.has(key)) {
72+
seen.add(key)
73+
toCall.push(entry)
74+
}
75+
}
76+
}
77+
}
78+
79+
let applied = false
80+
for (const { handler } of toCall) {
81+
try {
82+
if (handler(update)) applied = true
83+
} catch (err) {
84+
console.error('[Server HMR] Handler error:', err)
85+
}
86+
}
87+
88+
return applied
89+
}
90+
}
91+
92+
globalThis.__turbopack_server_hmr_handlers__ = handlers
93+
94+
handlers.set(__filename, {
95+
handler: __turbopack_server_hmr_apply__,
96+
chunkPrefix,
97+
})

turbopack/crates/turbopack-ecmascript-runtime/js/src/nodejs/runtime/nodejs-globals.d.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ declare global {
99
var __turbopack_module_factories__: ModuleFactories
1010
var __turbopack_module_cache__: Record<ModuleId, any>
1111
var __turbopack_runtime_modules__: Set<ModuleId>
12+
/**
13+
* Shared registry of per-runtime server HMR handler entries, keyed by the
14+
* runtime file's __filename. Map.set() naturally replaces stale entries when
15+
* a runtime file is re-evaluated. Reset to undefined by the hot-reloader on
16+
* full cache reset.
17+
*/
18+
// NodeJsHmrPayload is only in scope inside the concatenated runtime chunk, not
19+
// in this .d.ts file, so the handler parameter must use any here.
20+
21+
var __turbopack_server_hmr_handlers__:
22+
| Map<string, { handler: (update: any) => boolean; chunkPrefix: string }>
23+
| undefined
1224
}
1325

1426
export {}

turbopack/crates/turbopack-tests/tests/snapshot/debug-ids/node/output/[turbopack]_runtime.js

Lines changed: 38 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

turbopack/crates/turbopack-tests/tests/snapshot/debug-ids/node/output/[turbopack]_runtime.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)