Skip to content

Commit 36aee8d

Browse files
committed
Recover from blocking routes under Instant Navigation lock when deployed
The Instant Navigation feature locks navigation to a route's static shell so you can inspect the prefetched shell before any dynamic content streams in. It is controlled by a cookie: in development the Instant Navigation DevTools set and clear it for you, and in any environment you can drive it by hand by setting the cookie in the browser's DevTools. A blocking route (one with a Suspense boundary above `<body>`, or `export const instant = false`) has an empty static shell, so when you do a full page load of such a route while the cookie is set, that empty shell is served as a blank document with no way to release the lock, and every reload renders the same blank shell and leaves you stuck. Previously the handler threw for an empty shell. In development that surfaces as the error overlay, which is what we want, and the catch clears the cookie so a reload recovers. It did not recover when deployed: the cookie could only be cleared via `Set-Cookie`, which cannot take effect there because the empty shell is served from the ISR cache with its response headers already committed, so the user stayed on the blank shell across reloads. In `next start` it recovered but rendered only a generic "Internal Server Error" page. With this change a deployed blocking route recovers. For the empty-shell case we serve a minimal document whose inline script clears the cookie client-side, so the next full page load renders the route normally; we clear it from the document rather than with `Set-Cookie` because the headers are already committed when the shell is served from the cache. Development still throws so the error overlay shows. As a secondary improvement, `next start` now serves that same document with a readable explanation instead of the generic "Internal Server Error" page. The blocking-route test now asserts the user-facing outcome (after a reload the route renders normally) rather than transport details such as the status code and `Set-Cookie`, which legitimately differ across dev, `next start`, and deploy, and it runs on deploy as well.
1 parent 7e29e40 commit 36aee8d

2 files changed

Lines changed: 109 additions & 57 deletions

File tree

