Skip to content

Commit

Permalink
[Segment Cache] Add PPR header to segment prefetch (#73756)
Browse files Browse the repository at this point in the history
Follow-up to #73715. Since PPR should always result in a cache hit for a
prefetch (even in the worst case, there's a fallback cache entry), the
only reason a route tree prefetch would not be found in the per-segment
cache is because PPR is disabled.

So we should just fallthrough to the old prefetching flow whenever PPR
is disabled.

But the client still needs some way to tell whether the response was
served from the per-segment cache or by the old implementation.

So I added an explicit response header to every per-segment prefetch. If
the header is missing, the client can infer that the route does not
support PPR.
  • Loading branch information
acdlite authored Dec 12, 2024
1 parent 1832c83 commit 363a7d4
Show file tree
Hide file tree
Showing 5 changed files with 66 additions and 25 deletions.
12 changes: 12 additions & 0 deletions packages/next/src/client/components/segment-cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
} from '../../../server/app-render/collect-segment-data'
import type { LoadingModuleData } from '../../../shared/lib/app-router-context.shared-runtime'
import {
NEXT_DID_POSTPONE_HEADER,
NEXT_ROUTER_PREFETCH_HEADER,
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
NEXT_URL,
Expand Down Expand Up @@ -507,6 +508,11 @@ async function fetchRouteOnCacheMiss(
// PPR is enabled, because we always respond to route tree requests, even
// if it needs to be blockingly generated on demand.
response.status === 204 ||
// This checks whether the response was served from the per-segment cache,
// rather than the old prefetching flow. If it fails, it implies that PPR
// is disabled on this route.
// TODO: Add support for non-PPR routes.
response.headers.get(NEXT_DID_POSTPONE_HEADER) !== '2' ||
!response.body
) {
// Server responded with an error, or with a miss. We should still cache
Expand Down Expand Up @@ -607,6 +613,12 @@ async function fetchSegmentEntryOnCacheMiss(
!response ||
!response.ok ||
response.status === 204 || // Cache miss
// This checks whether the response was served from the per-segment cache,
// rather than the old prefetching flow. If it fails, it implies that PPR
// is disabled on this route. Theoretically this should never happen
// because we only issue requests for segments once we've verified that
// the route supports PPR.
response.headers.get(NEXT_DID_POSTPONE_HEADER) !== '2' ||
!response.body
) {
// Server responded with an error, or with a miss. We should still cache
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,16 @@ export async function walkTreeWithFlightRouterState({
)

if (shouldSkipComponentTree) {
// Send only the router state
// Send only the router state.
// TODO: Even for a dynamic route, we should cache these responses,
// because they do not contain any render data (neither segment data nor
// the head). They can be made even more cacheable once we move the route
// params into a separate data structure.
return [
[
overriddenSegment,
routerState,
null,
// TODO: It's possible that all the segment data was prefetched during
// a navigation, but the head was not. Should we send it down
// here anyway?
null,
false,
] satisfies FlightDataSegment,
Expand Down
34 changes: 16 additions & 18 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3042,10 +3042,19 @@ export default abstract class Server<
}
)

if (isPrefetchRSCRequest && typeof segmentPrefetchHeader === 'string') {
if (isRoutePPREnabled && typeof segmentPrefetchHeader === 'string') {
// This is a prefetch request issued by the client Segment Cache. These
// should never reach the application layer (lambda). We should either
// respond from the cache (HIT) or respond with 204 No Content (MISS).

// Set a header to indicate that PPR is enabled for this route. This
// lets the client distinguish between a regular cache miss and a cache
// miss due to PPR being disabled. In other contexts this header is used
// to indicate that the response contains dynamic data, but here we're
// only using it to indicate that the feature is enabled — the segment
// response itself contains whether the data is dynamic.
res.setHeader(NEXT_DID_POSTPONE_HEADER, '2')

if (
cacheEntry !== null &&
// This is always true at runtime but is needed to refine the type
Expand All @@ -3069,24 +3078,13 @@ export default abstract class Server<
}
}

// Cache miss. Either a cache entry for this route has not been generated,
// or there's no match for the requested segment. Regardless, respond with
// a 204 No Content. We don't bother to respond with 404 in cases where
// the segment does not exist, because these requests are only issued by
// the client cache.
// TODO: If this is a request for the route tree (the special /_tree
// segment), we should *always* respond with a tree, even if PPR
// is disabled.
// Cache miss. Either a cache entry for this route has not been generated
// (which technically should not be possible when PPR is enabled, because
// at a minimum there should always be a fallback entry) or there's no
// match for the requested segment. Respond with a 204 No Content. We
// don't bother to respond with 404, because these requests are only
// issued as part of a prefetch.
res.statusCode = 204
if (isRoutePPREnabled) {
// Set a header to indicate that PPR is enabled for this route. This
// lets the client distinguish between a regular cache miss and a cache
// miss due to PPR being disabled.
// NOTE: Theoretically, when PPR is enabled, there should *never* be
// a cache miss because we should generate a fallback route. So this
// is mostly defensive.
res.setHeader(NEXT_DID_POSTPONE_HEADER, '1')
}
return {
type: 'rsc',
body: RenderResult.fromStatic(''),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
import { Suspense } from 'react'
import { connection } from 'next/server'

async function Content() {
await connection()
return 'Dynamic Content'
}

export default function PPRDisabled() {
return '(intentionally empty)'
return (
<Suspense fallback="Loading...">
<Content />
</Suspense>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,20 @@ describe('segment cache (incremental opt in)', () => {
return
}

function extractPseudoJSONFromFlightResponse(flightText: string) {
// This is a cheat that takes advantage of the fact that the roots of the
// Flight responses in this test are JSON. This is just a temporary smoke test
// until the client part is implemented; we shouldn't rely on this as a
// general testing strategy.
const match = flightText.match(/^0:(.*)$/m)
if (match) {
return JSON.parse(match[1])
}
return null
}

// TODO: Replace with e2e test once the client part is implemented
it('prefetch responds with 204 if PPR is disabled for a route', async () => {
it('route tree prefetch falls through to old prefetching implementation if PPR is disabled for a route', async () => {
await next.browser('/')
const response = await next.fetch('/ppr-disabled', {
headers: {
Expand All @@ -20,6 +32,12 @@ describe('segment cache (incremental opt in)', () => {
'Next-Router-Segment-Prefetch': '/_tree',
},
})
expect(response.status).toBe(204)
expect(response.status).toBe(200)

// Smoke test to confirm that this returned a NavigationFlightResponse.
expect(response.headers.get('x-nextjs-postponed')).toBe(null)
const flightText = await response.text()
const result = extractPseudoJSONFromFlightResponse(flightText)
expect(typeof result.b === 'string').toBe(true)
})
})

0 comments on commit 363a7d4

Please sign in to comment.