Skip to content

Commit 16bfce6

Browse files
authored
[Segment Cache] Respond with 204 on cache miss (#73649)
Currently, when the client prefetches a segment, the server responds with a 404 if it cannot fulfill the request. This updates it to respond with a 204 No Content instead, since it's not an error for the client to request a segment whose prefetch hasn't been generated. When responding with 204, the server also sends the 'x-nextjs-postponed' header, but only if PPR is enabled for the route. The client can use the absence of this header to distinguish between a regular cache miss and a miss due to PPR being disabled. In a later PR, I will update the client to use this information to fall back to the non-PPR behavior.
1 parent c7d6ab7 commit 16bfce6

File tree

11 files changed

+153
-48
lines changed

11 files changed

+153
-48
lines changed

packages/next/src/client/components/segment-cache/cache.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -500,8 +500,17 @@ async function fetchRouteOnCacheMiss(
500500
const nextUrl = key.nextUrl
501501
try {
502502
const response = await fetchSegmentPrefetchResponse(href, '/_tree', nextUrl)
503-
if (!response || !response.ok || !response.body) {
504-
// Received an unexpected response.
503+
if (
504+
!response ||
505+
!response.ok ||
506+
// 204 is a Cache miss. Though theoretically this shouldn't happen when
507+
// PPR is enabled, because we always respond to route tree requests, even
508+
// if it needs to be blockingly generated on demand.
509+
response.status === 204 ||
510+
!response.body
511+
) {
512+
// Server responded with an error, or with a miss. We should still cache
513+
// the response, but we can try again after 10 seconds.
505514
rejectRouteCacheEntry(entry, Date.now() + 10 * 1000)
506515
return
507516
}
@@ -594,9 +603,14 @@ async function fetchSegmentEntryOnCacheMiss(
594603
accessToken === '' ? segmentPath : `${segmentPath}.${accessToken}`,
595604
routeKey.nextUrl
596605
)
597-
if (!response || !response.ok || !response.body) {
598-
// Server responded with an error. We should still cache the response, but
599-
// we can try again after 10 seconds.
606+
if (
607+
!response ||
608+
!response.ok ||
609+
response.status === 204 || // Cache miss
610+
!response.body
611+
) {
612+
// Server responded with an error, or with a miss. We should still cache
613+
// the response, but we can try again after 10 seconds.
600614
rejectSegmentCacheEntry(segmentCacheEntry, Date.now() + 10 * 1000)
601615
return
602616
}

packages/next/src/server/base-server.ts

Lines changed: 47 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3044,49 +3044,55 @@ export default abstract class Server<
30443044
}
30453045
)
30463046

3047-
if (
3048-
isRoutePPREnabled &&
3049-
isPrefetchRSCRequest &&
3050-
typeof segmentPrefetchHeader === 'string'
3051-
) {
3052-
if (cacheEntry?.value?.kind === CachedRouteKind.APP_PAGE) {
3053-
// This is a prefetch request for an individual segment's static data.
3054-
// Unless the segment is fully dynamic, the data should have already been
3055-
// loaded into the cache, when the page itself was generated. So we should
3056-
// always either return the cache entry. If no cache entry is available,
3057-
// it's a 404 — either the segment is fully dynamic, or an invalid segment
3058-
// path was requested.
3059-
if (cacheEntry.value.segmentData) {
3060-
const matchedSegment = cacheEntry.value.segmentData.get(
3061-
segmentPrefetchHeader
3062-
)
3063-
if (matchedSegment !== undefined) {
3064-
return {
3065-
type: 'rsc',
3066-
body: RenderResult.fromStatic(matchedSegment),
3067-
// TODO: Eventually this should use revalidate time of the
3068-
// individual segment, not the whole page.
3069-
revalidate: cacheEntry.revalidate,
3070-
}
3047+
if (isPrefetchRSCRequest && typeof segmentPrefetchHeader === 'string') {
3048+
// This is a prefetch request issued by the client Segment Cache. These
3049+
// should never reach the application layer (lambda). We should either
3050+
// respond from the cache (HIT) or respond with 204 No Content (MISS).
3051+
if (
3052+
cacheEntry !== null &&
3053+
// This is always true at runtime but is needed to refine the type
3054+
// of cacheEntry.value to CachedAppPageValue, because the outer
3055+
// ResponseCacheEntry is not a discriminated union.
3056+
cacheEntry.value?.kind === CachedRouteKind.APP_PAGE &&
3057+
cacheEntry.value.segmentData
3058+
) {
3059+
const matchedSegment = cacheEntry.value.segmentData.get(
3060+
segmentPrefetchHeader
3061+
)
3062+
if (matchedSegment !== undefined) {
3063+
// Cache hit
3064+
return {
3065+
type: 'rsc',
3066+
body: RenderResult.fromStatic(matchedSegment),
3067+
// TODO: Eventually this should use revalidate time of the
3068+
// individual segment, not the whole page.
3069+
revalidate: cacheEntry.revalidate,
30713070
}
30723071
}
3073-
// If the segment is not found, return a 404. Since this is an RSC
3074-
// request, there's no reason to render a 404 page; just return an
3075-
// empty response.
3076-
res.statusCode = 404
3077-
return {
3078-
type: 'rsc',
3079-
body: RenderResult.fromStatic(''),
3080-
revalidate: cacheEntry.revalidate,
3081-
}
3082-
} else {
3083-
// Segment prefetches should never reach the application layer. If
3084-
// there's no cache entry for this page, it's a 404.
3085-
res.statusCode = 404
3086-
return {
3087-
type: 'rsc',
3088-
body: RenderResult.fromStatic(''),
3089-
}
3072+
}
3073+
3074+
// Cache miss. Either a cache entry for this route has not been generated,
3075+
// or there's no match for the requested segment. Regardless, respond with
3076+
// a 204 No Content. We don't bother to respond with 404 in cases where
3077+
// the segment does not exist, because these requests are only issued by
3078+
// the client cache.
3079+
// TODO: If this is a request for the route tree (the special /_tree
3080+
// segment), we should *always* respond with a tree, even if PPR
3081+
// is disabled.
3082+
res.statusCode = 204
3083+
if (isRoutePPREnabled) {
3084+
// Set a header to indicate that PPR is enabled for this route. This
3085+
// lets the client distinguish between a regular cache miss and a cache
3086+
// miss due to PPR being disabled.
3087+
// NOTE: Theoretically, when PPR is enabled, there should *never* be
3088+
// a cache miss because we should generate a fallback route. So this
3089+
// is mostly defensive.
3090+
res.setHeader(NEXT_DID_POSTPONE_HEADER, '1')
3091+
}
3092+
return {
3093+
type: 'rsc',
3094+
body: RenderResult.fromStatic(''),
3095+
revalidate: cacheEntry?.revalidate,
30903096
}
30913097
}
30923098

test/e2e/app-dir/ppr-navigations/simple/per-segment-prefetching.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ describe('per segment prefetching', () => {
6464
expect(childResponseText).toInclude('"rsc"')
6565
})
6666

67-
it('respond with 404 if the segment does not have prefetch data', async () => {
67+
it('respond with 204 if the segment does not have prefetch data', async () => {
6868
const response = await prefetch('/en', '/does-not-exist')
69-
expect(response.status).toBe(404)
69+
expect(response.status).toBe(204)
7070
const responseText = await response.text()
7171
expect(responseText.trim()).toBe('')
7272
})
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default function RootLayout({
2+
children,
3+
}: {
4+
children: React.ReactNode
5+
}) {
6+
return (
7+
<html lang="en">
8+
<body>{children}</body>
9+
</html>
10+
)
11+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Link from 'next/link'
2+
3+
export default function Page() {
4+
return (
5+
<ul>
6+
<li>
7+
<Link href="/ppr-enabled">Page with PPR enabled</Link>
8+
</li>
9+
<li>
10+
<Link href="/ppr-enabled/dynamic-param">
11+
Page with PPR enabled but has dynamic param
12+
</Link>
13+
</li>
14+
<li>
15+
<Link href="/ppr-disabled">Page with PPR disabled</Link>
16+
</li>
17+
</ul>
18+
)
19+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function PPRDisabled() {
2+
return '(intentionally empty)'
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return '(intentionally empty)'
3+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const experimental_ppr = true
2+
3+
export default function RootLayout({
4+
children,
5+
}: {
6+
children: React.ReactNode
7+
}) {
8+
return children
9+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function PPREnabled() {
2+
return '(intentionally empty)'
3+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {
5+
experimental: {
6+
ppr: 'incremental',
7+
dynamicIO: true,
8+
clientSegmentCache: true,
9+
},
10+
}
11+
12+
module.exports = nextConfig

0 commit comments

Comments
 (0)