packages/next/src/build/templates/app-page.ts

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1755,7 +1755,15 @@ export async function handler(
17551755
? (getRequestMeta(req, 'onCacheEntryV2') ??
17561756
getRequestMeta(req, 'onCacheEntry'))
17571757
: getRequestMeta(req, 'onCacheEntry')
1758-
if (onCacheEntry) {
1758+
1759+
// `onCacheEntry` lets the platform capture a freshly prerendered result
1760+
// so the proxy can write it to the ISR cache; on deploy it returns true
1761+
// and the function returns below. In debug-shell mode the render was
1762+
// skipped and we only serve the already-cached shell, so there is nothing
1763+
// to capture, and we need to reach the serve path below to close the
1764+
// document. `onCacheEntry` is absent in `next start`/dev, so this guard
1765+
// only affects the deploy (minimalMode) path.
1766+
if (onCacheEntry && !isDebugStaticShell) {
17591767
const rawCacheEntryUrl = getRequestMeta(req, 'initURL') ?? req.url
17601768
const cacheEntryUrl = rawCacheEntryUrl
17611769
? (parseUrl(rawCacheEntryUrl)?.pathname ?? rawCacheEntryUrl)
@@ -1875,33 +1883,62 @@ export async function handler(
18751883
// This is a request for HTML data.
18761884
const body = cachedData.html
18771885

1878-
// Instant Navigation Testing API: serve the static shell without resuming
1879-
// the dynamic render. The cookie-guarded bootstrap that sets
1880-
// self.__next_instant_test is embedded in the prerendered shell via
1881-
// `bootstrapScriptContent`, so it is already present (in the served shell
1882-
// for a fresh render, or in the cached prelude on deploy). Since the
1883-
// render is not resumed, append the closing tags so the browser can parse
1884-
// a complete document.
1886+
// Instant Navigation Testing API: under the instant lock we serve the
1887+
// static shell as a complete document without resuming the dynamic
1888+
// render, either recovering from an empty (blocking) shell or appending
1889+
// the closing tags to a non-empty one.
18851890
if (isInstantNavigationTest && isDebugStaticShell) {
1886-
// If the static shell came back empty, the page reads a dynamic value
1887-
// (e.g. `await cookies()`) at the root with no Suspense boundary above
1888-
// it, so there is nothing to render before the first dynamic hole.
1889-
// Serving it would be a blank document with no DevTools, leaving the
1890-
// user unable to release the instant navigation lock. Throw so we
1891-
// surface an error page instead; the catch below clears the instant
1892-
// navigation cookie so the next reload renders normally. The empty
1893-
// prelude marker is carried in the postponed state, so this works for
1894-
// both fresh dev renders and prebuilt production shells.
1895-
if (
1891+
const isEmptyPrelude =
18961892
typeof cachedData.postponed === 'string' &&
18971893
entryBase.isEmptyHTMLPrelude(cachedData.postponed)
1898-
) {
1899-
throw new Error(
1900-
`The Navigation Inspector was active, but you attempted to load a blocking route. Reload the page to reset the inspector.\n\n` +
1901-
`To identify why this route is blocking, refer to the Instant Navigation docs: https://preview.nextjs.org/docs/app/guides/instant-navigation`
1902-
)
1894+
1895+
if (isEmptyPrelude) {
1896+
// A blocking route (a Suspense boundary above <body>, or `export
1897+
// const instant = false`) has an empty static shell. Serving it under
1898+
// the lock would be a blank document with no way to release the lock,
1899+
// so every reload would render the same blank shell and leave the
1900+
// user stuck. We surface the reason instead: in development we throw
1901+
// so it shows as an error overlay (the catch below clears the instant
1902+
// cookie via Set-Cookie); in production we serve a minimal document
1903+
// whose script clears the cookie client-side, since on deploy the
1904+
// edge has already served the cached shell and committed the response
1905+
// headers and this function only resumes by appending to the body, so
1906+
// a Set-Cookie could not take effect. `next start` reuses the same
1907+
// document, where throwing would render only a generic "Internal
1908+
// Server Error" page.
1909+
if (routeModule.isDev === true) {
1910+
throw new Error(
1911+
`The Navigation Inspector was active, but you attempted to load a blocking route. Reload the page to reset the inspector.\n\n` +
1912+
`To identify why this route is blocking, refer to the Instant Navigation docs: https://preview.nextjs.org/docs/app/guides/instant-navigation`
1913+
)
1914+
}
1915+
1916+
const recoveryHtml =
1917+
`<!DOCTYPE html><html><head><meta charSet="utf-8"/></head><body>` +
1918+
`<script>document.cookie="${NEXT_INSTANT_TEST_COOKIE}=; Path=/; Max-Age=0"</script>` +
1919+
`<p>The Navigation Inspector was active, but you attempted to load a blocking route. Reload the page to reset the inspector.</p>` +
1920+
`<p>To identify why this route is blocking, refer to the ` +
1921+
`<a href="https://preview.nextjs.org/docs/app/guides/instant-navigation">Instant Navigation docs</a>.</p>` +
1922+
`</body></html>`
1923+
1924+
return sendRenderResult({
1925+
req,
1926+
res,
1927+
generateEtags: nextConfig.generateEtags,
1928+
poweredByHeader: nextConfig.poweredByHeader,
1929+
result: RenderResult.fromStatic(
1930+
recoveryHtml,
1931+
HTML_CONTENT_TYPE_HEADER
1932+
),
1933+
cacheControl: { revalidate: 0, expire: undefined },
1934+
})
19031935
}
19041936

1937+
// Non-empty shell: the cookie-guarded bootstrap that sets
1938+
// self.__next_instant_test is embedded in the prerendered shell via
1939+
// `bootstrapScriptContent`, so it is already present (in the served
1940+
// shell for a fresh render, or in the cached prelude on deploy). Append
1941+
// the closing tags so the browser can parse a complete document.
19051942
body.push(
19061943
new ReadableStream({
19071944
start(controller) {

test/e2e/app-dir/instant-navigation-testing-api/instant-navigation-testing-api.test.ts

Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
NextInstance,
2727
nextTestSetup,
2828
isNextDev,
29-
isNextDeploy,
3029
type Playwright as NextBrowser,
3130
} from 'e2e-utils'
3231
import { instant } from '@next/playwright'
@@ -1050,40 +1049,56 @@ describe('instant-navigation-testing-api', () => {
10501049
})
10511050

10521051
// A page that reads a dynamic value (e.g. `await cookies()`) at the root with
1053-
// no Suspense boundary above it produces an empty static shell. During
1054-
// Instant Navigation Testing that shell is served directly, so an empty shell
1055-
// would be a blank document with no DevTools — leaving the user unable to
1056-
// release the instant navigation lock. Instead the server clears the instant
1057-
// cookie (so the next reload renders normally) and surfaces an error page.
1058-
//
1059-
// TODO: This is skipped on deploy. There, the empty prelude is served
1060-
// straight from the platform cache before this code runs, so the Set-Cookie
1061-
// that clears the instant cookie never takes effect and the user stays on a
1062-
// blank shell. A follow-up serves a client-side cookie-clearing recovery
1063-
// document so the next reload renders the route normally on deploy too.
1064-
if (!isNextDeploy) {
1065-
it('clears the instant cookie and serves an error when the static shell is empty', async () => {
1066-
const res = await next.fetch('/root-blocking-page', {
1067-
headers: { cookie: 'next-instant-navigation-testing=[0]' },
1068-
})
1069-
1070-
// An error response is served instead of a blank document. (The exact
1071-
// body differs by mode — a dev error overlay vs. a minimal production
1072-
// error — but the 500 status is what distinguishes it from the empty 200
1073-
// shell.)
1074-
expect(res.status).toBe(500)
1075-
1076-
// The instant cookie is cleared so the next reload renders normally.
1077-
const setCookie = res.headers.get('set-cookie') ?? ''
1078-
expect(setCookie).toContain('next-instant-navigation-testing=')
1079-
expect(setCookie).toMatch(/Max-Age=0|expires=/i)
1080-
1081-
// The response is a real, non-empty error response — not a blank shell.
1082-
const body = await res.text()
1083-
expect(body.length).toBeGreaterThan(0)
1084-
expect(body).toMatch(/error/i)
1052+
// no Suspense boundary above it produces an empty static shell. Serving that
1053+
// shell under the instant lock would be a blank document with no way to
1054+
// release the lock, so every reload would render the same empty shell and
1055+
// leave the user stuck. The framework breaks that loop: it surfaces the error
1056+
// and clears the instant cookie so the next reload renders the route
1057+
// normally. We assert the user-facing outcome, not transport details (status
1058+
// code, Set-Cookie) which legitimately differ across dev, `next start`, and
1059+
// deploy.
1060+
it('recovers on reload for a blocking route under the instant lock', async () => {
1061+
const browser = await next.browser('/root-blocking-page', {
1062+
beforePageLoad(p: Playwright.Page) {
1063+
const { hostname } = new URL(next.url)
1064+
p.context().addCookies([
1065+
{
1066+
name: 'next-instant-navigation-testing',
1067+
value: '[0]',
1068+
domain: hostname,
1069+
path: '/',
1070+
},
1071+
])
1072+
},
10851073
})
1086-
}
1074+
1075+
// In development the empty shell surfaces as an error overlay so the
1076+
// developer sees why the route is blocking.
1077+
if (isNextDev) {
1078+
await expect(browser).toDisplayRedbox(`
1079+
{
1080+
"code": "E1387",
1081+
"description": "The Navigation Inspector was active, but you attempted to load a blocking route. Reload the page to reset the inspector.
1082+
1083+
To identify why this route is blocking, refer to the Instant Navigation docs: https://preview.nextjs.org/docs/app/guides/instant-navigation",
1084+
"environmentLabel": null,
1085+
"label": "Runtime Error",
1086+
"source": null,
1087+
"stack": [],
1088+
}
1089+
`)
1090+
}
1091+
1092+
// The empty shell can render nothing useful, but the instant cookie is
1093+
// cleared, so reloading renders the route normally instead of the blank
1094+
// shell.
1095+
await browser.refresh()
1096+
1097+
const content = await browser
1098+
.elementByCss('[data-testid="root-blocking-content"]')
1099+
.text()
1100+
expect(content).toContain('testCookie:')
1101+
})
10871102
})
10881103

10891104
describe('instant-navigation-testing-api - root params', () => {

0 commit comments

Comments
 (0)