diff --git a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx index b7cf15ce625f4c..2f145df1a8c58a 100644 --- a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx @@ -260,6 +260,7 @@ describe('navigateReducer', () => { "kind": "temporary", "lastUsedTime": 1690329600000, "prefetchTime": 1690329600000, + "status": "fresh", "treeAtTimeOfPrefetch": [ "", { @@ -455,6 +456,7 @@ describe('navigateReducer', () => { "kind": "temporary", "lastUsedTime": 1690329600000, "prefetchTime": 1690329600000, + "status": "fresh", "treeAtTimeOfPrefetch": [ "", { @@ -893,6 +895,7 @@ describe('navigateReducer', () => { "kind": "temporary", "lastUsedTime": 1690329600000, "prefetchTime": 1690329600000, + "status": "fresh", "treeAtTimeOfPrefetch": [ "", { @@ -1120,6 +1123,7 @@ describe('navigateReducer', () => { "kind": "auto", "lastUsedTime": 1690329600000, "prefetchTime": 1690329600000, + "status": "fresh", "treeAtTimeOfPrefetch": [ "", { @@ -1375,6 +1379,7 @@ describe('navigateReducer', () => { "kind": "temporary", "lastUsedTime": 1690329600000, "prefetchTime": 1690329600000, + "status": "fresh", "treeAtTimeOfPrefetch": [ "", { @@ -1719,6 +1724,7 @@ describe('navigateReducer', () => { "kind": "temporary", "lastUsedTime": 1690329600000, "prefetchTime": 1690329600000, + "status": "fresh", "treeAtTimeOfPrefetch": [ "", { diff --git a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts index 01a209e699e8a8..024c9335a92636 100644 --- a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts @@ -11,13 +11,13 @@ import { fillCacheWithDataProperty } from '../fill-cache-with-data-property' import { applyRouterStatePatchToTreeSkipDefault } from '../apply-router-state-patch-to-tree' import { shouldHardNavigate } from '../should-hard-navigate' import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' -import type { - Mutable, - NavigateAction, - ReadonlyReducerState, - ReducerState, +import { + PrefetchCacheEntryStatus, + type Mutable, + type NavigateAction, + type ReadonlyReducerState, + type ReducerState, } from '../router-reducer-types' -import { PrefetchKind } from '../router-reducer-types' import { handleMutable } from '../handle-mutable' import { applyFlightData } from '../apply-flight-data' import { prefetchQueue } from './prefetch-reducer' @@ -28,12 +28,8 @@ import { updateCacheNodeOnNavigation, } from '../ppr-navigations' import { - createPrefetchCacheKey, getPrefetchCacheEntry, - PrefetchCacheEntryStatus, - getPrefetchEntryCacheStatus, prunePrefetchCache, - createPrefetchCacheEntry, } from './prefetch-cache-utils' export function handleExternalUrl( @@ -129,27 +125,17 @@ function navigateReducer_noPPR( return handleExternalUrl(state, mutable, url.toString(), pendingPush) } - let prefetchValues = getPrefetchCacheEntry(url, state) - // If we don't have a prefetch value, we need to create one - if (!prefetchValues) { - const cacheKey = createPrefetchCacheKey(url) - prefetchValues = createPrefetchCacheEntry({ - state, - url, - // in dev, there's never gonna be a prefetch entry so we want to prefetch here - kind: - process.env.NODE_ENV === 'development' - ? PrefetchKind.AUTO - : PrefetchKind.TEMPORARY, - prefetchCacheKey: cacheKey, - }) - - state.prefetchCache.set(cacheKey, prefetchValues) - } - - const prefetchEntryCacheStatus = getPrefetchEntryCacheStatus(prefetchValues) + const prefetchValues = getPrefetchCacheEntry({ + url, + state, + createIfNotFound: true, + }) + const { + treeAtTimeOfPrefetch, + data, + status: prefetchEntryCacheStatus, + } = prefetchValues - const { treeAtTimeOfPrefetch, data } = prefetchValues prefetchQueue.bump(data) return data.then( @@ -306,27 +292,17 @@ function navigateReducer_PPR( return handleExternalUrl(state, mutable, url.toString(), pendingPush) } - let prefetchValues = getPrefetchCacheEntry(url, state) - // If we don't have a prefetch value, we need to create one - if (!prefetchValues) { - const cacheKey = createPrefetchCacheKey(url) - prefetchValues = createPrefetchCacheEntry({ - state, - url, - // in dev, there's never gonna be a prefetch entry so we want to prefetch here - kind: - process.env.NODE_ENV === 'development' - ? PrefetchKind.AUTO - : PrefetchKind.TEMPORARY, - prefetchCacheKey: cacheKey, - }) - - state.prefetchCache.set(cacheKey, prefetchValues) - } - - const prefetchEntryCacheStatus = getPrefetchEntryCacheStatus(prefetchValues) + const prefetchValues = getPrefetchCacheEntry({ + url, + state, + createIfNotFound: true, + }) + const { + treeAtTimeOfPrefetch, + data, + status: prefetchEntryCacheStatus, + } = prefetchValues - const { treeAtTimeOfPrefetch, data } = prefetchValues prefetchQueue.bump(data) return data.then( diff --git a/packages/next/src/client/components/router-reducer/reducers/prefetch-cache-utils.ts b/packages/next/src/client/components/router-reducer/reducers/prefetch-cache-utils.ts index e96432f4a53423..6dc610fc56ecb1 100644 --- a/packages/next/src/client/components/router-reducer/reducers/prefetch-cache-utils.ts +++ b/packages/next/src/client/components/router-reducer/reducers/prefetch-cache-utils.ts @@ -1,9 +1,10 @@ import { createHrefFromUrl } from '../create-href-from-url' import { fetchServerResponse } from '../fetch-server-response' -import type { - PrefetchCacheEntry, +import { + PrefetchCacheEntryStatus, + type PrefetchCacheEntry, PrefetchKind, - ReadonlyReducerState, + type ReadonlyReducerState, } from '../router-reducer-types' import { prefetchQueue } from './prefetch-reducer' @@ -14,7 +15,7 @@ import { prefetchQueue } from './prefetch-reducer' * @param nextUrl - an internal URL, primarily used for handling rewrites. Defaults to '/'. * @return The generated prefetch cache key. */ -export function createPrefetchCacheKey(url: URL, nextUrl?: string | null) { +function createPrefetchCacheKey(url: URL, nextUrl?: string | null) { const pathnameFromUrl = createHrefFromUrl( url, // Ensures the hash is not part of the cache key as it does not impact the server fetch @@ -32,10 +33,37 @@ export function createPrefetchCacheKey(url: URL, nextUrl?: string | null) { return pathnameFromUrl } -export function getPrefetchCacheEntry( - url: URL, +/** + * Returns a prefetch cache entry if one exists. Otherwise creates a new one. + */ +export function getPrefetchCacheEntry({ + url, + state, + createIfNotFound, +}: { + url: URL + state: ReadonlyReducerState + createIfNotFound: true +}): PrefetchCacheEntry +export function getPrefetchCacheEntry({ + url, + state, + createIfNotFound, +}: { + url: URL + state: ReadonlyReducerState + createIfNotFound: false +}): PrefetchCacheEntry | undefined +export function getPrefetchCacheEntry({ + url, + state, + createIfNotFound, +}: { + url: URL state: ReadonlyReducerState -): PrefetchCacheEntry | undefined { + createIfNotFound: boolean +}): PrefetchCacheEntry | undefined { + let existingCacheEntry: PrefetchCacheEntry | undefined = undefined // We first check if there's a more specific interception route prefetch entry // This is because when we detect a prefetch that corresponds with an interception route, we prefix it with nextUrl (see `createPrefetchCacheKey`) // to avoid conflicts with other pages that may have the same URL but render different things depending on the `Next-URL` header. @@ -43,27 +71,50 @@ export function getPrefetchCacheEntry( const interceptionData = state.prefetchCache.get(interceptionCacheKey) if (interceptionData) { - return interceptionData + existingCacheEntry = interceptionData + } else { + // If we dont find a more specific interception route prefetch entry, we check for a regular prefetch entry + const prefetchCacheKey = createPrefetchCacheKey(url) + const prefetchData = state.prefetchCache.get(prefetchCacheKey) + if (prefetchData) { + existingCacheEntry = prefetchData + } } - // If we dont find a more specific interception route prefetch entry, we check for a regular prefetch entry - const prefetchCacheKey = createPrefetchCacheKey(url) - return state.prefetchCache.get(prefetchCacheKey) + if (existingCacheEntry) { + // Grab the latest status of the cache entry and update it + existingCacheEntry.status = getPrefetchEntryCacheStatus(existingCacheEntry) + return existingCacheEntry + } + + // When retrieving a prefetch entry, we usually want to create one if it doesn't exist + // This let's us create a new one if it doesn't exist to avoid needing typeguards in the calling code + if (createIfNotFound) { + // If we don't have a prefetch value, we need to create one + return createPrefetchCacheEntry({ + state, + url, + // in dev, there's never gonna be a prefetch entry so we want to prefetch here + kind: + process.env.NODE_ENV === 'development' + ? PrefetchKind.AUTO + : PrefetchKind.TEMPORARY, + }) + } } export function createPrefetchCacheEntry({ state, url, kind, - prefetchCacheKey, }: { state: ReadonlyReducerState url: URL kind: PrefetchKind - prefetchCacheKey: string }): PrefetchCacheEntry { // initiates the fetch request for the prefetch and attaches a listener // to the promise to update the prefetch cache entry when the promise resolves (if necessary) + const prefetchCacheKey = createPrefetchCacheKey(url) const getPrefetchData = () => fetchServerResponse( url, @@ -88,14 +139,19 @@ export function createPrefetchCacheEntry({ const data = prefetchQueue.enqueue(getPrefetchData) - return { + const prefetchEntry = { treeAtTimeOfPrefetch: state.tree, data, kind, prefetchTime: Date.now(), lastUsedTime: null, key: prefetchCacheKey, + status: PrefetchCacheEntryStatus.fresh, } + + state.prefetchCache.set(prefetchCacheKey, prefetchEntry) + + return prefetchEntry } export function prunePrefetchCache( @@ -114,13 +170,6 @@ export function prunePrefetchCache( const FIVE_MINUTES = 5 * 60 * 1000 const THIRTY_SECONDS = 30 * 1000 -export enum PrefetchCacheEntryStatus { - fresh = 'fresh', - reusable = 'reusable', - expired = 'expired', - stale = 'stale', -} - export function getPrefetchEntryCacheStatus({ kind, prefetchTime, diff --git a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx index 88efb764f6b3ad..d3a6b3f8cae13e 100644 --- a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx @@ -4,7 +4,11 @@ import type { FlightData } from '../../../../server/app-render/types' import type { FlightRouterState } from '../../../../server/app-render/types' import type { CacheNode } from '../../../../shared/lib/app-router-context.shared-runtime' import { createInitialRouterState } from '../create-initial-router-state' -import { ACTION_PREFETCH, PrefetchKind } from '../router-reducer-types' +import { + ACTION_PREFETCH, + PrefetchCacheEntryStatus, + PrefetchKind, +} from '../router-reducer-types' import type { PrefetchAction } from '../router-reducer-types' import { prefetchReducer } from './prefetch-reducer' import { fetchServerResponse } from '../fetch-server-response' @@ -153,6 +157,7 @@ describe('prefetchReducer', () => { kind: PrefetchKind.AUTO, lastUsedTime: null, prefetchTime: expect.any(Number), + status: PrefetchCacheEntryStatus.fresh, treeAtTimeOfPrefetch: [ '', { @@ -310,6 +315,7 @@ describe('prefetchReducer', () => { prefetchTime: expect.any(Number), kind: PrefetchKind.AUTO, lastUsedTime: null, + status: PrefetchCacheEntryStatus.fresh, treeAtTimeOfPrefetch: [ '', { diff --git a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts index 851a648538303a..954bf3305e6689 100644 --- a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts @@ -7,7 +7,6 @@ import { PrefetchKind } from '../router-reducer-types' import { NEXT_RSC_UNION_QUERY } from '../../app-router-headers' import { PromiseQueue } from '../../promise-queue' import { - createPrefetchCacheKey, createPrefetchCacheEntry, getPrefetchCacheEntry, prunePrefetchCache, @@ -25,7 +24,11 @@ export function prefetchReducer( const { url } = action url.searchParams.delete(NEXT_RSC_UNION_QUERY) - const cacheEntry = getPrefetchCacheEntry(url, state) + const cacheEntry = getPrefetchCacheEntry({ + url, + state, + createIfNotFound: false, + }) if (cacheEntry) { /** @@ -53,15 +56,7 @@ export function prefetchReducer( } } - const prefetchCacheKey = createPrefetchCacheKey(url) - const newEntry = createPrefetchCacheEntry({ - state, - url, - kind: action.kind, - prefetchCacheKey, - }) - - state.prefetchCache.set(prefetchCacheKey, newEntry) + createPrefetchCacheEntry({ url, state, kind: action.kind }) return state } diff --git a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx index 3ad5b14e2b146a..76a6a8708fe674 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx @@ -448,6 +448,7 @@ describe('serverPatchReducer', () => { "kind": "temporary", "lastUsedTime": 1690329600000, "prefetchTime": 1690329600000, + "status": "fresh", "treeAtTimeOfPrefetch": [ "", { diff --git a/packages/next/src/client/components/router-reducer/router-reducer-types.ts b/packages/next/src/client/components/router-reducer/router-reducer-types.ts index 4870257ad19121..9268aca1fa0f60 100644 --- a/packages/next/src/client/components/router-reducer/router-reducer-types.ts +++ b/packages/next/src/client/components/router-reducer/router-reducer-types.ts @@ -203,6 +203,14 @@ export type PrefetchCacheEntry = { prefetchTime: number lastUsedTime: number | null key: string + status: PrefetchCacheEntryStatus +} + +export enum PrefetchCacheEntryStatus { + fresh = 'fresh', + reusable = 'reusable', + expired = 'expired', + stale = 'stale', } /** diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index e3fbc3a43d4b41..48dabdfaac79fc 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1746,6 +1746,35 @@ export default abstract class Server { } } + protected pathCouldBeIntercepted(resolvedPathname: string): boolean { + return ( + isInterceptionRouteAppPath(resolvedPathname) || + this.interceptionRouteRewrites?.some((rewrite) => { + return new RegExp(rewrite.regex).test(resolvedPathname) + }) + ) + } + + protected setVaryHeaderForAppPath( + req: BaseNextRequest, + res: BaseNextResponse, + resolvedPathname: string + ): void { + // Interception route responses can vary based on the `Next-URL` header as they're rewritten to different components. + // This means that multiple route interception responses can resolve to the same URL. We use the Vary header to signal this + // behavior to the client so that it can properly cache the response. + // If the request that we're handling is one that could have a different response based on the `Next-URL` header, or if + // we're handling an interception route, then we include `Next-URL` in the Vary header. + if (this.pathCouldBeIntercepted(resolvedPathname)) { + res.setHeader('vary', `${RSC_VARY_HEADER}, ${NEXT_URL}`) + } else { + res.setHeader('vary', RSC_VARY_HEADER) + + // strip `Next-URL` from the request headers so we know that if it wasn't intercepted, nothing is relying on it being present + delete req.headers[NEXT_URL] + } + } + private async renderToResponseWithComponentsImpl( { req, res, pathname, renderOpts: opts }: RequestContext, { components, query }: FindComponentsResult @@ -1760,6 +1789,7 @@ export default abstract class Server { const is500Page = pathname === '/500' const isAppPath = components.isAppPath === true + const hasServerProps = !!components.getServerSideProps let hasStaticPaths = !!components.getStaticPaths const isServerAction = getIsServerAction(req) @@ -1773,6 +1803,10 @@ export default abstract class Server { let resolvedUrlPathname = getRequestMeta(req, 'rewroteURL') || urlPathname + if (isAppPath) { + this.setVaryHeaderForAppPath(req, res, resolvedUrlPathname) + } + let staticPaths: string[] | undefined let fallbackMode: FallbackMode @@ -1998,26 +2032,6 @@ export default abstract class Server { } if (isAppPath) { - res.setHeader('vary', RSC_VARY_HEADER) - - const couldBeIntercepted = this.interceptionRouteRewrites?.some( - (rewrite) => { - return new RegExp(rewrite.regex).test(resolvedUrlPathname) - } - ) - - // Interception route responses can vary based on the `Next-URL` header as they're rewritten to different components. - // This means that multiple route interception responses can resolve to the same URL. We use the Vary header to signal this - // behavior to the client so that it can properly cache the response. - // If the request that we're handling is one that could have a different response based on the `Next-URL` header, or if - // we're handling an interception route, then we include `Next-URL` in the Vary header. - if ( - couldBeIntercepted || - isInterceptionRouteAppPath(resolvedUrlPathname) - ) { - res.setHeader('vary', `${RSC_VARY_HEADER}, ${NEXT_URL}`) - } - if (!this.renderOpts.dev && !isPreviewMode && isSSG && isRSCRequest) { // If this is an RSC request but we aren't in minimal mode, then we mark // that this is a data request so that we can generate the flight data