diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index cc6a6b18f399..d0d72b65de73 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -137,6 +137,7 @@ import { isAppRouteRoute } from '../lib/is-app-route-route' import { createClientRouterFilter } from '../lib/create-client-router-filter' import { createValidFileMatcher } from '../server/lib/find-page-file' import { startTypeChecking } from './type-check' +import { generateInterceptionRoutesRewrites } from '../lib/generate-interception-routes-rewrites' export type SsgRoute = { initialRevalidateSeconds: number | false @@ -519,6 +520,12 @@ export default async function build( appPageKeys.push(normalizedAppPageKey) } } + + // Interception routes are modelled as beforeFiles rewrites + rewrites.beforeFiles.unshift( + ...generateInterceptionRoutesRewrites(appPageKeys) + ) + const totalAppPagesCount = appPageKeys.length const pageKeys = { diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx index ebbe4aec34b0..3518705e869e 100644 --- a/packages/next/src/client/app-index.tsx +++ b/packages/next/src/client/app-index.tsx @@ -240,6 +240,7 @@ export function hydrate() { apply: false, hashFragment: null, }, + nextUrl: null, }} > diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index dd5205603949..4326aa07b3e2 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -298,7 +298,11 @@ function InnerLayoutRouter({ */ childNodes.set(cacheKey, { status: CacheStates.DATA_FETCH, - data: fetchServerResponse(new URL(url, location.origin), refetchTree), + data: fetchServerResponse( + new URL(url, location.origin), + refetchTree, + context.nextUrl + ), subTreeData: null, head: childNode && childNode.status === CacheStates.LAZY_INITIALIZED diff --git a/packages/next/src/client/components/router-reducer/compute-changed-path.ts b/packages/next/src/client/components/router-reducer/compute-changed-path.ts new file mode 100644 index 000000000000..f2366383b0ec --- /dev/null +++ b/packages/next/src/client/components/router-reducer/compute-changed-path.ts @@ -0,0 +1,96 @@ +import { FlightRouterState, Segment } from '../../../server/app-render/types' +import { INTERCEPTION_ROUTE_MARKERS } from '../../../server/future/helpers/interception-routes' +import { matchSegment } from '../match-segments' + +const segmentToPathname = (segment: Segment): string => { + if (typeof segment === 'string') { + return segment + } + + return segment[1] +} + +export function extractPathFromFlightRouterState( + flightRouterState: FlightRouterState +): string | undefined { + const segment = Array.isArray(flightRouterState[0]) + ? flightRouterState[0][1] + : flightRouterState[0] + + if ( + segment === '__DEFAULT__' || + INTERCEPTION_ROUTE_MARKERS.some((m) => segment.startsWith(m)) + ) + return undefined + + if (segment === '__PAGE__') return '' + + const path = [segment] + + const parallelRoutes = flightRouterState[1] ?? {} + + const childrenPath = parallelRoutes.children + ? extractPathFromFlightRouterState(parallelRoutes.children) + : undefined + + if (childrenPath !== undefined) { + path.push(childrenPath) + } else { + for (const [key, value] of Object.entries(parallelRoutes)) { + if (key === 'children') continue + + const childPath = extractPathFromFlightRouterState(value) + + if (childPath !== undefined) { + path.push(childPath) + } + } + } + + const finalPath = path.join('/') + + // it'll end up including a trailing slash because of '__PAGE__' + return finalPath.endsWith('/') ? finalPath.slice(0, -1) : finalPath +} + +export function computeChangedPath( + treeA: FlightRouterState, + treeB: FlightRouterState +): string | null { + const [segmentA, parallelRoutesA] = treeA + const [segmentB, parallelRoutesB] = treeB + + const normalizedSegmentA = segmentToPathname(segmentA) + const normalizedSegmentB = segmentToPathname(segmentB) + + if ( + INTERCEPTION_ROUTE_MARKERS.some( + (m) => + normalizedSegmentA.startsWith(m) || normalizedSegmentB.startsWith(m) + ) + ) { + return '' + } + + if (!matchSegment(segmentA, segmentB)) { + // once we find where the tree changed, we compute the rest of the path by traversing the tree + return extractPathFromFlightRouterState(treeB) ?? '' + } + + for (const parallelRouterKey in parallelRoutesA) { + if (parallelRoutesB[parallelRouterKey]) { + const changedPath = computeChangedPath( + parallelRoutesA[parallelRouterKey], + parallelRoutesB[parallelRouterKey] + ) + if (changedPath !== null) { + if (changedPath === '') { + return segmentToPathname(segmentB) + } + return `${segmentToPathname(segmentB)}/${changedPath}` + } + } + } + + return null +} diff --git a/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx b/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx index b49f888eb5df..33e274ce8803 100644 --- a/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx +++ b/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx @@ -95,6 +95,7 @@ describe('createInitialRouterState', () => { pushRef: { pendingPush: false, mpaNavigation: false }, focusAndScrollRef: { apply: false, hashFragment: null }, cache: expectedCache, + nextUrl: '/linking', } expect(state).toMatchObject(expected) diff --git a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts index 4391d5893e37..d4db35bf3296 100644 --- a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts +++ b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts @@ -51,5 +51,6 @@ export function createInitialRouterState({ ? // window.location does not have the same type as URL but has all the fields createHrefFromUrl needs. createHrefFromUrl(location) : initialCanonicalUrl, + nextUrl: location?.pathname ?? null, } } 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 ff6e079a73a8..ae11340ca564 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 @@ -8,6 +8,7 @@ import type { import { NEXT_ROUTER_PREFETCH, NEXT_ROUTER_STATE_TREE, + NEXT_URL, RSC, RSC_CONTENT_TYPE_HEADER, } from '../app-router-headers' @@ -21,11 +22,13 @@ import { callServer } from '../../app-call-server' export async function fetchServerResponse( url: URL, flightRouterState: FlightRouterState, + nextUrl: string | null, prefetch?: true ): Promise<[FlightData: FlightData, canonicalUrlOverride: URL | undefined]> { const headers: { [RSC]: '1' [NEXT_ROUTER_STATE_TREE]: string + [NEXT_URL]?: string [NEXT_ROUTER_PREFETCH]?: '1' } = { // Enable flight response @@ -38,6 +41,10 @@ export async function fetchServerResponse( headers[NEXT_ROUTER_PREFETCH] = '1' } + if (nextUrl) { + headers[NEXT_URL] = nextUrl + } + try { let fetchUrl = url if (process.env.NODE_ENV === 'production') { diff --git a/packages/next/src/client/components/router-reducer/handle-mutable.ts b/packages/next/src/client/components/router-reducer/handle-mutable.ts index a3b6e3e9ddf3..04bcfca0f154 100644 --- a/packages/next/src/client/components/router-reducer/handle-mutable.ts +++ b/packages/next/src/client/components/router-reducer/handle-mutable.ts @@ -1,3 +1,4 @@ +import { computeChangedPath } from './compute-changed-path' import { Mutable, ReadonlyReducerState, @@ -50,5 +51,10 @@ export function handleMutable( typeof mutable.patchedTree !== 'undefined' ? mutable.patchedTree : state.tree, + nextUrl: + typeof mutable.patchedTree !== 'undefined' + ? computeChangedPath(state.tree, mutable.patchedTree) ?? + state.canonicalUrl + : state.nextUrl, } } diff --git a/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts index 742ea2eab398..a96ab90000ce 100644 --- a/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts @@ -32,12 +32,11 @@ function fastRefreshReducerImpl( // TODO-APP: verify that `href` is not an external url. // Fetch data from the root of the tree. cache.data = createRecordFromThenable( - fetchServerResponse(new URL(href, origin), [ - state.tree[0], - state.tree[1], - state.tree[2], - 'refetch', - ]) + fetchServerResponse( + new URL(href, origin), + [state.tree[0], state.tree[1], state.tree[2], 'refetch'], + state.nextUrl + ) ) } const [flightData, canonicalUrlOverride] = readRecordValue(cache.data!) 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 ed75c173f063..6fca8c3246dc 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 @@ -195,6 +195,7 @@ describe('navigateReducer', () => { hashFragment: null, }, canonicalUrl: '/linking/about', + nextUrl: '/linking/about', cache: { status: CacheStates.READY, data: null, @@ -378,6 +379,7 @@ describe('navigateReducer', () => { hashFragment: null, }, canonicalUrl: '/linking/about', + nextUrl: '/linking/about', cache: { status: CacheStates.READY, data: null, @@ -564,6 +566,7 @@ describe('navigateReducer', () => { hashFragment: null, }, canonicalUrl: 'https://example.vercel.sh/', + nextUrl: '/linking', cache: { status: CacheStates.READY, data: null, @@ -721,6 +724,7 @@ describe('navigateReducer', () => { hashFragment: null, }, canonicalUrl: 'https://example.vercel.sh/', + nextUrl: '/linking', cache: { status: CacheStates.READY, data: null, @@ -875,6 +879,7 @@ describe('navigateReducer', () => { hashFragment: null, }, canonicalUrl: '/linking/about', + nextUrl: '/linking/about', cache: { status: CacheStates.READY, data: null, @@ -1098,6 +1103,7 @@ describe('navigateReducer', () => { hashFragment: null, }, canonicalUrl: '/linking/about', + nextUrl: '/linking/about', cache: { status: CacheStates.READY, data: null, @@ -1325,6 +1331,7 @@ describe('navigateReducer', () => { hashFragment: null, }, canonicalUrl: '/parallel-tab-bar/demographics', + nextUrl: '/parallel-tab-bar/demographics', cache: { status: CacheStates.READY, data: null, 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 14e5b4cd463a..9a88088ef402 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 @@ -169,7 +169,7 @@ export function navigateReducer( state.cache, // TODO-APP: segments.slice(1) strips '', we can get rid of '' altogether. segments.slice(1), - () => fetchServerResponse(url, optimisticTree) + () => fetchServerResponse(url, optimisticTree, state.nextUrl) ) // If optimistic fetch couldn't happen it falls back to the non-optimistic case. @@ -190,7 +190,9 @@ export function navigateReducer( // If no in-flight fetch at the top, start it. if (!cache.data) { - cache.data = createRecordFromThenable(fetchServerResponse(url, state.tree)) + cache.data = createRecordFromThenable( + fetchServerResponse(url, state.tree, state.nextUrl) + ) } // Unwrap cache data with `use` to suspend here (in the reducer) until the fetch resolves. 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 e645e4533f92..95b1c24f6c6f 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 @@ -124,7 +124,12 @@ describe('prefetchReducer', () => { }) const url = new URL('/linking/about', 'https://localhost') - const serverResponse = await fetchServerResponse(url, initialTree, true) + const serverResponse = await fetchServerResponse( + url, + initialTree, + null, + true + ) const action: PrefetchAction = { type: ACTION_PREFETCH, url, @@ -195,6 +200,7 @@ describe('prefetchReducer', () => { undefined, true, ], + nextUrl: '/linking', } expect(newState).toMatchObject(expectedState) @@ -262,7 +268,12 @@ describe('prefetchReducer', () => { }) const url = new URL('/linking/about', 'https://localhost') - const serverResponse = await fetchServerResponse(url, initialTree, true) + const serverResponse = await fetchServerResponse( + url, + initialTree, + null, + true + ) const action: PrefetchAction = { type: ACTION_PREFETCH, url, @@ -335,6 +346,7 @@ describe('prefetchReducer', () => { undefined, true, ], + nextUrl: '/linking', } expect(newState).toMatchObject(expectedState) 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 7749b9009052..c46dbda969d8 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 @@ -29,6 +29,7 @@ export function prefetchReducer( 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, true ) ) diff --git a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx index 2ca7f4556a04..337ba122d550 100644 --- a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx @@ -154,6 +154,7 @@ describe('refreshReducer', () => { hashFragment: null, }, canonicalUrl: '/linking', + nextUrl: '/linking', cache: { status: CacheStates.READY, data: null, @@ -311,6 +312,7 @@ describe('refreshReducer', () => { hashFragment: null, }, canonicalUrl: '/linking', + nextUrl: '/linking', cache: { status: CacheStates.READY, data: null, @@ -492,6 +494,7 @@ describe('refreshReducer', () => { hashFragment: null, }, canonicalUrl: '/linking', + nextUrl: '/linking', cache: { status: CacheStates.READY, data: null, @@ -722,6 +725,7 @@ describe('refreshReducer', () => { hashFragment: null, }, canonicalUrl: '/linking', + nextUrl: '/linking', cache: { status: CacheStates.READY, data: null, diff --git a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts index 11087da3221f..89584855be58 100644 --- a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts @@ -32,12 +32,11 @@ export function refreshReducer( // TODO-APP: verify that `href` is not an external url. // Fetch data from the root of the tree. cache.data = createRecordFromThenable( - fetchServerResponse(new URL(href, origin), [ - state.tree[0], - state.tree[1], - state.tree[2], - 'refetch', - ]) + fetchServerResponse( + new URL(href, origin), + [state.tree[0], state.tree[1], state.tree[2], 'refetch'], + state.nextUrl + ) ) } const [flightData, canonicalUrlOverride] = readRecordValue(cache.data!) diff --git a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx index 5ea3e0eaf236..1d91b95c7e61 100644 --- a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx @@ -126,6 +126,7 @@ describe('serverPatchReducer', () => { hashFragment: null, }, canonicalUrl: '/linking/about', + nextUrl: '/linking/about', cache: { status: CacheStates.READY, data: null, @@ -287,6 +288,7 @@ describe('serverPatchReducer', () => { hashFragment: null, }, canonicalUrl: '/linking/about', + nextUrl: '/linking/about', cache: { status: CacheStates.READY, data: null, diff --git a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts index f352c08e515c..a734119d2935 100644 --- a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts @@ -21,5 +21,6 @@ export function restoreReducer( prefetchCache: state.prefetchCache, // Restore provided tree tree: tree, + nextUrl: url.pathname, } } 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 6fe017eae757..51317920acd7 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 @@ -189,6 +189,7 @@ describe('serverPatchReducer', () => { hashFragment: null, }, canonicalUrl: '/linking/about', + nextUrl: '/linking/somewhere-else', cache: { status: CacheStates.READY, data: null, @@ -383,6 +384,7 @@ describe('serverPatchReducer', () => { hashFragment: null, }, canonicalUrl: '/linking/about', + nextUrl: '/linking/about', cache: { status: CacheStates.READY, data: null, @@ -556,6 +558,7 @@ describe('serverPatchReducer', () => { hashFragment: null, }, canonicalUrl: '/linking/about', + nextUrl: '/linking/somewhere-else', cache: { status: CacheStates.READY, data: null, 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 fa583f948106..21b38b999393 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 @@ -188,6 +188,10 @@ export type AppRouterState = { * - This is the url you see in the browser. */ canonicalUrl: string + /** + * The underlying "url" representing the UI state, which is used for intercepting routes. + */ + nextUrl: string | null } export type ReadonlyReducerState = Readonly diff --git a/packages/next/src/lib/generate-interception-routes-rewrites.ts b/packages/next/src/lib/generate-interception-routes-rewrites.ts new file mode 100644 index 000000000000..72051294b753 --- /dev/null +++ b/packages/next/src/lib/generate-interception-routes-rewrites.ts @@ -0,0 +1,53 @@ +import { pathToRegexp } from 'next/dist/compiled/path-to-regexp' +import { NEXT_URL } from '../client/components/app-router-headers' +import { + extractInterceptionRouteInformation, + isInterceptionRouteAppPath, +} from '../server/future/helpers/interception-routes' +import { Rewrite } from './load-custom-routes' + +// 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 { + return path.replace(/\[([^\]]+)\]/g, ':$1') +} + +export function generateInterceptionRoutesRewrites( + appPaths: string[] +): Rewrite[] { + const rewrites: Rewrite[] = [] + + for (const appPath of appPaths) { + if (isInterceptionRouteAppPath(appPath)) { + const { interceptingRoute, interceptedRoute } = + extractInterceptionRouteInformation(appPath) + + const normalizedInterceptingRoute = `${toPathToRegexpPath( + interceptingRoute + )}/(.*)?` + + const normalizedInterceptedRoute = toPathToRegexpPath(interceptedRoute) + const normalizedAppPath = toPathToRegexpPath(appPath) + + // pathToRegexp returns a regex that matches the path, but we need to + // convert it to a string that can be used in a header value + // to the format that Next/the proxy expects + let interceptingRouteRegex = pathToRegexp(normalizedInterceptingRoute) + .toString() + .slice(2, -3) + + rewrites.push({ + source: normalizedInterceptedRoute, + destination: normalizedAppPath, + has: [ + { + type: 'header', + key: NEXT_URL, + value: interceptingRouteRegex, + }, + ], + }) + } + } + + return rewrites +} diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index faf4a257aec9..4c758690d5d3 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -307,6 +307,11 @@ export async function renderToHTMLOrFlight( let value = pathParams[key] + // this is a special marker that will be present for interception routes + if (value === '__NEXT_EMPTY_PARAM__') { + value = undefined + } + if (Array.isArray(value)) { value = value.map((i) => encodeURIComponent(i)) } else if (typeof value === 'string') { diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 98d3ed5be034..4bd1ef07cb69 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -99,7 +99,6 @@ import { I18NProvider } from './future/helpers/i18n-provider' import { sendResponse } from './send-response' import { RouteKind } from './future/route-kind' import { handleInternalServerErrorResponse } from './future/route-modules/helpers/response-handlers' -import { parseNextReferrerFromHeaders } from './lib/parse-next-referrer' import { fromNodeHeaders, toNodeHeaders } from './web/utils' import { NEXT_QUERY_PARAM_PREFIX } from '../lib/constants' @@ -745,7 +744,6 @@ export default abstract class Server { let srcPathname = matchedPath const match = await this.matchers.match(matchedPath, { i18n: localeAnalysisResult, - referrer: parseNextReferrerFromHeaders(req.headers), }) // Update the source pathname to the matched page's pathname. @@ -2094,14 +2092,13 @@ export default abstract class Server { private async renderToResponseImpl( ctx: RequestContext ): Promise { - const { res, query, pathname, req } = ctx + const { res, query, pathname } = ctx let page = pathname const bubbleNoFallback = !!query._nextBubbleNoFallback delete query._nextBubbleNoFallback const options: MatchOptions = { i18n: this.i18nProvider?.fromQuery(pathname, query), - referrer: parseNextReferrerFromHeaders(req.headers), } try { diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 9f5f9d8c02bd..9c3f2fe91f33 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -103,6 +103,7 @@ import LRUCache from 'next/dist/compiled/lru-cache' import { NextUrlWithParsedQuery } from '../request-meta' import { deserializeErr, errorToJSON } from '../render' import { invokeRequest } from '../lib/server-ipc' +import { generateInterceptionRoutesRewrites } from '../../lib/generate-interception-routes-rewrites' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -789,6 +790,23 @@ export default class DevServer extends Server { } : undefined + this.customRoutes = await loadCustomRoutes(this.nextConfig) + this.customRoutes.rewrites.beforeFiles.unshift( + ...generateInterceptionRoutesRewrites(Object.keys(appPaths)) + ) + const { rewrites } = this.customRoutes + if ( + rewrites.beforeFiles.length || + rewrites.afterFiles.length || + rewrites.fallback.length + ) { + this.router.setRewrites( + this.generateRewrites({ + restrictedRedirectPaths: [], + }) + ) + } + try { // we serve a separate manifest with all pages for the client in // dev mode so that we can match a page after a rewrite on the client diff --git a/packages/next/src/server/future/helpers/interception-routes.test.ts b/packages/next/src/server/future/helpers/interception-routes.test.ts index e24679d9aaf6..98cc4b11e15b 100644 --- a/packages/next/src/server/future/helpers/interception-routes.test.ts +++ b/packages/next/src/server/future/helpers/interception-routes.test.ts @@ -1,19 +1,19 @@ import { extractInterceptionRouteInformation, - isIntersectionRouteAppPath, + isInterceptionRouteAppPath, } from './interception-routes' describe('Interception Route helper', () => { - describe('isIntersectionRouteAppPath', () => { + describe('isInterceptionRouteAppPath', () => { it('should validate correct paths', () => { - expect(isIntersectionRouteAppPath('/foo/(..)/bar')).toBe(true) - expect(isIntersectionRouteAppPath('/foo/(...)/bar')).toBe(true) - expect(isIntersectionRouteAppPath('/foo/(..)(..)/bar')).toBe(true) + expect(isInterceptionRouteAppPath('/foo/(..)/bar')).toBe(true) + expect(isInterceptionRouteAppPath('/foo/(...)/bar')).toBe(true) + expect(isInterceptionRouteAppPath('/foo/(..)(..)/bar')).toBe(true) }) it('should not validate incorrect paths', () => { - expect(isIntersectionRouteAppPath('/foo/(..')).toBe(false) - expect(isIntersectionRouteAppPath('/foo/..)/bar')).toBe(false) - expect(isIntersectionRouteAppPath('/foo')).toBe(false) + expect(isInterceptionRouteAppPath('/foo/(..')).toBe(false) + expect(isInterceptionRouteAppPath('/foo/..)/bar')).toBe(false) + expect(isInterceptionRouteAppPath('/foo')).toBe(false) }) }) describe('extractInterceptionRouteInformation', () => { diff --git a/packages/next/src/server/future/helpers/interception-routes.ts b/packages/next/src/server/future/helpers/interception-routes.ts index 30bd5a08b33a..eac361aac729 100644 --- a/packages/next/src/server/future/helpers/interception-routes.ts +++ b/packages/next/src/server/future/helpers/interception-routes.ts @@ -3,7 +3,7 @@ import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths' // order matters here, the first match will be used export const INTERCEPTION_ROUTE_MARKERS = ['(..)(..)', '(..)', '(...)'] as const -export function isIntersectionRouteAppPath(path: string): boolean { +export function isInterceptionRouteAppPath(path: string): boolean { // TODO-APP: add more serious validation return ( path diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts index cf3c00ff3b58..060457d6d1f3 100644 --- a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts @@ -10,7 +10,6 @@ import { MatchOptions, RouteMatcherManager } from './route-matcher-manager' import { getSortedRoutes } from '../../../shared/lib/router/utils' import { LocaleRouteMatcher } from '../route-matchers/locale-route-matcher' import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash' -import { AppPageInterceptingRouteMatcher } from '../route-matchers/app-intercepting-route-matcher' interface RouteMatchers { static: ReadonlyArray @@ -164,19 +163,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { throw new Error('Invariant: expected to find identity in indexes map') } - // Sort the dynamic matches by the type of matcher. This ensures that - // intercepting matchers are always first for each pathname. - const dynamicMatches = indexes - .map((index) => dynamic[index]) - .sort((a, b) => { - if (a instanceof AppPageInterceptingRouteMatcher) { - return -1 - } - if (b instanceof AppPageInterceptingRouteMatcher) { - return 1 - } - return 0 - }) + const dynamicMatches = indexes.map((index) => dynamic[index]) sortedDynamicMatchers.push(...dynamicMatches) } @@ -239,10 +226,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { matcher: RouteMatcher, options: MatchOptions ): RouteMatch | null { - if ( - matcher instanceof LocaleRouteMatcher || - matcher instanceof AppPageInterceptingRouteMatcher - ) { + if (matcher instanceof LocaleRouteMatcher) { return matcher.match(pathname, options) } diff --git a/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts index 6a5058635955..dc4e8f024caa 100644 --- a/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts @@ -5,11 +5,6 @@ import type { LocaleAnalysisResult } from '../helpers/i18n-provider' export type MatchOptions = { skipDynamic?: boolean - /** - * If defined, this will be used as the referrer for the matching potential intercepting routes. - */ - referrer?: string | undefined - /** * If defined, this indicates to the matcher that the request should be * treated as locale-aware. If this is undefined, it means that this diff --git a/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts index 0d75ef2eb817..e63751c3cac0 100644 --- a/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts @@ -1,13 +1,8 @@ import { isAppPageRoute } from '../../../lib/is-app-page-route' -import { - extractInterceptionRouteInformation, - isIntersectionRouteAppPath, -} from '../helpers/interception-routes' import { APP_PATHS_MANIFEST } from '../../../shared/lib/constants' import { AppNormalizers } from '../normalizers/built/app' import { RouteKind } from '../route-kind' -import { AppPageInterceptingRouteMatcher } from '../route-matchers/app-intercepting-route-matcher' import { AppPageRouteMatcher } from '../route-matchers/app-page-route-matcher' import { Manifest, @@ -48,33 +43,16 @@ export class AppPageRouteMatcherProvider extends ManifestRouteMatcherProvider { @@ -69,33 +65,16 @@ export class DevAppPageRouteMatcherProvider extends FileCacheRouteMatcherProvide } const { pathname, page, bundlePath } = cached - if (isIntersectionRouteAppPath(pathname)) { - const { interceptingRoute, interceptedRoute } = - extractInterceptionRouteInformation(pathname) - matchers.push( - new AppPageInterceptingRouteMatcher({ - kind: RouteKind.APP_PAGE, - pathname: interceptedRoute, - page, - bundlePath, - filename, - appPaths: appPaths[pathname], - interceptingRoute: interceptingRoute, - pathnameOverride: pathname, - }) - ) - } else { - matchers.push( - new AppPageRouteMatcher({ - kind: RouteKind.APP_PAGE, - pathname, - page, - bundlePath, - filename, - appPaths: appPaths[pathname], - }) - ) - } + matchers.push( + new AppPageRouteMatcher({ + kind: RouteKind.APP_PAGE, + pathname, + page, + bundlePath, + filename, + appPaths: appPaths[pathname], + }) + ) } return matchers } diff --git a/packages/next/src/server/future/route-matchers/app-intercepting-route-matcher.ts b/packages/next/src/server/future/route-matchers/app-intercepting-route-matcher.ts deleted file mode 100644 index d93aa9eb212d..000000000000 --- a/packages/next/src/server/future/route-matchers/app-intercepting-route-matcher.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { AppPageInterceptingRouteDefinition } from '../route-definitions/app-page-route-definition' -import type { AppPageInterceptingRouteMatch } from '../route-matches/app-page-route-match' -import { RouteMatcher } from './route-matcher' - -export type AppPageInterceptingRouteMatcherMatchOptions = { - /** - * If provided, this is used for intercepting routes to resolve. - */ - referrer?: string -} - -export class AppPageInterceptingRouteMatcher extends RouteMatcher { - private readonly interceptingRouteMatcher: RouteMatcher - - constructor(definition: AppPageInterceptingRouteDefinition) { - super(definition) - this.interceptingRouteMatcher = - new RouteMatcher({ - ...definition, - pathname: definition.interceptingRoute, - }) - } - - public get identity(): string { - // TODO: probably include other details about the entrypoint - return `${this.definition.pathname}?__nextPage=${this.definition.page}` - } - - /** - * Match will attempt to match the given pathname against this route while - * also taking into account the referrer information. - * - * @param pathname The pathname to match against. - * @param options The options to use when matching. - * @returns The match result, or `null` if there was no match. - */ - public match( - pathname: string, - options?: AppPageInterceptingRouteMatcherMatchOptions - ): AppPageInterceptingRouteMatch | null { - // This is like the parent `match` method but instead this injects the - // additional `options` into the - const result = this.test(pathname, options) - if (!result) return null - - return { - definition: this.definition, - params: result.params, - } - } - - /** - * Test will attempt to match the given pathname against this route while - * also taking into account the referrer information. - * - * @param pathname The pathname to match against. - * @param options The options to use when matching. - * @returns The match result, or `null` if there was no match. - */ - public test( - pathname: string, - options?: AppPageInterceptingRouteMatcherMatchOptions - ) { - // If this route does not have referrer information, then we can't match. - if (!options?.referrer) { - return null - } - - // If the intercepting route match does not match, then this route does not - // match. - if (!this.interceptingRouteMatcher.test(options.referrer)) { - return null - } - - // Perform the underlying test with the intercepted route definition. - return super.test(pathname) - } -} diff --git a/packages/next/src/server/lib/parse-next-referrer.test.ts b/packages/next/src/server/lib/parse-next-referrer.test.ts deleted file mode 100644 index 9fe35fcc1977..000000000000 --- a/packages/next/src/server/lib/parse-next-referrer.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { extractPathFromFlightRouterState } from './parse-next-referrer' - -describe('extractPathFromFlightRouterState', () => { - it('should return the correct referrer for a simple router state', () => { - expect( - extractPathFromFlightRouterState([ - '', - { - children: [ - 'parallel-tab-bar', - { - views: ['impressions', { children: ['__PAGE__', {}] }], - children: ['__DEFAULT__', {}], - audience: ['__DEFAULT__', {}], - }, - ], - }, - null, - null, - true, - ]) - ).toBe('/parallel-tab-bar/impressions') - }) - it('should return the correct referrer for a router state with an interception', () => { - expect( - extractPathFromFlightRouterState([ - '', - { - children: [ - 'intercepting-parallel-modal', - { - children: [ - ['username', 'jim', 'd'], - { - children: ['__PAGE__', {}], - modal: [ - '(..)photo', - { - children: [ - ['id', '0', 'd'], - { children: ['__PAGE__', {}] }, - ], - }, - null, - 'refetch', - ], - }, - ], - }, - ], - }, - ]) - ).toBe('/intercepting-parallel-modal/jim') - }) - it('should return the correct referrer for a router state with a nested interception', () => { - expect( - extractPathFromFlightRouterState([ - '', - { - children: [ - 'intercepting-parallel-modal', - { - children: [ - ['username', 'jim', 'd'], - { - children: ['__PAGE__', {}], - modal: [ - '(..)photo', - { - children: [ - ['id', '0', 'd'], - { children: ['__PAGE__', {}] }, - ], - }, - ], - }, - ], - }, - ], - }, - null, - null, - true, - ]) - ).toBe('/intercepting-parallel-modal/jim') - }) - it('should return the correct referrer for a router state with a default children', () => { - expect( - extractPathFromFlightRouterState([ - '', - { - children: [ - 'parallel-tab-bar', - { - audience: ['subscribers', { children: ['__PAGE__', {}] }], - children: ['__DEFAULT__', {}], - views: ['__DEFAULT__', {}], - }, - ], - }, - null, - null, - true, - ]) - ).toBe('/parallel-tab-bar/subscribers') - }) -}) diff --git a/packages/next/src/server/lib/parse-next-referrer.ts b/packages/next/src/server/lib/parse-next-referrer.ts deleted file mode 100644 index 6e38765ac51e..000000000000 --- a/packages/next/src/server/lib/parse-next-referrer.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { IncomingHttpHeaders } from 'http' -import { NEXT_ROUTER_STATE_TREE } from '../../client/components/app-router-headers' -import { parseAndValidateFlightRouterState } from '../app-render/parse-and-validate-flight-router-state' -import { FlightRouterState } from '../app-render/types' -import { INTERCEPTION_ROUTE_MARKERS } from '../future/helpers/interception-routes' - -export function extractPathFromFlightRouterState( - flightRouterState: FlightRouterState -): string | undefined { - const segment = Array.isArray(flightRouterState[0]) - ? flightRouterState[0][1] - : flightRouterState[0] - - if ( - segment === '__DEFAULT__' || - INTERCEPTION_ROUTE_MARKERS.some((m) => segment.startsWith(m)) - ) - return undefined - - if (segment === '__PAGE__') return '' - - const path = [segment] - - const parallelRoutes = flightRouterState[1] ?? {} - - const childrenPath = parallelRoutes.children - ? extractPathFromFlightRouterState(parallelRoutes.children) - : undefined - - if (childrenPath !== undefined) { - path.push(childrenPath) - } else { - for (const [key, value] of Object.entries(parallelRoutes)) { - if (key === 'children') continue - - const childPath = extractPathFromFlightRouterState(value) - - if (childPath !== undefined) { - path.push(childPath) - } - } - } - - const finalPath = path.join('/') - - // it'll end up including a trailing slash because of '__PAGE__' - return finalPath.endsWith('/') ? finalPath.slice(0, -1) : finalPath -} - -export function parseNextReferrerFromHeaders( - headers: IncomingHttpHeaders -): string | undefined { - const flightRouterState = parseAndValidateFlightRouterState( - headers[NEXT_ROUTER_STATE_TREE.toLowerCase()] - ) - - if (!flightRouterState) return undefined - return extractPathFromFlightRouterState(flightRouterState) -} diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index f5240f61ea5f..780807f237b3 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -100,7 +100,6 @@ import { NextNodeServerSpan } from './lib/trace/constants' import { nodeFs } from './lib/node-fs-methods' import { getRouteRegex } from '../shared/lib/router/utils/route-regex' import { removePathPrefix } from '../shared/lib/router/utils/remove-path-prefix' -import { parseNextReferrerFromHeaders } from './lib/parse-next-referrer' import { addPathPrefix } from '../shared/lib/router/utils/add-path-prefix' import { pathHasPrefix } from '../shared/lib/router/utils/path-has-prefix' import { filterReqHeaders, invokeRequest } from './lib/server-ipc' @@ -1363,7 +1362,6 @@ export default class NextNodeServer extends BaseServer { const options: MatchOptions = { i18n: this.i18nProvider?.fromQuery(pathname, query), - referrer: parseNextReferrerFromHeaders(req.headers), } const match = await this.matchers.match(pathname, options) @@ -2078,7 +2076,6 @@ export default class NextNodeServer extends BaseServer { const options: MatchOptions = { i18n: this.i18nProvider?.analyze(normalizedPathname), - referrer: parseNextReferrerFromHeaders(params.request.headers), } if (this.nextConfig.skipMiddlewareUrlNormalize) { diff --git a/packages/next/src/server/router.ts b/packages/next/src/server/router.ts index dc7e5b22f499..594d59ccaa75 100644 --- a/packages/next/src/server/router.ts +++ b/packages/next/src/server/router.ts @@ -27,7 +27,6 @@ import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing- import type { I18NProvider } from './future/helpers/i18n-provider' import { getTracer } from './lib/trace/tracer' import { RouterSpan } from './lib/trace/constants' -import { parseNextReferrerFromHeaders } from './lib/parse-next-referrer' type RouteResult = { finished: boolean @@ -84,7 +83,7 @@ export default class Router { private readonly headers: ReadonlyArray private readonly fsRoutes: Route[] private readonly redirects: ReadonlyArray - private readonly rewrites: { + private rewrites: { beforeFiles: ReadonlyArray afterFiles: ReadonlyArray fallback: ReadonlyArray @@ -145,6 +144,11 @@ export default class Router { this.needsRecompilation = true } + public setRewrites(rewrites: RouterOptions['rewrites']) { + this.rewrites = rewrites + this.needsRecompilation = true + } + public addFsRoute(fsRoute: Route) { // We use unshift so that we're sure the routes is defined before Next's // default routes. @@ -200,7 +204,6 @@ export default class Router { // not include dynamic matches. skipDynamic: true, i18n: this.i18nProvider?.analyze(pathname), - referrer: parseNextReferrerFromHeaders(req.headers), } // If the locale was inferred from the default, we should mark diff --git a/packages/next/src/shared/lib/app-router-context.ts b/packages/next/src/shared/lib/app-router-context.ts index cb9b4c609b6f..cd9af26dc15c 100644 --- a/packages/next/src/shared/lib/app-router-context.ts +++ b/packages/next/src/shared/lib/app-router-context.ts @@ -113,6 +113,7 @@ export const GlobalLayoutRouterContext = React.createContext<{ overrideCanonicalUrl: URL | undefined ) => void focusAndScrollRef: FocusAndScrollRef + nextUrl: string | null }>(null as any) export const TemplateContext = React.createContext(null as any) diff --git a/packages/next/src/shared/lib/router/utils/prepare-destination.ts b/packages/next/src/shared/lib/router/utils/prepare-destination.ts index f7338b1ccc09..97390e75af51 100644 --- a/packages/next/src/shared/lib/router/utils/prepare-destination.ts +++ b/packages/next/src/shared/lib/router/utils/prepare-destination.ts @@ -8,6 +8,10 @@ import type { BaseNextRequest } from '../../../../server/base-http' import { compile, pathToRegexp } from 'next/dist/compiled/path-to-regexp' import { escapeStringRegexp } from '../../escape-regexp' import { parseUrl } from './parse-url' +import { + INTERCEPTION_ROUTE_MARKERS, + isInterceptionRouteAppPath, +} from '../../../../server/future/helpers/interception-routes' /** * Ensure only a-zA-Z are used for param names for proper interpolating @@ -227,6 +231,27 @@ export function prepareDestination(args: { let newUrl + // for interception routes we don't have access to the dynamic segments from the + // referrer route so we mark them as noop for the app renderer so that it + // can retrieve them from the router state later on. This also allows us to + // compile the route properly with path-to-regexp, otherwise it will throw + // The compiler also thinks that the interception route marker is an unnamed param, hence '0', + // so we need to add it to the params object. + if (isInterceptionRouteAppPath(destPath)) { + main: for (const segment of destPath.split('/')) { + for (const marker of INTERCEPTION_ROUTE_MARKERS) { + if (segment.startsWith(marker)) { + args.params['0'] = marker + break main + } + } + if (segment.startsWith(':')) { + const param = segment.slice(1) + args.params[param] = '__NEXT_EMPTY_PARAM__' + } + } + } + try { newUrl = destPathCompiler(args.params) diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-routes/feed/nested/page.js b/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-routes/feed/nested/page.js new file mode 100644 index 000000000000..6fe66fe5744c --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-routes/feed/nested/page.js @@ -0,0 +1,16 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +

