Skip to content

Commit 79daac3

Browse files
authored
Prevent synchronous IO from entering Cached Navigations (#98511)
Cached Navigations incorrectly reused uncached timestamps as static page content. A repeat visit showed the previous request's `Date.now()` result while the new response was still pending. This affected both dynamic RSC responses and the RSC payload embedded in the initial HTML. The production renderer left synchronous IO untracked because it assumed the build had already checked it. A private cache exposed the gap: the static prerender suspended at the cache call and never reached the `Date.now()` after it. A production request resolved the private cache during the static stage and included the uncached timestamp in the reusable response. Production staged renders now end the static stage when synchronous request-time IO occurs. They mark the interrupted static and shell prefixes as empty, while the rest of the response renders normally. The client skips those prefixes instead of waiting for a Flight root that the interrupted stage may not have emitted. The regression tests cover dynamic RSC navigation, initial HTML hydration, and full prefetching with a paramless private-cache fixture.
1 parent 4e906f1 commit 79daac3

11 files changed

Lines changed: 382 additions & 21 deletions

File tree

‎packages/next/src/client/components/router-reducer/create-initial-router-state.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,19 @@ export function createInitialRouterState({
150150
// native Promise so we can chain `.then` on it safely.
151151
Promise.resolve(initialStaticStageByteLength)
152152
.then(async (byteLength) => {
153+
if (byteLength === 0) {
154+
initialFlightStreamForCache.cancel()
155+
return
156+
}
153157
const staticStageResponse =
154158
await decodeStageUntilBoundary<InitialRSCPayload>(
155159
initialFlightStreamForCache,
156160
byteLength,
157161
undefined
158162
)
163+
if (staticStageResponse === null) {
164+
return
165+
}
159166
spawnStaticStageCacheWrite(
160167
Date.now(),
161168
staticStageResponse,

‎packages/next/src/client/components/router-reducer/fetch-server-response.ts‎

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,10 @@ async function resolveStaticStageResponse<
469469
// Partially static — truncate the body clone at the byte boundary and
470470
// decode it.
471471
const staticStageByteLength = await flightResponse.l
472+
if (staticStageByteLength === 0) {
473+
staticBodyClone.cancel()
474+
return null
475+
}
472476
return decodeStageUntilBoundary<T>(
473477
staticBodyClone,
474478
staticStageByteLength,
@@ -513,6 +517,10 @@ export async function resolveShellStageResponse<
513517
}
514518

515519
const shellByteLength = await flightResponse.a
520+
if (shellByteLength === 0) {
521+
shellBodyClone.cancel()
522+
return null
523+
}
516524
if (shellByteLength === null) {
517525
// The shell IS the full response (no shell/full split). Return the full
518526
// response itself — callers detect this case by reference equality —
@@ -528,16 +536,42 @@ export async function resolveShellStageResponse<
528536

529537
/**
530538
* Truncates and buffers a Flight stream clone at the given byte boundary and
531-
* decodes the prefix as a Flight payload. Used by the static-stage and
532-
* shell-stage extraction helpers.
539+
* decodes the prefix as an optional Flight payload. Returns null if extraction
540+
* fails or the root does not resolve before the next task. The caller can still
541+
* use the full response.
533542
*/
534543
export async function decodeStageUntilBoundary<T>(
535544
responseBodyClone: ReadableStream<Uint8Array>,
536545
byteLength: number,
537546
headers: RequestHeaders | undefined
538-
): Promise<T> {
539-
const buffer = await bufferPrefetchResponseBody(responseBodyClone, byteLength)
540-
return decodeBufferedStage<T>(buffer, headers)
547+
): Promise<T | null> {
548+
try {
549+
const buffer = await bufferPrefetchResponseBody(
550+
responseBodyClone,
551+
byteLength
552+
)
553+
const response = decodeBufferedStage<T>(buffer, headers)
554+
555+
// The caller already has the full response root, but this prefix may omit
556+
// rows that the root needs. Bound this optional extraction so it cannot
557+
// hold up the full response. This deadline applies to the outer root, not
558+
// to Client Components that continue loading through lazy references.
559+
return await new Promise<T | null>((resolve) => {
560+
const timeout = setTimeout(() => resolve(null), 0)
561+
response.then(
562+
(root) => {
563+
clearTimeout(timeout)
564+
resolve(root)
565+
},
566+
() => {
567+
clearTimeout(timeout)
568+
resolve(null)
569+
}
570+
)
571+
})
572+
} catch {
573+
return null
574+
}
541575
}
542576

543577
/**

‎packages/next/src/server/app-render/app-render.tsx‎

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,14 +1071,14 @@ async function generateStagedDynamicFlightRenderResultNode(
10711071

10721072
const selectStaleTime = createSelectStaleTime(experimental)
10731073
const staleTimeIterable = new StaleTimeIterable()
1074+
const prefetchMode = await getPrefetchingModeForPage(renderOpts, loaderTree)
10741075

10751076
const stageController = new StagedRenderingController({
10761077
abortSignal: null,
10771078
abandonController: null,
1078-
// TODO(cached-navs): this assumes that we checked during build that there's no sync IO.
1079-
// but it can happen e.g. after a revalidation or conditionally for a param that wasn't prerendered.
1080-
// we should change this to track sync IO, log an error and advance to dynamic.
1081-
syncIO: SyncIOMode.Untracked,
1079+
// Synchronous request-time data ends the static stage before its result can
1080+
// enter the Cached Navigation.
1081+
syncIO: getSyncIOMode(prefetchMode),
10821082
finalStage: null,
10831083
})
10841084

@@ -1111,10 +1111,7 @@ async function generateStagedDynamicFlightRenderResultNode(
11111111
// fill caches and then spawn a final runtime prerender whose result stream
11121112
// is embedded in the RSC payload. This is gated because it adds extra server
11131113
// processing and increases the response payload size.
1114-
if (
1115-
Boolean(renderOpts.partialPrefetching) ||
1116-
(await anySegmentHasPartialPrefetchingEnabled(loaderTree))
1117-
) {
1114+
if (prefetchMode === PrefetchingMode.Partial) {
11181115
// Create a mutable cache that gets filled during the dynamic render.
11191116
const prerenderResumeDataCache = createPrerenderResumeDataCache()
11201117
requestStore.resumeDataCache = prerenderResumeDataCache
@@ -3844,14 +3841,14 @@ async function renderToStream(
38443841

38453842
const selectStaleTime = createSelectStaleTime(experimental)
38463843
const staleTimeIterable = new StaleTimeIterable()
3844+
const prefetchMode = await getPrefetchingModeForPage(renderOpts, tree)
38473845

38483846
const stageController = new StagedRenderingController({
38493847
abortSignal: null,
38503848
abandonController: null,
3851-
// TODO(cached-navs): this assumes that we checked during build that there's no sync IO.
3852-
// but it can happen e.g. after a revalidation or conditionally for a param that wasn't prerendered.
3853-
// we should change this to track sync IO, log an error and advance to dynamic.
3854-
syncIO: SyncIOMode.Untracked,
3849+
// Synchronous request-time data ends the static stage before its
3850+
// result can enter the Cached Navigation.
3851+
syncIO: getSyncIOMode(prefetchMode),
38553852
finalStage: null,
38563853
})
38573854

@@ -3887,10 +3884,7 @@ async function renderToStream(
38873884
// Partial Prefetching is on for the route, either per segment (a
38883885
// `prefetch` of 'partial') or globally (the
38893886
// `partialPrefetching` config).
3890-
if (
3891-
Boolean(renderOpts.partialPrefetching) ||
3892-
(await anySegmentHasPartialPrefetchingEnabled(tree))
3893-
) {
3887+
if (prefetchMode === PrefetchingMode.Partial) {
38943888
const prerenderResumeDataCache = createPrerenderResumeDataCache()
38953889
requestStore.resumeDataCache = prerenderResumeDataCache
38963890

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { unstable_navigation } from 'next/cache'
2+
import { Suspense } from 'react'
3+
import UncachedTimePage from '../uncached-time/page'
4+
5+
async function Content() {
6+
// The navigation stage defers this subtree past the initial Flight rows. The
7+
// private cache in UncachedTimePage still resolves before the dynamic stage.
8+
await unstable_navigation()
9+
return <UncachedTimePage />
10+
}
11+
12+
export default function Page() {
13+
return (
14+
<>
15+
<p>Content before sync IO</p>
16+
<Suspense fallback={<p>Loading target...</p>}>
17+
<Content />
18+
</Suspense>
19+
</>
20+
)
21+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { connection } from 'next/server'
2+
import { Suspense } from 'react'
3+
import { LinkAccordion } from '../../components/link-accordion'
4+
5+
async function HubContent() {
6+
await connection()
7+
return <h1>Hub B</h1>
8+
}
9+
10+
export default function Page() {
11+
return (
12+
<>
13+
<Suspense fallback={<p>Loading hub...</p>}>
14+
<HubContent />
15+
</Suspense>
16+
<LinkAccordion href="/uncached-time" prefetch={false}>
17+
Target
18+
</LinkAccordion>
19+
<LinkAccordion href="/uncached-time#full-prefetch" prefetch={true}>
20+
Full prefetch
21+
</LinkAccordion>
22+
<LinkAccordion href="/delayed-sync-io" prefetch={false}>
23+
Delayed sync IO
24+
</LinkAccordion>
25+
</>
26+
)
27+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { ReactNode } from 'react'
2+
3+
export default function Root({ children }: { children: ReactNode }) {
4+
return (
5+
<html>
6+
<body>
7+
<main>{children}</main>
8+
</body>
9+
</html>
10+
)
11+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { cookies } from 'next/headers'
2+
import { Suspense } from 'react'
3+
import { LinkAccordion } from '../components/link-accordion'
4+
5+
async function enableSyncIO() {
6+
'use server'
7+
const cookieStore = await cookies()
8+
cookieStore.set('sync-io-enabled', 'true')
9+
}
10+
11+
async function CookieStatus() {
12+
const cookieStore = await cookies()
13+
const enabled = cookieStore.get('sync-io-enabled')?.value === 'true'
14+
return (
15+
<p id="sync-io-status">
16+
{enabled ? 'Sync IO enabled' : 'Sync IO disabled'}
17+
</p>
18+
)
19+
}
20+
21+
export default function Page() {
22+
return (
23+
<>
24+
<h1>Hub A</h1>
25+
<form action={enableSyncIO}>
26+
<button id="enable-sync-io">Enable sync IO</button>
27+
</form>
28+
<Suspense fallback={<p>Loading cookie...</p>}>
29+
<CookieStatus />
30+
</Suspense>
31+
<LinkAccordion href="/uncached-time" prefetch={false}>
32+
Target
33+
</LinkAccordion>
34+
<LinkAccordion href="/delayed-sync-io" prefetch={false}>
35+
Delayed sync IO
36+
</LinkAccordion>
37+
</>
38+
)
39+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { cacheLife } from 'next/cache'
2+
import { cookies } from 'next/headers'
3+
import { connection } from 'next/server'
4+
import { Suspense } from 'react'
5+
import { LinkAccordion } from '../../components/link-accordion'
6+
7+
async function isSyncIOEnabled() {
8+
'use cache: private'
9+
cacheLife({ stale: 120 })
10+
const cookieStore = await cookies()
11+
return cookieStore.get('sync-io-enabled')?.value === 'true'
12+
}
13+
14+
async function Timestamp() {
15+
// The private cache suspends during static prerendering. A staged dynamic
16+
// render can resolve it during the static stage. The uncached timestamp must
17+
// end that stage.
18+
const enabled = await isSyncIOEnabled()
19+
if (!enabled) {
20+
return <p>Sync IO disabled</p>
21+
}
22+
return <p id="timestamp">Uncached time: {Date.now()}</p>
23+
}
24+
25+
async function DynamicContent() {
26+
await connection()
27+
return <p>Dynamic content</p>
28+
}
29+
30+
export default function Page() {
31+
return (
32+
<>
33+
<h1>Sync IO target</h1>
34+
<Suspense fallback={<p>Loading timestamp...</p>}>
35+
<Timestamp />
36+
</Suspense>
37+
<Suspense fallback={<p>Loading dynamic content...</p>}>
38+
<DynamicContent />
39+
</Suspense>
40+
<LinkAccordion href="/hub-b" prefetch={false}>
41+
Hub B
42+
</LinkAccordion>
43+
</>
44+
)
45+
}

0 commit comments

Comments
 (0)