Skip to content

test: validateRSCRequestHeaders for runtime prefetches #82149

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: canary
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -764,5 +764,11 @@
"763": "\\`unstable_rootParams\\` must not be used within a client component. Next.js should be preventing it from being included in client components statically, but did not in this case.",
"764": "Missing workStore in %s",
"765": "Route %s used %s inside a Route Handler. Support for this API in Route Handlers is planned for a future version of Next.js.",
"766": "%s was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route."
"766": "%s was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route.",
"767": "A runtime prerender store should not be used for a route handler.",
"768": "createPrerenderSearchParamsForClientPage should not be called in a runtime prerender.",
"769": "createSearchParamsFromClient should not be called in a runtime prerender.",
"770": "createParamsFromClient should not be called in a runtime prerender.",
"771": "\\`%s\\` was called during a runtime prerender. Next.js should be preventing %s from being included in server components statically, but did not in this case.",
"772": "FetchStrategy.PPRRuntime should never be used when `experimental.clientSegmentCache` is disabled"
}
2 changes: 1 addition & 1 deletion packages/next/src/build/templates/app-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export async function handler(
*/
const isPrefetchRSCRequest =
getRequestMeta(req, 'isPrefetchRSCRequest') ??
Boolean(req.headers[NEXT_ROUTER_PREFETCH_HEADER])
req.headers[NEXT_ROUTER_PREFETCH_HEADER] === '1' // exclude runtime prefetches, which use '2'

// NOTE: Don't delete headers[RSC] yet, it still needs to be used in renderToHTML later

Expand Down
1 change: 1 addition & 0 deletions packages/next/src/client/app-dir/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export default function Form({
}
}

// TODO(runtime-ppr): allow runtime prefetches in Form
const prefetch =
prefetchProp === false || prefetchProp === null ? prefetchProp : null

Expand Down
66 changes: 51 additions & 15 deletions packages/next/src/client/app-dir/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import {
import { isLocalURL } from '../../shared/lib/router/utils/is-local-url'
import { dispatchNavigateAction } from '../components/app-router-instance'
import { errorOnce } from '../../shared/lib/utils/error-once'
import { FetchStrategy } from '../components/segment-cache'
import {
FetchStrategy,
type PrefetchTaskFetchStrategy,
} from '../components/segment-cache'

type Url = string | UrlObject
type RequiredKeys<T> = {
Expand Down Expand Up @@ -151,10 +154,10 @@ type InternalLinkProps = {
* </Link>
* ```
*/
prefetch?: boolean | 'auto' | null
prefetch?: boolean | 'auto' | null | 'unstable_forceStale'

/**
* (unstable) Switch to a dynamic prefetch on hover. Effectively the same as
* (unstable) Switch to a full prefetch on hover. Effectively the same as
* updating the prefetch prop to `true` in a mouse event.
*/
unstable_dynamicOnHover?: boolean
Expand Down Expand Up @@ -356,18 +359,12 @@ export default function LinkComponent(
const router = React.useContext(AppRouterContext)

const prefetchEnabled = prefetchProp !== false
/**
* The possible states for prefetch are:
* - null: this is the default "auto" mode, where we will prefetch partially if the link is in the viewport
* - true: we will prefetch if the link is visible and prefetch the full page, not just partially
* - false: we will not prefetch if in the viewport at all
* - 'unstable_dynamicOnHover': this starts in "auto" mode, but switches to "full" when the link is hovered
*/

const fetchStrategy =
prefetchProp === null || prefetchProp === 'auto'
? // We default to PPR. We'll discover whether or not the route supports it with the initial prefetch.
prefetchProp !== false
? getFetchStrategyFromPrefetchProp(prefetchProp)
: // TODO: it makes no sense to assign a fetchStrategy when prefetching is disabled.
FetchStrategy.PPR
: FetchStrategy.Full

if (process.env.NODE_ENV !== 'production') {
function createPropError(args: {
Expand Down Expand Up @@ -470,11 +467,12 @@ export default function LinkComponent(
if (
props[key] != null &&
valType !== 'boolean' &&
props[key] !== 'auto'
props[key] !== 'auto' &&
props[key] !== 'unstable_forceStale'
) {
throw createPropError({
key,
expected: '`boolean | "auto"`',
expected: '`boolean | "auto" | "unstable_forceStale"`',
actual: valType,
})
}
Expand Down Expand Up @@ -745,3 +743,41 @@ const LinkStatusContext = createContext<
export const useLinkStatus = () => {
return useContext(LinkStatusContext)
}

function getFetchStrategyFromPrefetchProp(
prefetchProp: Exclude<LinkProps['prefetch'], undefined | false>
): PrefetchTaskFetchStrategy {
if (
process.env.__NEXT_CACHE_COMPONENTS &&
process.env.__NEXT_CLIENT_SEGMENT_CACHE
) {
// In the new implementation:
// - `prefetch={true}` is a runtime prefetch
// (includes cached IO + params + cookies, with dynamic holes for uncached IO).
// - `unstable_forceStale` is a "full" prefetch
// (forces inclusion of all dynamic data, i.e. the old behavior of `prefetch={true}`)
if (prefetchProp === true) {
return FetchStrategy.PPRRuntime
}
if (prefetchProp === 'unstable_forceStale') {
return FetchStrategy.Full
}

// `null` or `"auto"`: this is the default "auto" mode, where we will prefetch partially if the link is in the viewport.
// This will also include invalid prop values that don't match the types specified here.
// (although those should've been filtered out by prop validation in dev)
prefetchProp satisfies null | 'auto'
// In `clientSegmentCache`, we default to PPR, and we'll discover whether or not the route supports it with the initial prefetch.
// If we're not using `clientSegmentCache`, this will be converted into a `PrefetchKind.AUTO`.
return FetchStrategy.PPR
} else {
return prefetchProp === null || prefetchProp === 'auto'
? // In `clientSegmentCache`, we default to PPR, and we'll discover whether or not the route supports it with the initial prefetch.
// If we're not using `clientSegmentCache`, this will be converted into a `PrefetchKind.AUTO`.
FetchStrategy.PPR
: // In the old implementation without runtime prefetches, `prefetch={true}` forces all dynamic data to be prefetched.
// To preserve backwards-compatibility, anything other than `false`, `null`, or `"auto"` results in a full prefetch.
// (although invalid values should've been filtered out by prop validation in dev)
FetchStrategy.Full
}
}
2 changes: 2 additions & 0 deletions packages/next/src/client/components/app-router-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ export const publicAppRouterInstance: AppRouterInstance = {
const actionQueue = getAppRouterActionQueue()
const prefetchKind = options?.kind ?? PrefetchKind.AUTO

// We don't currently offer a way to issue a runtime prefetch via `router.prefetch()`.
// This will be possible when we update its API to not take a PrefetchKind.
let fetchStrategy: PrefetchTaskFetchStrategy
switch (prefetchKind) {
case PrefetchKind.AUTO: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function bailoutToClientRendering(reason: string): void | never {
if (workUnitStore) {
switch (workUnitStore.type) {
case 'prerender':
case 'prerender-runtime':
case 'prerender-client':
case 'prerender-ppr':
case 'prerender-legacy':
Expand Down
10 changes: 9 additions & 1 deletion packages/next/src/client/components/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from './segment-cache'
import { startTransition } from 'react'
import { PrefetchKind } from './router-reducer/router-reducer-types'
import { InvariantError } from '../../shared/lib/invariant-error'

type LinkElement = HTMLAnchorElement | SVGAElement

Expand Down Expand Up @@ -264,7 +265,7 @@ export function onNavigationIntent(
process.env.__NEXT_DYNAMIC_ON_HOVER &&
unstable_upgradeToDynamicPrefetch
) {
// Switch to a full, dynamic prefetch
// Switch to a full prefetch
instance.fetchStrategy = FetchStrategy.Full
}
rescheduleLinkPrefetch(instance, PrefetchPriority.Intent)
Expand Down Expand Up @@ -378,6 +379,13 @@ function prefetchWithOldCacheImplementation(instance: PrefetchableInstance) {
prefetchKind = PrefetchKind.FULL
break
}
case FetchStrategy.PPRRuntime: {
// We can only get here if Client Segment Cache is off, and in that case
// it shouldn't be possible for a link to request a runtime prefetch.
throw new InvariantError(
'FetchStrategy.PPRRuntime should never be used when `experimental.clientSegmentCache` is disabled'
)
}
default: {
instance.fetchStrategy satisfies never
// Unreachable, but otherwise typescript will consider the variable unassigned
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function hasFallbackRouteParams(): boolean {
return fallbackParams ? fallbackParams.size > 0 : false
case 'prerender-legacy':
case 'request':
case 'prerender-runtime':
case 'cache':
case 'private-cache':
case 'unstable-cache':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export type RequestHeaders = {
[RSC_HEADER]?: '1'
[NEXT_ROUTER_STATE_TREE_HEADER]?: string
[NEXT_URL]?: string
[NEXT_ROUTER_PREFETCH_HEADER]?: '1'
[NEXT_ROUTER_PREFETCH_HEADER]?: '1' | '2'
[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]?: string
'x-deployment-id'?: string
[NEXT_HMR_REFRESH_HEADER]?: '1'
Expand Down
108 changes: 89 additions & 19 deletions packages/next/src/client/components/segment-cache-impl/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,9 @@ export function getSegmentKeypathForTask(
// the cache key, because the search params are treated as dynamic data. The
// cache entry is valid for all possible search param values.
const isDynamicTask =
task.fetchStrategy === FetchStrategy.Full || !route.isPPREnabled
task.fetchStrategy === FetchStrategy.Full ||
task.fetchStrategy === FetchStrategy.PPRRuntime ||
!route.isPPREnabled
return isDynamicTask && path.endsWith('/' + PAGE_SEGMENT_KEY)
? [path, route.renderedSearch]
: [path]
Expand Down Expand Up @@ -639,11 +641,21 @@ export function upsertSegmentEntry(
// this function and confirming it's the same as `existingEntry`.
const existingEntry = readExactSegmentCacheEntry(now, keypath)
if (existingEntry !== null) {
if (candidateEntry.isPartial && !existingEntry.isPartial) {
// Don't replace a full segment with a partial one. A case where this
// might happen is if the existing segment was fetched via
// <Link prefetch={true}>.

// Don't replace a more specific segment with a less-specific one. A case where this
// might happen is if the existing segment was fetched via
// `<Link prefetch={true}>`.
if (
// We fetched the new segment using a different, less specific fetch strategy
// than the segment we already have in the cache, so it can't have more content.
(candidateEntry.fetchStrategy !== existingEntry.fetchStrategy &&
!canNewFetchStrategyProvideMoreContent(
existingEntry.fetchStrategy,
candidateEntry.fetchStrategy
)) ||
// The existing entry isn't partial, but the new one is.
// (TODO: can this be true if `candidateEntry.fetchStrategy >= existingEntry.fetchStrategy`?)
(!existingEntry.isPartial && candidateEntry.isPartial)
) {
// We're going to leave the entry on the owner's `revalidating` field
// so that it doesn't get revalidated again unnecessarily. Downgrade the
// Fulfilled entry to Rejected and null out the data so it can be garbage
Expand All @@ -655,6 +667,7 @@ export function upsertSegmentEntry(
rejectedEntry.rsc = null
return null
}

// Evict the existing entry from the cache.
deleteSegmentFromCache(existingEntry, keypath)
}
Expand Down Expand Up @@ -1355,7 +1368,10 @@ export async function fetchSegmentOnCacheMiss(
export async function fetchSegmentPrefetchesUsingDynamicRequest(
task: PrefetchTask,
route: FulfilledRouteCacheEntry,
fetchStrategy: FetchStrategy.LoadingBoundary | FetchStrategy.Full,
fetchStrategy:
| FetchStrategy.LoadingBoundary
| FetchStrategy.PPRRuntime
| FetchStrategy.Full,
dynamicRequestTree: FlightRouterState,
spawnedEntries: Map<string, PendingSegmentCacheEntry>
): Promise<PrefetchSubtaskResult<null> | null> {
Expand All @@ -1370,13 +1386,26 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
if (nextUrl !== null) {
headers[NEXT_URL] = nextUrl
}
// Only set the prefetch header if we're not doing a "full" prefetch. We
// omit the prefetch header from a full prefetch because it's essentially
// just a navigation request that happens ahead of time — it should include
// all the same data in the response.
if (fetchStrategy !== FetchStrategy.Full) {
headers[NEXT_ROUTER_PREFETCH_HEADER] = '1'
switch (fetchStrategy) {
case FetchStrategy.Full: {
// We omit the prefetch header from a full prefetch because it's essentially
// just a navigation request that happens ahead of time — it should include
// all the same data in the response.
break
}
case FetchStrategy.PPRRuntime: {
headers[NEXT_ROUTER_PREFETCH_HEADER] = '2'
break
}
case FetchStrategy.LoadingBoundary: {
headers[NEXT_ROUTER_PREFETCH_HEADER] = '1'
break
}
default: {
fetchStrategy satisfies never
}
}

try {
const response = await fetchPrefetchResponse(url, headers)
if (!response || !response.ok || !response.body) {
Expand Down Expand Up @@ -1425,9 +1454,13 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
prefetchStream
) as Promise<NavigationFlightResponse>)

// Since we did not set the prefetch header, the response from the server
// will never contain dynamic holes.
const isResponsePartial = false
const isResponsePartial =
fetchStrategy === FetchStrategy.PPRRuntime
? // A runtime prefetch may have holes.
!!response.headers.get(NEXT_DID_POSTPONE_HEADER)
: // Full and LoadingBoundary prefetches cannot have holes.
// (even if we did set the prefetch header, we only use this codepath for non-PPR-enabled routes)
false

// Aside from writing the data into the cache, this function also returns
// the entries that were fulfilled, so we can streamingly update their sizes
Expand Down Expand Up @@ -1455,7 +1488,10 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
function writeDynamicTreeResponseIntoCache(
now: number,
task: PrefetchTask,
fetchStrategy: FetchStrategy.LoadingBoundary | FetchStrategy.Full,
fetchStrategy:
| FetchStrategy.LoadingBoundary
| FetchStrategy.PPRRuntime
| FetchStrategy.Full,
response: RSCResponse,
serverData: NavigationFlightResponse,
entry: PendingRouteCacheEntry,
Expand Down Expand Up @@ -1553,7 +1589,10 @@ function rejectSegmentEntriesIfStillPending(
function writeDynamicRenderResponseIntoCache(
now: number,
task: PrefetchTask,
fetchStrategy: FetchStrategy.LoadingBoundary | FetchStrategy.Full,
fetchStrategy:
| FetchStrategy.LoadingBoundary
| FetchStrategy.PPRRuntime
| FetchStrategy.Full,
response: RSCResponse,
serverData: NavigationFlightResponse,
isResponsePartial: boolean,
Expand Down Expand Up @@ -1661,7 +1700,10 @@ function writeDynamicRenderResponseIntoCache(
function writeSeedDataIntoCache(
now: number,
task: PrefetchTask,
fetchStrategy: FetchStrategy.LoadingBoundary | FetchStrategy.Full,
fetchStrategy:
| FetchStrategy.LoadingBoundary
| FetchStrategy.PPRRuntime
| FetchStrategy.Full,
route: FulfilledRouteCacheEntry,
staleAt: number,
seedData: CacheNodeSeedData,
Expand Down Expand Up @@ -1855,3 +1897,31 @@ function createPromiseWithResolvers<T>(): PromiseWithResolvers<T> {
})
return { resolve: resolve!, reject: reject!, promise }
}

/**
* Checks whether the new fetch strategy is likely to provide more content than the old one.
*
* Generally, when an app uses dynamic data, a "more specific" fetch strategy is expected to provide more content:
* - `LoadingBoundary` only provides static layouts
* - `PPR` can provide shells for each segment (even for segments that use dynamic data)
* - `PPRRuntime` can additionally include content that uses searchParams, params, or cookies
* - `Full` includes all the content, even if it uses dynamic data
*
* However, it's possible that a more specific fetch strategy *won't* give us more content if:
* - a segment is fully static
* (then, `PPR`/`PPRRuntime`/`Full` will all yield equivalent results)
* - providing searchParams/params/cookies doesn't reveal any more content, e.g. because of an `await connection()`
* (then, `PPR` and `PPRRuntime` will yield equivalent results, only `Full` will give us more)
* Because of this, when comparing two segments, we should also check if the existing segment is partial.
* If it's not partial, then there's no need to prefetch it again, even using a "more specific" strategy.
* There's currently no way to know if `PPRRuntime` will yield more data that `PPR`, so we have to assume it will.
*
* Also note that, in practice, we don't expect to be comparing `LoadingBoundary` to `PPR`/`PPRRuntime`,
* because a non-PPR-enabled route wouldn't ever use the latter strategies. It might however use `Full`.
*/
export function canNewFetchStrategyProvideMoreContent(
currentStrategy: FetchStrategy,
newStrategy: FetchStrategy
): boolean {
return currentStrategy < newStrategy
}
Loading
Loading