Skip to content

Commit c0edad2

Browse files
wbinnssmithclaude
andcommitted
Turbopack: exclude metadata routes from server HMR (#92034)
### What? Metadata routes (`manifest.ts`, `robots.ts`, `sitemap.ts`, `icon.tsx`, `apple-icon.tsx`, etc.) were not being hot-reloaded in Turbopack dev mode — changes to those files would not be reflected on subsequent requests until a full server restart. ### Why? PR #91466 extended `usesServerHmr = true` in `clearRequireCache()` (in `hot-reloader-turbopack.ts`) from `app-page` entries only to **all** `app`-type entries (pages + route handlers). The motivation was correct: regular route handlers like `app/api/hello/route.ts` use Turbopack's in-place module update model and benefit from server HMR. However, metadata routes (`/manifest.webmanifest/route`, `/robots.txt/route`, etc.) are also `app`-type entries but they are **not** suitable for in-place server HMR. When `usesServerHmr = true` for a metadata route, `clearRequireCache()` skips two critical invalidation steps: 1. Deleting the compiled chunk from `require.cache` 2. Calling `__next__clear_chunk_cache__()` Without those steps, the old module stays in-memory and all subsequent requests to `/manifest.webmanifest` (etc.) return the stale content. ### How? Added an `!isMetadataRoute(entryPage)` guard to the `usesServerHmr` expression in `clearRequireCache()`. This restores full cache invalidation for metadata routes on every rebuild while leaving regular route handler server HMR (added in #91466) intact. ```ts // Before const usesServerHmr = serverFastRefresh && entryType === 'app' && writtenEndpoint.type !== 'edge' // After const usesServerHmr = serverFastRefresh && entryType === 'app' && writtenEndpoint.type !== 'edge' && !isMetadataRoute(entryPage) // ← metadata routes always clear the cache ``` `isMetadataRoute('/manifest.webmanifest/route')` → `true` (excluded from server HMR) `isMetadataRoute('/api/hello/route')` → `false` (keeps server HMR, no regression) Also added a regression test: `metadata route hmr > reflects manifest.ts changes on fetch/refresh` in the `server-hmr` test suite, with a `manifest.ts` fixture that starts at `name: 'Version 0'`. The test patches the file and asserts the updated JSON is returned on the next fetch. Fixes #91981 --------- Co-authored-by: Will Binns-Smith <wbinnssmith@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent d644699 commit c0edad2

3 files changed

Lines changed: 44 additions & 7 deletions

File tree

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ import { isAppPageRouteDefinition } from '../route-definitions/app-page-route-de
8282
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
8383
import type { ModernSourceMapPayload } from '../lib/source-maps'
8484
import { isDeferredEntry } from '../../build/entries'
85-
import { isMetadataRouteFile } from '../../lib/metadata/is-metadata-route'
85+
import {
86+
isMetadataRoute,
87+
isMetadataRouteFile,
88+
} from '../../lib/metadata/is-metadata-route'
8689
import { setBundlerFindSourceMapImplementation } from '../patch-error-inspect'
8790
import { getNextErrorFeedbackMiddleware } from '../../next-devtools/server/get-next-error-feedback-middleware'
8891
import {
@@ -602,16 +605,20 @@ export async function createHotReloaderTurbopack(
602605
join(distDir, p)
603606
)
604607

605-
const { type: entryType } = splitEntryKey(key)
608+
const { type: entryType, page: entryPage } = splitEntryKey(key)
606609

607-
// Server HMR applies to all App Router entries built with the Turbopack
608-
// Node.js runtime: both app pages and route handlers. Edge routes,
609-
// Pages Router pages, and middleware/instrumentation do not use the
610-
// Turbopack Node.js dev runtime and are excluded.
610+
// Server HMR applies to App Router entries built with the Turbopack Node.js
611+
// runtime: app pages and regular route handlers. Edge routes, Pages Router
612+
// pages, middleware/instrumentation, and metadata routes (manifest.ts,
613+
// robots.ts, sitemap.ts, icon.tsx, etc.) are excluded. Metadata routes are
614+
// excluded because they serve HTTP responses directly and must re-execute
615+
// on every request to pick up file changes; the in-place module update
616+
// model of Server HMR does not apply to them.
611617
const usesServerHmr =
612618
serverFastRefresh &&
613619
entryType === 'app' &&
614-
writtenEndpoint.type !== 'edge'
620+
writtenEndpoint.type !== 'edge' &&
621+
!isMetadataRoute(entryPage)
615622

616623
const filesToDelete: string[] = []
617624
for (const file of serverPaths) {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { MetadataRoute } from 'next'
2+
3+
export default function manifest(): MetadataRoute.Manifest {
4+
return {
5+
name: 'Version 0',
6+
short_name: 'v0',
7+
start_url: '/',
8+
display: 'standalone',
9+
}
10+
}

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

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

159+
describe('metadata route hmr', () => {
160+
it('reflects manifest.ts changes on fetch/refresh', async () => {
161+
const initial = await next
162+
.fetch('/manifest.webmanifest')
163+
.then((res) => res.json())
164+
expect(initial.name).toBe('Version 0')
165+
166+
await next.patchFile('app/manifest.ts', (content) =>
167+
content.replace('Version 0', 'Version 1')
168+
)
169+
170+
await retry(async () => {
171+
const updated = await next
172+
.fetch('/manifest.webmanifest')
173+
.then((res) => res.json())
174+
expect(updated.name).toBe('Version 1')
175+
})
176+
})
177+
})
178+
159179
describe('route handler hmr', () => {
160180
function getText(res: Response) {
161181
return res.ok

0 commit comments

Comments
 (0)