diff --git a/packages/next/src/client/components/app-router-headers.ts b/packages/next/src/client/components/app-router-headers.ts index 41bb97d1afd89..cc73b1b88ce72 100644 --- a/packages/next/src/client/components/app-router-headers.ts +++ b/packages/next/src/client/components/app-router-headers.ts @@ -6,7 +6,7 @@ export const NEXT_ROUTER_PREFETCH_HEADER = 'Next-Router-Prefetch' as const export const NEXT_URL = 'Next-Url' as const export const RSC_CONTENT_TYPE_HEADER = 'text/x-component' as const export const RSC_VARY_HEADER = - `${RSC_HEADER}, ${NEXT_ROUTER_STATE_TREE}, ${NEXT_ROUTER_PREFETCH_HEADER}, ${NEXT_URL}` as const + `${RSC_HEADER}, ${NEXT_ROUTER_STATE_TREE}, ${NEXT_ROUTER_PREFETCH_HEADER}` as const export const FLIGHT_PARAMETERS = [ [RSC_HEADER], diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index b6cb4d43bb4fb..2af83aa8a203c 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -33,11 +33,12 @@ import { hexHash } from '../../../shared/lib/hash' export type FetchServerResponseResult = [ flightData: FlightData, canonicalUrlOverride: URL | undefined, - postponed?: boolean + postponed?: boolean, + intercepted?: boolean ] function doMpaNavigation(url: string): FetchServerResponseResult { - return [urlToUrlWithoutFlightMarker(url).toString(), undefined] + return [urlToUrlWithoutFlightMarker(url).toString(), undefined, false, false] } /** @@ -112,6 +113,7 @@ export async function fetchServerResponse( const contentType = res.headers.get('content-type') || '' const postponed = !!res.headers.get(NEXT_DID_POSTPONE_HEADER) + const interception = !!res.headers.get('vary')?.includes(NEXT_URL) let isFlightResponse = contentType === RSC_CONTENT_TYPE_HEADER if (process.env.NODE_ENV === 'production') { @@ -145,7 +147,7 @@ export async function fetchServerResponse( return doMpaNavigation(res.url) } - return [flightData, canonicalUrl, postponed] + return [flightData, canonicalUrl, postponed, interception] } catch (err) { console.error( `Failed to fetch RSC payload for ${url}. Falling back to browser navigation.`, @@ -154,6 +156,6 @@ export async function fetchServerResponse( // If fetch fails handle it like a mpa navigation // TODO-APP: Add a test for the case where a CORS request fails, e.g. external url redirect coming from the response. // See https://github.com/vercel/next.js/issues/43605#issuecomment-1451617521 for a reproduction. - return [url.toString(), undefined] + return [url.toString(), undefined, false, false] } } diff --git a/packages/next/src/client/components/router-reducer/reducers/create-prefetch-cache-key.ts b/packages/next/src/client/components/router-reducer/reducers/create-prefetch-cache-key.ts index f9f1e3e394aaf..5fc9528d277df 100644 --- a/packages/next/src/client/components/router-reducer/reducers/create-prefetch-cache-key.ts +++ b/packages/next/src/client/components/router-reducer/reducers/create-prefetch-cache-key.ts @@ -1,5 +1,3 @@ -import { addPathPrefix } from '../../../../shared/lib/router/utils/add-path-prefix' -import { pathHasPrefix } from '../../../../shared/lib/router/utils/path-has-prefix' import { createHrefFromUrl } from '../create-href-from-url' /** @@ -9,7 +7,7 @@ import { createHrefFromUrl } from '../create-href-from-url' * @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) { +export 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 @@ -17,12 +15,11 @@ export function createPrefetchCacheKey(url: URL, nextUrl: string | null) { ) // delimit the prefix so we don't conflict with other pages - const nextUrlPrefix = `${nextUrl}%` // Route interception depends on `nextUrl` values which aren't a 1:1 mapping to a URL // The cache key that we store needs to use `nextUrl` to properly distinguish cache entries - if (nextUrl && !pathHasPrefix(pathnameFromUrl, nextUrl)) { - return addPathPrefix(pathnameFromUrl, nextUrlPrefix) + if (nextUrl) { + return `${nextUrl}%${pathnameFromUrl}` } return pathnameFromUrl 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 91b0785b29d38..79f7012b457f6 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 @@ -25,7 +25,7 @@ import { getPrefetchEntryCacheStatus, } from '../get-prefetch-cache-entry-status' import { prunePrefetchCache } from './prune-prefetch-cache' -import { prefetchQueue } from './prefetch-reducer' +import { createPrefetchEntry, prefetchQueue } from './prefetch-reducer' import { createEmptyCacheNode } from '../../app-router' import { DEFAULT_SEGMENT_KEY } from '../../../../shared/lib/segment' import { @@ -127,45 +127,37 @@ function navigateReducer_noPPR( return handleExternalUrl(state, mutable, url.toString(), pendingPush) } - const prefetchCacheKey = createPrefetchCacheKey(url, state.nextUrl) - let prefetchValues = state.prefetchCache.get(prefetchCacheKey) + const prefetchCacheKey = createPrefetchCacheKey(url) + const interceptionCacheKey = createPrefetchCacheKey(url, state.nextUrl) + let prefetchValues = + // first check if there's a more specific interception route prefetch entry + // as we don't want to potentially re-use a cache node that would resolve to the same URL + // but renders differently when intercepted + state.prefetchCache.get(interceptionCacheKey) || + state.prefetchCache.get(prefetchCacheKey) // If we don't have a prefetch value, we need to create one if (!prefetchValues) { - const data = fetchServerResponse( + prefetchValues = createPrefetchEntry({ + state, url, - state.tree, - state.nextUrl, - state.buildId, // in dev, there's never gonna be a prefetch entry so we want to prefetch here - // in order to simulate the behavior of the prefetch cache - process.env.NODE_ENV === 'development' ? PrefetchKind.AUTO : undefined - ) - - const newPrefetchValue = { - data, - // this will make sure that the entry will be discarded after 30s kind: process.env.NODE_ENV === 'development' ? PrefetchKind.AUTO : PrefetchKind.TEMPORARY, - prefetchTime: Date.now(), - treeAtTimeOfPrefetch: state.tree, - lastUsedTime: null, - } + prefetchCacheKey, + }) - state.prefetchCache.set(prefetchCacheKey, newPrefetchValue) - prefetchValues = newPrefetchValue + state.prefetchCache.set(prefetchCacheKey, prefetchValues) } const prefetchEntryCacheStatus = getPrefetchEntryCacheStatus(prefetchValues) - // The one before last item is the router state tree patch const { treeAtTimeOfPrefetch, data } = prefetchValues + prefetchQueue.bump(data) - prefetchQueue.bump(data!) - - return data!.then( + return data.then( ([flightData, canonicalUrlOverride]) => { // we only want to mark this once if (prefetchValues && !prefetchValues.lastUsedTime) { @@ -319,45 +311,37 @@ function navigateReducer_PPR( return handleExternalUrl(state, mutable, url.toString(), pendingPush) } - const prefetchCacheKey = createPrefetchCacheKey(url, state.nextUrl) - let prefetchValues = state.prefetchCache.get(prefetchCacheKey) + const prefetchCacheKey = createPrefetchCacheKey(url) + const interceptionCacheKey = createPrefetchCacheKey(url, state.nextUrl) + let prefetchValues = + // first check if there's a more specific interception route prefetch entry + // as we don't want to potentially re-use a cache node that would resolve to the same URL + // but renders differently when intercepted + state.prefetchCache.get(interceptionCacheKey) || + state.prefetchCache.get(prefetchCacheKey) // If we don't have a prefetch value, we need to create one if (!prefetchValues) { - const data = fetchServerResponse( + prefetchValues = createPrefetchEntry({ + state, url, - state.tree, - state.nextUrl, - state.buildId, // in dev, there's never gonna be a prefetch entry so we want to prefetch here - // in order to simulate the behavior of the prefetch cache - process.env.NODE_ENV === 'development' ? PrefetchKind.AUTO : undefined - ) - - const newPrefetchValue = { - data, - // this will make sure that the entry will be discarded after 30s kind: process.env.NODE_ENV === 'development' ? PrefetchKind.AUTO : PrefetchKind.TEMPORARY, - prefetchTime: Date.now(), - treeAtTimeOfPrefetch: state.tree, - lastUsedTime: null, - } + prefetchCacheKey, + }) - state.prefetchCache.set(prefetchCacheKey, newPrefetchValue) - prefetchValues = newPrefetchValue + state.prefetchCache.set(prefetchCacheKey, prefetchValues) } const prefetchEntryCacheStatus = getPrefetchEntryCacheStatus(prefetchValues) - // The one before last item is the router state tree patch const { treeAtTimeOfPrefetch, data } = prefetchValues + prefetchQueue.bump(data) - prefetchQueue.bump(data!) - - return data!.then( + return data.then( ([flightData, canonicalUrlOverride, _postponed]) => { // we only want to mark this once if (prefetchValues && !prefetchValues.lastUsedTime) { 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 d6aa994a7f882..c13c199d63b90 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 @@ -3,6 +3,7 @@ import type { PrefetchAction, ReducerState, ReadonlyReducerState, + PrefetchCacheEntry, } from '../router-reducer-types' import { PrefetchKind } from '../router-reducer-types' import { prunePrefetchCache } from './prune-prefetch-cache' @@ -22,7 +23,7 @@ export function prefetchReducer( const { url } = action url.searchParams.delete(NEXT_RSC_UNION_QUERY) - const prefetchCacheKey = createPrefetchCacheKey(url, state.nextUrl) + let prefetchCacheKey = createPrefetchCacheKey(url) const cacheEntry = state.prefetchCache.get(prefetchCacheKey) if (cacheEntry) { @@ -51,27 +52,60 @@ export function prefetchReducer( } } - // fetchServerResponse is intentionally not awaited so that it can be unwrapped in the navigate-reducer - const serverResponse = prefetchQueue.enqueue(() => + const newEntry = createPrefetchEntry({ + state, + url, + kind: action.kind, + prefetchCacheKey, + }) + + state.prefetchCache.set(prefetchCacheKey, newEntry) + + return state +} + +export function createPrefetchEntry({ + 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 getPrefetchData = () => fetchServerResponse( url, - // initialTree is used when history.state.tree is missing because the history state is set in `useEffect` below, it being missing means this is the hydration case. state.tree, state.nextUrl, state.buildId, - action.kind - ) - ) + kind + ).then((prefetchResponse) => { + /* [flightData, canonicalUrlOverride, postpone, intercept] */ + const [, , , intercept] = prefetchResponse + const existingPrefetchEntry = state.prefetchCache.get(prefetchCacheKey) + // If we discover that the prefetch corresponds with an interception route, we want to move it to + // a prefixed cache key to avoid clobbering an existing entry. + if (intercept && existingPrefetchEntry) { + const prefixedCacheKey = createPrefetchCacheKey(url, state.nextUrl) + state.prefetchCache.set(prefixedCacheKey, existingPrefetchEntry) + state.prefetchCache.delete(prefetchCacheKey) + } - // Create new tree based on the flightSegmentPath and router state patch - state.prefetchCache.set(prefetchCacheKey, { - // Create new tree based on the flightSegmentPath and router state patch + return prefetchResponse + }) + + const data = prefetchQueue.enqueue(getPrefetchData) + + return { treeAtTimeOfPrefetch: state.tree, - data: serverResponse, - kind: action.kind, + data, + kind, prefetchTime: Date.now(), lastUsedTime: null, - }) - - return state + } } 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 44dde7edb59fc..b7f66b87789b7 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 @@ -199,7 +199,7 @@ export type FocusAndScrollRef = { export type PrefetchCacheEntry = { treeAtTimeOfPrefetch: FlightRouterState - data: Promise | null + data: Promise kind: PrefetchKind prefetchTime: number lastUsedTime: number | null diff --git a/packages/next/src/lib/generate-interception-routes-rewrites.ts b/packages/next/src/lib/generate-interception-routes-rewrites.ts index 931f2199675c2..4de355fc51b3b 100644 --- a/packages/next/src/lib/generate-interception-routes-rewrites.ts +++ b/packages/next/src/lib/generate-interception-routes-rewrites.ts @@ -6,6 +6,7 @@ import { isInterceptionRouteAppPath, } from '../server/future/helpers/interception-routes' import type { Rewrite } from './load-custom-routes' +import type { ManifestRewriteRoute } from '../build' // a function that converts normalised paths (e.g. /foo/[bar]/[baz]) to the format expected by pathToRegexp (e.g. /foo/:bar/:baz) function toPathToRegexpPath(path: string): string { @@ -86,3 +87,8 @@ export function generateInterceptionRoutesRewrites( return rewrites } + +export function isInterceptionRouteRewrite(route: ManifestRewriteRoute) { + // When we generate interception rewrites in the above implementation, we always do so with only a single `has` condition. + return route.has?.[0].key === NEXT_URL +} diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 16072e62ec073..15b99d11a5b3d 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -84,6 +84,7 @@ import { NEXT_RSC_UNION_QUERY, NEXT_ROUTER_PREFETCH_HEADER, NEXT_DID_POSTPONE_HEADER, + NEXT_URL, } from '../client/components/app-router-headers' import type { MatchOptions, @@ -129,6 +130,10 @@ import { import { PrefetchRSCPathnameNormalizer } from './future/normalizers/request/prefetch-rsc' import { NextDataPathnameNormalizer } from './future/normalizers/request/next-data' import { getIsServerAction } from './lib/server-action-request-meta' +import { isInterceptionRouteAppPath } from './future/helpers/interception-routes' +import { generateInterceptionRoutesRewrites } from '../lib/generate-interception-routes-rewrites' +import { pathToRegexp } from 'next/dist/compiled/path-to-regexp' +import { normalizeRouteRegex } from '../lib/load-custom-routes' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -2805,6 +2810,54 @@ export default abstract class Server { res.statusCode = cachedData.status } + if (isPrefetchRSCRequest && routeModule && this.appPathRoutes) { + // when prefetching interception routes, the prefetch cache key can vary based on Next-URL + // as multiple interception routes might resolve to the same URL but map to different components + + // interception routes are implemented as beforeFiles rewrites + + let interceptionRewrites: ManifestRewriteRoute[] = [] + + // TODO: Move these into separate handlers & cache them + // They need to be handled differently because dev doesn't have access to a routes manifest file + if (process.env.NODE_ENV === 'development') { + interceptionRewrites = generateInterceptionRoutesRewrites( + Object.keys(this.appPathRoutes || {}), + this.nextConfig.basePath + ).map((route) => { + // TODO: This should use buildCustomRoute + const compiled = pathToRegexp(route.source, [], { + strict: true, + sensitive: false, + delimiter: '/', // default is `/#?`, but Next does not pass query info + }) + + let source = compiled.source + + const regex = normalizeRouteRegex(source) + + return { ...route, regex } + }) + } else { + interceptionRewrites = + this.getRoutesManifest()?.rewrites.beforeFiles.filter( + // interception rewrites only contain a single `has` entry to verify Next-URL + (r) => r.has?.[0].key === NEXT_URL + ) ?? [] + } + + const couldBeRewritten = interceptionRewrites.some((rewrite) => { + return new RegExp(rewrite.regex).test(routeModule.definition.pathname) + }) + + if ( + couldBeRewritten || + isInterceptionRouteAppPath(routeModule.definition.pathname) + ) { + res.setHeader('vary', `${RSC_VARY_HEADER}, ${NEXT_URL}`) + } + } + // Mark that the request did postpone if this is a data request. if (cachedData.postponed && isRSCRequest) { res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') diff --git a/test/e2e/app-dir/app-prefetch/app/layout.js b/test/e2e/app-dir/app-prefetch/app/layout.js index c2349110eb852..d766f46be0679 100644 --- a/test/e2e/app-dir/app-prefetch/app/layout.js +++ b/test/e2e/app-dir/app-prefetch/app/layout.js @@ -2,6 +2,7 @@ export default function Root({ children }) { return ( + Hello World