diff --git a/packages/vinext/src/entries/app-rsc-manifest.ts b/packages/vinext/src/entries/app-rsc-manifest.ts index c4d6bcd156..572e7cd117 100644 --- a/packages/vinext/src/entries/app-rsc-manifest.ts +++ b/packages/vinext/src/entries/app-rsc-manifest.ts @@ -118,6 +118,7 @@ function buildRouteEntries(routes: AppRoute[], imports: ImportAllocator): string (ir) => ` { convention: ${JSON.stringify(ir.convention)}, targetPattern: ${JSON.stringify(ir.targetPattern)}, + sourceMatchPattern: ${JSON.stringify(ir.sourceMatchPattern)}, interceptLayouts: [${ir.layoutPaths.map((layoutPath) => imports.getImportVar(layoutPath)).join(", ")}], page: ${imports.getImportVar(ir.pagePath)}, params: ${JSON.stringify(ir.params)}, diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index 78786821d5..60cd62b2ff 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -16,6 +16,22 @@ export type InterceptingRoute = { convention: string; /** The URL pattern this intercepts (e.g. "/photos/:id") */ targetPattern: string; + /** + * URL pattern of the *intercepting route* — the path that owns the slot + * containing this interception marker, with route groups and `@slot` + * segments stripped. Mirrors Next.js' `interceptingRoute` from + * `extractInterceptionRouteInformation`. + * + * Used at request time to gate `findIntercept` against the Next-URL / + * interception-context header: an intercept only fires when the source + * pathname matches `^(?:/.*)?$`. Without this gate + * a direct RSC fetch to the intercept target would render the modal + * instead of the underlying page. + * + * @see https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.ts + * @see https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/interception-routes.ts + */ + sourceMatchPattern: string; /** Absolute path to the intercepting page component */ pagePath: string; /** Absolute layout paths inside the intercepting route tree, outermost to innermost */ @@ -1777,7 +1793,10 @@ function scanForInterceptingPages( const restOfName = entry.name.slice(interceptMatch.prefix.length); const interceptDir = path.join(currentDir, entry.name); - // Find page files within this intercepting directory tree + // Find page files within this intercepting directory tree. + // `currentDir` is the *parent* of the marker dir — used by + // computeInterceptSourceMatchPattern to derive the intercepting-route + // URL (the path that owns the slot containing the marker). collectInterceptingPages( interceptDir, interceptDir, @@ -1785,6 +1804,7 @@ function scanForInterceptingPages( restOfName, routeDir, appDir, + currentDir, results, matcher, ); @@ -1824,6 +1844,14 @@ function collectInterceptingPages( interceptSegment: string, routeDir: string, appDir: string, + /** + * Filesystem directory that owns the slot containing the interception + * marker — i.e. the parent of the marker dir. Used to derive the + * intercepting-route URL pattern that gates `findIntercept` at request + * time. With route groups and `@slot` segments stripped, this becomes + * Next.js' `interceptingRoute`. + */ + interceptParentDir: string, results: InterceptingRoute[], matcher: ValidFileMatcher, parentLayoutPaths: readonly string[] = [], @@ -1845,10 +1873,12 @@ function collectInterceptingPages( appDir, ); if (targetPattern) { + const sourceMatchPattern = computeInterceptSourceMatchPattern(interceptParentDir, appDir); results.push({ convention, layoutPaths: [...layoutPaths], targetPattern: targetPattern.pattern, + sourceMatchPattern, pagePath: page, params: targetPattern.params, }); @@ -1869,6 +1899,7 @@ function collectInterceptingPages( interceptSegment, routeDir, appDir, + interceptParentDir, results, matcher, layoutPaths, @@ -1876,6 +1907,29 @@ function collectInterceptingPages( } } +/** + * Compute the URL pattern for the *intercepting route* — the path that + * owns the slot containing the interception marker. Route groups (`(name)`) + * and parallel slots (`@slot`) are stripped because Next.js' + * `normalizeAppPath` treats them as invisible in the URL. + * + * Mirrors Next.js' computation in `extractInterceptionRouteInformation`: + * `interceptingRoute = normalizeAppPath(path.split(marker, 2)[0])`. + * + * Returns `/` for the app root. + * + * @see https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/interception-routes.ts + */ +function computeInterceptSourceMatchPattern(interceptParentDir: string, appDir: string): string { + const segments = path.relative(appDir, interceptParentDir).split(path.sep).filter(Boolean); + const converted = convertSegmentsToRouteParts(segments); + const urlSegments = converted + ? converted.urlSegments + : segments.filter((segment) => !isInvisibleSegment(segment)); + if (urlSegments.length === 0) return "/"; + return "/" + urlSegments.join("/"); +} + /** * Check whether a path segment is invisible in the URL (route groups, parallel slots, "."). * diff --git a/packages/vinext/src/server/app-rsc-route-matching.ts b/packages/vinext/src/server/app-rsc-route-matching.ts index 61f04b6ce1..f2f4357d64 100644 --- a/packages/vinext/src/server/app-rsc-route-matching.ts +++ b/packages/vinext/src/server/app-rsc-route-matching.ts @@ -6,6 +6,25 @@ type AppRscRouteParams = RoutePatternParams; type AppRscInterceptForMatching = { targetPattern: string; + /** + * URL pattern of the *intercepting route* (the path that owns the slot, + * with route groups and `@slot` segments stripped). Mirrors Next.js' + * `interceptingRoute` from `extractInterceptionRouteInformation`. + * + * Next.js implements interception as a rewrite that fires only when the + * `Next-URL` header matches `^(?:/.*)?$`. vinext's + * matcher enforces the same constraint at `findIntercept`: an intercept + * whose `targetPattern` matches the request URL is only valid when the + * provided source pathname (X-Vinext-Interception-Context / Next-URL) + * matches this pattern, with descendants allowed. + * + * Optional for backwards compat: when absent or empty, the matcher falls + * back to the legacy behavior of matching by target alone (still gated on + * a non-null source pathname). + * + * @see https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.ts + */ + sourceMatchPattern?: string; interceptLayouts: readonly unknown[]; page: unknown; params: readonly string[]; @@ -30,6 +49,8 @@ type AppRscInterceptLookupEntry = { slotKey: string; targetPattern: string; targetPatternParts: string[]; + sourceMatchPattern: string | null; + sourceMatchPatternParts: string[] | null; interceptLayouts: readonly unknown[]; page: unknown; params: readonly string[]; @@ -60,25 +81,110 @@ export function createAppRscRouteMatcher( return trieMatch(routeTrie, appRscPathnameParts(url)); }, findIntercept(pathname, sourcePathname = null) { + // Mirror Next.js' rewrite semantics: interception only fires when the + // Next-URL header is present AND matches the intercepting route's regex + // (with descendants allowed). Without a source pathname there is no + // header for the rewrite to gate on, so we render the direct route. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.ts if (sourcePathname === null) return null; + const urlParts = appRscPathnameParts(pathname); const sourceParts = appRscPathnameParts(sourcePathname); + for (const entry of interceptLookup) { + // Primary gate: when the intercept declares a `sourceMatchPattern` + // (the intercepting route's path, descendants allowed), require the + // request's source pathname to satisfy it. This mirrors Next.js' + // `^(?:/.*)?$` header regex precisely and is the + // authoritative gate when the manifest carries the pattern. + if (!matchInterceptSource(sourceParts, entry)) continue; + const params = matchAppRscRoutePattern(urlParts, entry.targetPatternParts); - if (params !== null) { - const sourceRoute = routes[entry.sourceRouteIndex]; - const sourceParams = sourceRoute - ? matchAppRscRoutePattern(sourceParts, sourceRoute.patternParts) - : null; - if (sourceParams === null) continue; - return { ...entry, matchedParams: mergeMatchedParams(sourceParams, params) }; + if (params === null) continue; + + const sourceRoute = routes[entry.sourceRouteIndex]; + const matchedSourceParams = sourceRoute + ? matchAppRscRoutePattern(sourceParts, sourceRoute.patternParts) + : null; + + // Secondary gate (from #1249): when the entry has no + // `sourceMatchPatternParts` declared (older manifest shapes), reject + // sources that don't match the slot owner's route pattern exactly. + // This is the safety net that keeps unrelated sources from pulling + // in a modal they have no slot for. When `sourceMatchPatternParts` + // *is* declared, `matchInterceptSource` above has already approved + // the source (including descendants), so a stricter exact-match + // check on the slot-owner route here would defeat the descendant + // semantics — fall back to empty params instead. + if (matchedSourceParams === null && entry.sourceMatchPatternParts === null) { + continue; } + const sourceParams = matchedSourceParams ?? createRouteParams(); + return { ...entry, matchedParams: mergeMatchedParams(sourceParams, params) }; } return null; }, }; } +/** + * Check whether the request's source pathname (Next-URL / interception + * context) satisfies the intercept entry's intercepting-route pattern, with + * descendants allowed. Mirrors the header regex shape Next.js emits for the + * generated interception rewrite: `^(?:/.*)?$`. + * + * When the entry has no declared `sourceMatchPatternParts`, fall back to the + * legacy behavior of accepting any source (we still require the source to be + * non-null at the caller — see `findIntercept`). + */ +function matchInterceptSource(sourceParts: string[], entry: AppRscInterceptLookupEntry): boolean { + const patternParts = entry.sourceMatchPatternParts; + if (!patternParts) return true; + // Root pattern (`/`) matches any source. + if (patternParts.length === 0) return true; + return matchPathPrefix(sourceParts, patternParts); +} + +/** + * True when `pathParts` matches `patternParts` from index 0 and all pattern + * segments are satisfied. Trailing path segments beyond the pattern are + * allowed (to mirror Next.js' `(?:/.*)?` tail on intercepting-route headers). + * + * Supports the same segment syntax as the route trie: + * - `:name` — dynamic, exactly one segment + * - `:name+` — catch-all, 1+ segments (must be terminal in the pattern) + * - `:name*` — optional catch-all, 0+ segments (must be terminal) + * - anything else — literal segment + */ +function matchPathPrefix(pathParts: string[], patternParts: string[]): boolean { + let pi = 0; + for (let i = 0; i < patternParts.length; i++) { + const part = patternParts[i]; + const isTerminal = i === patternParts.length - 1; + + if (part.startsWith(":") && part.endsWith("+")) { + if (!isTerminal) return false; + // Catch-all must consume at least one remaining segment. + return pathParts.length - pi >= 1; + } + if (part.startsWith(":") && part.endsWith("*")) { + if (!isTerminal) return false; + // Optional catch-all accepts any tail (including empty). + return true; + } + if (pi >= pathParts.length) return false; + if (part.startsWith(":")) { + // Dynamic single-segment — any non-empty value matches. + pi++; + continue; + } + if (pathParts[pi] !== part) return false; + pi++; + } + // Pattern fully consumed; any extra path segments are descendants and OK. + return true; +} + function createInterceptLookup( routes: Route[], ): AppRscInterceptLookupEntry[] { @@ -89,12 +195,18 @@ function createInterceptLookup( for (const [slotKey, slotModule] of Object.entries(route.slots)) { if (!slotModule.intercepts) continue; for (const intercept of slotModule.intercepts) { + const sourceMatchPattern = intercept.sourceMatchPattern ?? null; + const sourceMatchPatternParts = sourceMatchPattern + ? sourceMatchPattern.split("/").filter(Boolean) + : null; interceptLookup.push({ sourceRouteIndex: routeIndex, slotKey, slotId: typeof slotModule.id === "string" ? slotModule.id : null, targetPattern: intercept.targetPattern, targetPatternParts: intercept.targetPattern.split("/").filter(Boolean), + sourceMatchPattern, + sourceMatchPatternParts, interceptLayouts: intercept.interceptLayouts, page: intercept.page, params: intercept.params, diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index 75a133c180..c9383e100e 100644 --- a/tests/app-route-graph.test.ts +++ b/tests/app-route-graph.test.ts @@ -842,4 +842,140 @@ describe("App Router route graph builder", () => { expect(patterns).toHaveLength(0); }); }); + + // Intercepting route source-pattern computation. Mirrors Next.js' + // `extractInterceptionRouteInformation` which derives the intercepting + // route from the slot's owner path (route groups + `@slot` segments are + // invisible). The pattern is used at request time to gate `findIntercept` + // against the Next-URL header. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/interception-routes.ts + describe("intercepting routes", () => { + function collectIntercepts(routes: readonly AppRouteGraphRoute[]) { + const out: Array<{ + ownerRoute: string; + slotKey: string; + targetPattern: string; + sourceMatchPattern: string; + convention: string; + }> = []; + for (const route of routes) { + for (const slot of route.parallelSlots) { + for (const ir of slot.interceptingRoutes) { + out.push({ + ownerRoute: route.pattern, + slotKey: slot.key, + targetPattern: ir.targetPattern, + sourceMatchPattern: ir.sourceMatchPattern, + convention: ir.convention, + }); + } + } + } + return out; + } + + it("computes `/` for root-level (.) slot", async () => { + // Mirrors test/e2e/app-dir/parallel-routes-and-interception-basepath. + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "nested/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "@slot/default.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "@slot/(.)nested/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const intercepts = collectIntercepts(graph.routes); + + expect(intercepts).toContainEqual( + expect.objectContaining({ + targetPattern: "/nested", + sourceMatchPattern: "/", + convention: ".", + }), + ); + }); + }); + + it("computes `/feed` for (..) slot nested under a static segment", async () => { + // Mirrors the (..) marker scoped to a parallel slot: source pathname + // must match the slot's owner directory (`/feed`), and the target + // pattern climbs one segment to `/photos/:id`. + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "photos/[id]/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "feed/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "feed/@modal/default.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "feed/@modal/(..)photos/[id]/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const intercepts = collectIntercepts(graph.routes); + + expect(intercepts).toContainEqual( + expect.objectContaining({ + targetPattern: "/photos/:id", + sourceMatchPattern: "/feed", + convention: "..", + }), + ); + }); + }); + + it("strips `@modal` and keeps dynamic ancestor segments", async () => { + // Mirrors test/e2e/app-dir/parallel-routes-and-interception-from-root: + // app/[locale]/example/@modal/(...)[locale]/intercepted/page.tsx + // intercepting route = /[locale]/example. + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "[locale]/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "[locale]/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "[locale]/example/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "[locale]/example/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "[locale]/example/@modal/default.tsx", EMPTY_PAGE); + await writeAppFile( + appDir, + "[locale]/example/@modal/(...)[locale]/intercepted/page.tsx", + EMPTY_PAGE, + ); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const intercepts = collectIntercepts(graph.routes); + + expect(intercepts).toContainEqual( + expect.objectContaining({ + targetPattern: "/:locale/intercepted", + sourceMatchPattern: "/:locale/example", + convention: "...", + }), + ); + }); + }); + + it("computes intercepting route across `(..)(..)` two-levels-up marker", async () => { + // Inspired by test/e2e/app-dir/interception-segments-two-levels-above + // but adapted to use a parallel slot, which is the structure vinext + // currently supports for interception markers. + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "hoge/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "foo/bar/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "foo/bar/@modal/default.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "foo/bar/@modal/(..)(..)hoge/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const intercepts = collectIntercepts(graph.routes); + + // (..)(..) target climbs two visible segments from /foo/bar → /, + // then appends `hoge`. Intercepting route remains /foo/bar. + expect(intercepts).toContainEqual( + expect.objectContaining({ + targetPattern: "/hoge", + sourceMatchPattern: "/foo/bar", + convention: "../..", + }), + ); + }); + }); + }); }); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 05c116a6cc..14716b2f6b 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -743,6 +743,12 @@ describe("App Router integration", () => { // RSC request simulates client-side navigation from /team/[teamId]/members // to /team/[teamId]/settings. The source route has a dynamic :teamId segment. // The intercepting route handler must extract "42" from the URL, not ":teamId". + // + // The X-Vinext-Interception-Context header carries the source pathname + // (the equivalent of Next.js' Next-URL header). Without it the matcher + // must NOT fire the interception, matching Next.js' rewrite semantics — + // see app-rsc-route-matching.ts and the source-pathname filtering tests + // in app-rsc-route-matching.test.ts. const res = await fetch(`${baseUrl}/team/42/settings.rsc`, { headers: { Accept: "text/x-component", @@ -763,6 +769,41 @@ describe("App Router integration", () => { expect(rscPayload).not.toContain('":teamId"'); }); + it("does NOT fire intercept on direct RSC request without interception context", async () => { + // Mirrors Next.js: interception rewrites only fire when the Next-URL + // header matches the intercepting-route regex. A direct `.rsc` fetch + // with no source pathname must render the underlying page. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.ts + const res = await fetch(`${baseUrl}/team/42/settings.rsc`, { + headers: { Accept: "text/x-component" }, + }); + expect(res.status).toBe(200); + + const rscPayload = await res.text(); + expect(rscPayload).not.toContain("Settings Modal"); + expect(rscPayload).not.toContain("settings-modal"); + expect(rscPayload).toContain("settings-page"); + }); + + it("does NOT fire intercept when interception context is from an unrelated route", async () => { + // The intercept lives at app/team/[teamId]/members/@modal/(..)settings, + // so its sourceMatchPattern is /team/:teamId/members. A source pathname + // outside that prefix (e.g. `/feed`) must not satisfy the rewrite header + // and the underlying settings page should render. + const res = await fetch(`${baseUrl}/team/42/settings.rsc`, { + headers: { + Accept: "text/x-component", + "X-Vinext-Interception-Context": "/feed", + }, + }); + expect(res.status).toBe(200); + + const rscPayload = await res.text(); + expect(rscPayload).not.toContain("Settings Modal"); + expect(rscPayload).not.toContain("settings-modal"); + expect(rscPayload).toContain("settings-page"); + }); + it("returns Method Not Allowed for unsupported HTTP methods on route handlers", async () => { const res = await fetch(`${baseUrl}/api/hello`, { method: "DELETE" }); expect(res.status).toBe(405); @@ -4855,6 +4896,7 @@ describe("generateRscEntry ISR code generation", () => { pagePath: "/tmp/test/app/@modal/(.)explicit-layout/deeper/page.tsx", params: [], targetPattern: "/explicit-layout/deeper", + sourceMatchPattern: "/", }, ], key: "modal@@modal", diff --git a/tests/app-rsc-route-matching.test.ts b/tests/app-rsc-route-matching.test.ts index 315c988077..870d38193f 100644 --- a/tests/app-rsc-route-matching.test.ts +++ b/tests/app-rsc-route-matching.test.ts @@ -187,6 +187,158 @@ describe("App RSC route matching", () => { }); expect(matcher.findIntercept("/photos/anything", "/feed")).toBeNull(); }); + + // Ported from Next.js: lib/generate-interception-routes-rewrites.test.ts + // https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.test.ts + // + // Interception is implemented in Next.js as a rewrite that only fires when + // the Next-URL header matches the *intercepting route* regex (the path that + // owns the slot, with route groups and @slot segments stripped). + // + // vinext must enforce the same constraint at the matcher boundary: an + // intercept entry whose targetPattern matches the request URL is only valid + // when the provided source pathname matches its declared sourceMatchPattern. + // Otherwise the matcher must fall through to null so the direct route is + // rendered. + describe("source-pathname filtering (mirrors Next.js Next-URL header rewrite)", () => { + it("returns null when the source pathname does not match the intercepting route", () => { + // Slot lives at root (`/@modal`) and intercepts `/groups/[id]/new`. + // Intercepting route = `/` so any source under `/` is allowed. + // But for a slot at `/templates`, only `/templates(?:/.*)?` sources qualify. + const matcher = createAppRscRouteMatcher([ + route("/templates/:catchAll+", ["templates", ":catchAll+"], { + modal: { + intercepts: [ + { + // (..)showcase/[...catchAll] from `app/templates` + sourceMatchPattern: "/templates", + targetPattern: "/showcase/:catchAll+", + interceptLayouts: ["layout"], + page: "intercept-page", + params: ["catchAll"], + }, + ], + }, + }), + ]); + + // Source under /templates — should intercept. + expect(matcher.findIntercept("/showcase/multi/slug", "/templates/multi/slug")).toMatchObject({ + targetPattern: "/showcase/:catchAll+", + }); + + // Source NOT under /templates — must not intercept. + expect(matcher.findIntercept("/showcase/single", "/")).toBeNull(); + expect(matcher.findIntercept("/showcase/single", "/other")).toBeNull(); + }); + + it("returns null when no source pathname is provided (no Next-URL header)", () => { + // Without a Next-URL header the rewrite cannot fire in Next.js, so the + // direct page must render. vinext should mirror that. + const matcher = createAppRscRouteMatcher([ + route("/templates/:catchAll+", ["templates", ":catchAll+"], { + modal: { + intercepts: [ + { + sourceMatchPattern: "/templates", + targetPattern: "/showcase/:catchAll+", + interceptLayouts: ["layout"], + page: "intercept-page", + params: ["catchAll"], + }, + ], + }, + }), + ]); + + expect(matcher.findIntercept("/showcase/multi/slug", null)).toBeNull(); + expect(matcher.findIntercept("/showcase/multi/slug")).toBeNull(); + }); + + it("accepts descendants of the intercepting route as valid sources", () => { + // Header regex appends `(?:/.*)?` to allow any descendant of the + // intercepting route to trigger the rewrite. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.ts + const matcher = createAppRscRouteMatcher([ + route("/feed/:id", ["feed", ":id"], { + modal: { + intercepts: [ + { + // (.)photos/[id] from `app/feed/[id]` + sourceMatchPattern: "/feed/:id", + targetPattern: "/feed/:id/photos/:photoId", + interceptLayouts: ["layout"], + page: "modal-photo", + params: ["id", "photoId"], + }, + ], + }, + }), + ]); + + // Exact match on intercepting route. + expect(matcher.findIntercept("/feed/abc/photos/1", "/feed/abc")).not.toBeNull(); + // Descendant of intercepting route. + expect(matcher.findIntercept("/feed/abc/photos/1", "/feed/abc/nested/deep")).not.toBeNull(); + // Parent of intercepting route — should NOT intercept. + expect(matcher.findIntercept("/feed/abc/photos/1", "/")).toBeNull(); + // Sibling — should NOT intercept. + expect(matcher.findIntercept("/feed/abc/photos/1", "/other")).toBeNull(); + }); + + it("treats a sourceMatchPattern of `/` as matching any source", () => { + // Slot at root (`/@modal/(.)groups/[id]/new`) yields intercepting route `/`, + // which Next.js implements as `^/.*$` — i.e. any source. + const matcher = createAppRscRouteMatcher([ + route("/", [], { + modal: { + intercepts: [ + { + sourceMatchPattern: "/", + targetPattern: "/groups/:id/new", + interceptLayouts: ["layout"], + page: "modal-new", + params: ["id"], + }, + ], + }, + }), + ]); + + expect(matcher.findIntercept("/groups/123/new", "/")).not.toBeNull(); + expect(matcher.findIntercept("/groups/123/new", "/groups/123")).not.toBeNull(); + expect(matcher.findIntercept("/groups/123/new", "/anything/else/deep")).not.toBeNull(); + // But still must require a source pathname (Next-URL header) to fire. + expect(matcher.findIntercept("/groups/123/new", null)).toBeNull(); + }); + + it("matches dynamic segments in the intercepting route pattern", () => { + // /[lang]/foo/(..)photos has interceptingRoute `/[lang]/foo`, + // header regex `^/(?[^/]+)/foo(?:/.*)?$`. + const matcher = createAppRscRouteMatcher([ + route("/:lang/foo", [":lang", "foo"], { + modal: { + intercepts: [ + { + sourceMatchPattern: "/:lang/foo", + targetPattern: "/:lang/photos", + interceptLayouts: ["layout"], + page: "modal-photos", + params: ["lang"], + }, + ], + }, + }), + ]); + + expect(matcher.findIntercept("/en/photos", "/en/foo")).not.toBeNull(); + expect(matcher.findIntercept("/en/photos", "/en/foo/bar")).not.toBeNull(); + // Wrong dynamic value — still a descendant in URL terms but the + // intercepting route requires `//foo`, so `/en/bar` should fail. + expect(matcher.findIntercept("/en/photos", "/en/bar")).toBeNull(); + expect(matcher.findIntercept("/en/photos", "/en")).toBeNull(); + }); + }); }); function route( @@ -209,6 +361,13 @@ type TestRoute = { type TestIntercept = { targetPattern: string; + /** + * URL pattern of the intercepting route (the path that owns the slot, + * with route groups and `@slot` segments stripped). Mirrors Next.js' + * `interceptingRoute` from `extractInterceptionRouteInformation` and is + * used to gate `findIntercept` against the Next-URL header. + */ + sourceMatchPattern?: string; interceptLayouts: readonly unknown[]; page: unknown; params: string[]; diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 1af3688e8d..3a0472a1b9 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -234,6 +234,7 @@ describe("App Router generated manifest construction", () => { { convention: ".", targetPattern: "/photos/:photoId", + sourceMatchPattern: "/dashboard", pagePath: "/tmp/test/app/dashboard/@modal/(.)photos/[photoId]/page.tsx", layoutPaths: ["/tmp/test/app/dashboard/@modal/(.)photos/layout.tsx"], params: ["photoId"],