Feed

+
    + {Array.from({ length: 10 }).map((_, i) => ( +
  • + Link {i} +
  • + ))} +
+ + ) +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts b/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts index ec459464e335..d0343dd8bfff 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts +++ b/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts @@ -225,6 +225,43 @@ createNextDescribe( ) }) + it('should render intercepted route from a nested route', async () => { + const browser = await next.browser('/intercepting-routes/feed/nested') + + // Check if navigation to modal route works. + await check( + () => + browser + .elementByCss('[href="/intercepting-routes/photos/1"]') + .click() + .waitForElementByCss('#photo-intercepted-1') + .text(), + 'Photo INTERCEPTED 1' + ) + + // Check if intercepted route was rendered while existing page content was removed. + // Content would only be preserved when combined with parallel routes. + // await check(() => browser.elementByCss('#feed-page').text()).not.toBe('Feed') + + // Check if url matches even though it was intercepted. + await check( + () => browser.url(), + next.url + '/intercepting-routes/photos/1' + ) + + // Trigger a refresh, this should load the normal page, not the modal. + await check( + () => browser.refresh().waitForElementByCss('#photo-page-1').text(), + 'Photo PAGE 1' + ) + + // Check if the url matches still. + await check( + () => browser.url(), + next.url + '/intercepting-routes/photos/1' + ) + }) + it('should render modal when paired with parallel routes', async () => { const browser = await next.browser( '/intercepting-parallel-modal/vercel'