mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
b4c829d6a2
* test(interception): add failing sibling-interception graph tests * feat(routing): discover sibling-style interception markers * feat(routing): emit sibling interception manifest facts * feat(app-router): serialize sibling intercepts in route manifest * feat(app-router): match sibling intercepts in route matcher * feat(app-router): render sibling intercept as full page response * test: add siblingIntercepts field to AppRoute test fixtures * test(interception): add sibling interception fixtures * fix(app-router): emit null proof for sibling intercepts to bypass slot-validation * test(interception): add sibling interception e2e specs * fix(interception): correct (...)→(..)(..) marker in interception-from-root fixture * fix(interception): resolve sourceRouteIndex to slot-owner route in intercept lookup When a route inherits a @slot from an ancestor via discoverInheritedParallelSlots, the inherited slot's interceptingRoutes are copied from the ancestor. Previously, createInterceptLookup used routeIndex (the inheriting descendant's index) as sourceRouteIndex, causing resolveAppPageInterceptState to see sourceRoute === currentRoute → kind='current-route' instead of kind='source-route'. Fix: build a patternToIndex map and resolve sourceRouteIndex via the intercept's sourceMatchPattern (which names the actual slot-owner route), falling back to routeIndex only when no match is found. * fix(interception): use locale-prefixed href so middleware interception fires correctly * fix(interception): address codex review issues in sibling intercept implementation - Pass effectiveParams to resolveAppPageHead so generateMetadata/generateViewport sees the intercepted route's params instead of the source route's params - Rename SIBLING_INTERCEPT_SLOT_NAME from __page to __vinext_sibling_intercept to prevent collision with a user @__page parallel route directory - Restore middleware-rewrite fixture link without locale prefix so the interception-dynamic-segment-middleware spec exercises the intended middleware rewrite path * fix(test): update slotId assertions and restore locale-prefixed fixture link - Update test assertions from slot:__page to slot:__vinext_sibling_intercept following the rename in commit2248b8fe- Restore locale-prefixed href in interception-mw fixture so the interception-dynamic-segment-middleware E2E tests pass (reverts the accidental revert from2248b8feback to the working state from02e80c26) * fix(interception): address review findings on sibling interception PR - Restore locale-less href in interception-mw fixture and fix the underlying middleware-rewrite interception gap: add a Referer-based fallback in app-rsc-handler so interception fires when middleware rewrites the URL before the server matches it (the client-side manifest check sees the pre-rewrite target and skips setting the interception context header; the Referer carries the source page the server needs) - Apply interceptLayouts for sibling intercepts in buildPageElements so a layout.tsx under the interception marker dir wraps the intercepting page, matching Next.js segment-layout wrapping behaviour; add fixture layout and E2E assertion - Add unit test proving findOwnerRouteForDir ancestor walk attaches a sibling intercept to the nearest ancestor route when the marker's parent dir has no page.tsx (not dropped silently) * fix(interception): send current pathname as fallback interception context The client-side manifest check compares the pre-middleware target URL against declared interception patterns. When middleware rewrites the URL (e.g. /foo/p/1 → /en/foo/p/1), the pre-rewrite URL has fewer segments and doesn't match the pattern, so no X-Vinext-Interception-Context header is sent and the server cannot fire interception. Fix: when the manifest check yields no match and there is no prior intercepted navigation in browser state, fall back to sending the current page pathname as the interception context. This mirrors Next.js sending Next-URL on every RSC navigation. The server's findIntercept is double-gated on both source and target matching, so false positives cannot occur. The previousNextUrl is also populated so back/forward traversal can restore intercepted state. Revert the Referer-based server-side fallback (unreliable across environments) in favour of this explicit browser-side fix. * fix(interception): gate middleware-rewrite fallback on declared source pattern The previous fix sent window.location.pathname as fallback interception context on every unmatched navigation, which broke prefetch cache reuse: a prefetch stored with null context wouldn't be found when the navigation used a non-null context key. Refine the fallback to only fire when the current page URL matches at least one sourcePatternParts declared in the route manifest — i.e., the current page is a known interception source. This preserves prefetch cache reuse for ordinary navigations while still enabling middleware- rewrite interception for pages that declare an interception relationship. * fix(interception): support middleware-rewrite targets in fallback context and planner The previous fallback-context gate only checked whether the current page is a declared interception source; it did not verify the target URL could plausibly be a middleware-rewritten version of the declared target pattern. This meant the client sent interception context when it shouldn't (pre-middleware target had no static anchors in common with the pattern) and the planner hard-navigated when the proof target matched only after the rewrite. Three coordinated fixes: 1. Client fallback gate (app-browser-entry / app-browser-interception-context): replace the broad source-only check with matchRoutePatternWithOptionalDynamicSegments on the target side — the pre-middleware URL must still share every static anchor of the declared target pattern, in order, while allowing dynamic segments to be absent. 2. Planner interception proof (navigation-planner): findRouteManifestInterceptionForProof now accepts a proof whose target matched URL equals the pre-rewrite canonical pathname (shorter than the full pattern) when matchRoutePatternWithOptionalDynamicSegments passes. Exact pattern match still skips the targetRouteId guard; the relaxed path skips it too so the middleware-added locale segment does not invalidate an otherwise valid proof. 3. displayPathname threading (app-rsc-handler / app-rsc-entry / app-page-element-builder): the handler now passes canonicalPathname as displayPathname separately from cleanPathname. buildPageElements uses cleanPathname for all internal routing work (slot param extraction, slot overrides) and displayPathname only for render identity — keeping the browser-visible URL in RSC metadata without affecting internal matching. * fix(interception): guard siblingInterceptElement construction and document reserved slot name * fix(interception): surface missing default export on sibling intercept pages A sibling-style interception whose intercepting page module is missing its `default` export previously fell back to the source route's page component (`effectivePageModule?.default ?? PageComponent`), silently rendering a *different* page than the one requested. Resolve the effective component from the intercepting page only and extend the existing no-default-export guard to cover sibling intercepts, so a missing export surfaces the same explicit "Page has no default export" error the source/slot paths already produce. For a normal request this is identical to the previous behavior since `effectivePageModule === pageModule`. Addresses bonk review feedback on #1804. * test(app-router): guard canonical pathname commit on soft-nav config rewrites The `displayPathname = canonicalPathname` change feeds the RSC payload identity the client planner uses to commit a navigation, so it affects every rewritten route, not just interceptions. Existing coverage (nextjs-compat/hooks.test.ts) only exercises the hard-nav (SSR) path. Add an e2e that soft-navigates (client-side) to an afterFiles-rewritten route and asserts the committed URL and `usePathname()` stay canonical (pre-rewrite), with a marker check proving no full page reload occurred. Addresses bonk review feedback on #1804. * fix(interception): restore __pageLoader on slot intercept lookup entries The main merge auto-resolved incorrectly: it moved the `__pageLoader` field that #1738 added to the *slot* intercept push block onto the new *sibling* push block instead (the two `interceptLookup.push({...})` calls are textually similar). As a result slot intercept lookup entries lost `__pageLoader`, so resolveAppPageInterceptState never loaded the lazy intercepting page (`page` stayed null) and the modal slot fell back to default.tsx — breaking every slot interception. Restore `__pageLoader: intercept.__pageLoader` on the slot push block. The sibling block already carries it (see merge commit), and both intercept types declare the optional field. Fixes the app-router-dev-server intercept tests and the interception e2e suites that regressed after merging main. --------- Co-authored-by: James Anderson <james@eli.cx>
1887 lines
79 KiB
TypeScript
1887 lines
79 KiB
TypeScript
import { describe, it, expect } from "vite-plus/test";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
import {
|
|
pagesRouter,
|
|
matchRoute,
|
|
apiRouter,
|
|
invalidateRouteCache,
|
|
type Route,
|
|
} from "../packages/vinext/src/routing/pages-router.js";
|
|
import {
|
|
appRouteGraph,
|
|
appRouter,
|
|
computeRootParamNames,
|
|
matchAppRoute,
|
|
invalidateAppRouteCache,
|
|
type AppRoute,
|
|
} from "../packages/vinext/src/routing/app-router.js";
|
|
|
|
const FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/pages-basic/pages");
|
|
const EMPTY_PAGE = "export default function Page() { return null; }\n";
|
|
const EMPTY_ROUTE = "export async function GET() { return Response.json({ ok: true }); }\n";
|
|
|
|
async function withTempDir<T>(prefix: string, run: (tmpDir: string) => Promise<T>): Promise<T> {
|
|
const tmpDir = await mkdtemp(path.join(os.tmpdir(), prefix));
|
|
try {
|
|
return await run(tmpDir);
|
|
} finally {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function makeTestAppRoute(
|
|
pattern: string,
|
|
patternParts: string[],
|
|
): AppRoute & { patternParts: string[] } {
|
|
return {
|
|
pattern,
|
|
patternParts,
|
|
pagePath: null,
|
|
routePath: null,
|
|
layouts: [],
|
|
templates: [],
|
|
parallelSlots: [],
|
|
loadingPath: null,
|
|
errorPath: null,
|
|
layoutErrorPaths: [],
|
|
errorPaths: [],
|
|
errorTreePositions: [],
|
|
notFoundPath: null,
|
|
notFoundPaths: [],
|
|
forbiddenPaths: [],
|
|
forbiddenPath: null,
|
|
unauthorizedPaths: [],
|
|
unauthorizedPath: null,
|
|
routeSegments: [],
|
|
layoutTreePositions: [],
|
|
isDynamic: pattern.includes(":"),
|
|
params: [],
|
|
rootParamNames: [],
|
|
siblingIntercepts: [],
|
|
};
|
|
}
|
|
|
|
describe("pagesRouter - route discovery", () => {
|
|
it("discovers pages from the fixture directory", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
expect(routes.length).toBeGreaterThan(0);
|
|
|
|
const patterns = routes.map((r) => r.pattern);
|
|
expect(patterns).toContain("/");
|
|
expect(patterns).toContain("/about");
|
|
expect(patterns).toContain("/ssr");
|
|
});
|
|
|
|
it("discovers dynamic routes", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const dynamicRoute = routes.find((r) => r.pattern === "/posts/:id");
|
|
expect(dynamicRoute).toBeDefined();
|
|
expect(dynamicRoute!.pattern).toBe("/posts/:id");
|
|
expect(dynamicRoute!.isDynamic).toBe(true);
|
|
expect(dynamicRoute!.params).toEqual(["id"]);
|
|
});
|
|
|
|
it("sorts static routes before dynamic routes", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const staticRoutes = routes.filter((r) => !r.isDynamic);
|
|
const dynamicRoutes = routes.filter((r) => r.isDynamic);
|
|
|
|
// All static routes should come before dynamic routes
|
|
const lastStaticIndex = routes.findIndex((r) => r === staticRoutes[staticRoutes.length - 1]);
|
|
const firstDynamicIndex = routes.findIndex((r) => r === dynamicRoutes[0]);
|
|
|
|
if (staticRoutes.length > 0 && dynamicRoutes.length > 0) {
|
|
expect(lastStaticIndex).toBeLessThan(firstDynamicIndex);
|
|
}
|
|
});
|
|
|
|
it("ignores _app.tsx and _document.tsx", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
expect(patterns).not.toContain("/_app");
|
|
expect(patterns).not.toContain("/_document");
|
|
expect(patterns).not.toContain("/_error");
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/underscore-ignore-app-paths
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/underscore-ignore-app-paths/underscore-ignore-app-paths.test.ts
|
|
it("serves pages/_foo.tsx even when app/ also exists", async () => {
|
|
await withTempDir("vinext-pages-underscore-mixed-", async (tmpDir) => {
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(pagesDir, { recursive: true });
|
|
await mkdir(appDir, { recursive: true });
|
|
await writeFile(path.join(pagesDir, "_dashboard.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(pagesDir, "_hidden.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(pagesDir, "index.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateRouteCache(pagesDir);
|
|
const routes = await pagesRouter(pagesDir);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
// Pages Router: underscore-prefixed pages SHOULD be discovered
|
|
expect(patterns).toContain("/_dashboard");
|
|
expect(patterns).toContain("/_hidden");
|
|
expect(patterns).toContain("/");
|
|
|
|
// App Router: underscore-prefixed folders should still be ignored
|
|
invalidateAppRouteCache();
|
|
const appRoutes = await appRouter(appDir);
|
|
const appPatterns = appRoutes.map((r) => r.pattern);
|
|
expect(appPatterns).toContain("/");
|
|
});
|
|
});
|
|
|
|
it("rejects non-terminal catch-all routes during discovery", async () => {
|
|
await withTempDir("vinext-pages-nonterminal-catchall-", async (tmpDir) => {
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
await mkdir(path.join(pagesDir, "[...slug]", "edit"), { recursive: true });
|
|
await writeFile(path.join(pagesDir, "[...slug]", "edit", "index.tsx"), EMPTY_PAGE);
|
|
|
|
const routes = await pagesRouter(pagesDir);
|
|
|
|
expect(routes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("rejects non-terminal optional catch-all routes during discovery", async () => {
|
|
await withTempDir("vinext-pages-nonterminal-optional-catchall-", async (tmpDir) => {
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
await mkdir(path.join(pagesDir, "[[...slug]]", "edit"), { recursive: true });
|
|
await writeFile(path.join(pagesDir, "[[...slug]]", "edit", "index.tsx"), EMPTY_PAGE);
|
|
|
|
const routes = await pagesRouter(pagesDir);
|
|
|
|
expect(routes).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("matchRoute - URL matching", () => {
|
|
it("matches static routes", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const result = matchRoute("/", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/");
|
|
|
|
const aboutResult = matchRoute("/about", routes);
|
|
expect(aboutResult).not.toBeNull();
|
|
expect(aboutResult!.route.pattern).toBe("/about");
|
|
});
|
|
|
|
it("matches dynamic routes and extracts params", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const result = matchRoute("/posts/42", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/posts/:id");
|
|
expect(result!.params).toEqual({ id: "42" });
|
|
});
|
|
|
|
it("preserves encoded slashes within a single static segment", () => {
|
|
const encodedRoute = {
|
|
pattern: "/a%2Fb",
|
|
patternParts: ["a%2Fb"],
|
|
filePath: "/tmp/pages/a%2Fb.tsx",
|
|
isDynamic: false,
|
|
params: [],
|
|
} as Route;
|
|
const nestedRoute = {
|
|
pattern: "/a/b",
|
|
patternParts: ["a", "b"],
|
|
filePath: "/tmp/pages/a/b.tsx",
|
|
isDynamic: false,
|
|
params: [],
|
|
} as Route;
|
|
|
|
expect(matchRoute("/a%2Fb", [encodedRoute, nestedRoute])?.route.pattern).toBe("/a%2Fb");
|
|
expect(matchRoute("/a/b", [encodedRoute, nestedRoute])?.route.pattern).toBe("/a/b");
|
|
// Lowercase %2f should also match: normalizePathnameForRouteMatch decodes
|
|
// then re-encodes via encodeURIComponent, which always produces uppercase.
|
|
expect(matchRoute("/a%2fb", [encodedRoute, nestedRoute])?.route.pattern).toBe("/a%2Fb");
|
|
});
|
|
|
|
it("returns null for unmatched routes", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const result = matchRoute("/nonexistent", routes);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("strips query strings before matching", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const result = matchRoute("/?foo=bar", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/");
|
|
});
|
|
|
|
it("strips trailing slashes before matching", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const result = matchRoute("/about/", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/about");
|
|
});
|
|
|
|
it("discovers catch-all routes [...slug]", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const catchAll = routes.find((r) => r.pattern.includes(":slug+"));
|
|
expect(catchAll).toBeTruthy();
|
|
expect(catchAll!.pattern).toBe("/docs/:slug+");
|
|
expect(catchAll!.isDynamic).toBe(true);
|
|
expect(catchAll!.params).toContain("slug");
|
|
});
|
|
|
|
it("matches catch-all routes with multiple segments", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const result = matchRoute("/docs/getting-started/install", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/docs/:slug+");
|
|
expect(result!.params.slug).toEqual(["getting-started", "install"]);
|
|
});
|
|
|
|
it("matches catch-all routes with single segment", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const result = matchRoute("/docs/intro", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.params.slug).toEqual(["intro"]);
|
|
});
|
|
|
|
it("does not match catch-all with zero segments", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
// /docs alone should NOT match [...slug] (requires at least 1 segment)
|
|
const result = matchRoute("/docs", routes);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rejects malformed non-terminal catch-all patterns in the matcher", () => {
|
|
const malformedRoute = {
|
|
pattern: "/:slug+/edit",
|
|
patternParts: [":slug+", "edit"],
|
|
filePath: "/tmp/pages/[...slug]/edit/index.tsx",
|
|
isDynamic: true,
|
|
params: ["slug"],
|
|
} as Route;
|
|
|
|
expect(matchRoute("/foo", [malformedRoute])).toBeNull();
|
|
expect(matchRoute("/foo/edit", [malformedRoute])).toBeNull();
|
|
});
|
|
|
|
it("rejects malformed non-terminal optional catch-all patterns in the matcher", () => {
|
|
const malformedRoute = {
|
|
pattern: "/:slug*/edit",
|
|
patternParts: [":slug*", "edit"],
|
|
filePath: "/tmp/pages/[[...slug]]/edit/index.tsx",
|
|
isDynamic: true,
|
|
params: ["slug"],
|
|
} as Route;
|
|
|
|
expect(matchRoute("/", [malformedRoute])).toBeNull();
|
|
expect(matchRoute("/foo/edit", [malformedRoute])).toBeNull();
|
|
});
|
|
|
|
it("skips malformed catch-all patterns and continues to later valid routes", () => {
|
|
const malformedRoute = {
|
|
pattern: "/:slug+/edit",
|
|
patternParts: [":slug+", "edit"],
|
|
filePath: "/tmp/pages/[...slug]/edit/index.tsx",
|
|
isDynamic: true,
|
|
params: ["slug"],
|
|
} as Route;
|
|
const validRoute = {
|
|
pattern: "/foo",
|
|
patternParts: ["foo"],
|
|
filePath: "/tmp/pages/foo.tsx",
|
|
isDynamic: false,
|
|
params: [],
|
|
} as Route;
|
|
|
|
const result = matchRoute("/foo", [malformedRoute, validRoute]);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/foo");
|
|
});
|
|
});
|
|
|
|
describe("apiRouter - route discovery", () => {
|
|
it("rejects non-terminal catch-all API routes during discovery", async () => {
|
|
await withTempDir("vinext-api-nonterminal-catchall-", async (tmpDir) => {
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
await mkdir(path.join(pagesDir, "api", "[...slug]"), { recursive: true });
|
|
await writeFile(path.join(pagesDir, "api", "[...slug]", "edit.ts"), EMPTY_ROUTE);
|
|
|
|
const routes = await apiRouter(pagesDir);
|
|
|
|
expect(routes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("rejects non-terminal optional catch-all API routes during discovery", async () => {
|
|
await withTempDir("vinext-api-nonterminal-optional-catchall-", async (tmpDir) => {
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
await mkdir(path.join(pagesDir, "api", "[[...slug]]"), { recursive: true });
|
|
await writeFile(path.join(pagesDir, "api", "[[...slug]]", "edit.ts"), EMPTY_ROUTE);
|
|
|
|
const routes = await apiRouter(pagesDir);
|
|
|
|
expect(routes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// Regression for #1542: pages whose path *starts with* `/api-…` (e.g.
|
|
// `/api-docs/[...slug]`, `/api-info`) must be classified as PAGE routes,
|
|
// not API routes. Next.js only treats files under `pages/api/` (with the
|
|
// trailing slash) as API routes — substring matches against `api-` are a
|
|
// false positive. The upstream Next.js fixture at
|
|
// `.nextjs-ref/test/e2e/prerender/pages/api-docs/[...slug].js` is the
|
|
// canonical case.
|
|
it("does not treat pages/api-docs/* as API routes (issue #1542)", async () => {
|
|
await withTempDir("vinext-issue-1542-api-docs-", async (tmpDir) => {
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
await mkdir(path.join(pagesDir, "api-docs"), { recursive: true });
|
|
await writeFile(path.join(pagesDir, "api-docs", "[...slug].tsx"), EMPTY_PAGE);
|
|
await mkdir(path.join(pagesDir, "api-info"), { recursive: true });
|
|
await writeFile(path.join(pagesDir, "api-info", "index.tsx"), EMPTY_PAGE);
|
|
// A real API route co-exists so we also verify the positive case.
|
|
await mkdir(path.join(pagesDir, "api"), { recursive: true });
|
|
await writeFile(path.join(pagesDir, "api", "hello.ts"), EMPTY_ROUTE);
|
|
|
|
invalidateRouteCache(pagesDir);
|
|
|
|
const pages = await pagesRouter(pagesDir);
|
|
const apis = await apiRouter(pagesDir);
|
|
|
|
const pagePatterns = pages.map((r) => r.pattern);
|
|
const apiPatterns = apis.map((r) => r.pattern);
|
|
|
|
// `/api-docs/[...slug]` and `/api-info` are pages, not API routes.
|
|
expect(pagePatterns).toContain("/api-docs/:slug+");
|
|
expect(pagePatterns).toContain("/api-info");
|
|
expect(apiPatterns).not.toContain("/api-docs/:slug+");
|
|
expect(apiPatterns).not.toContain("/api-info");
|
|
|
|
// The real API route is still discovered correctly.
|
|
expect(apiPatterns).toContain("/api/hello");
|
|
expect(pagePatterns).not.toContain("/api/hello");
|
|
|
|
// matchRoute against the page table resolves the `/api-…` URLs to
|
|
// their page files. A regression would either yield null (no match)
|
|
// or match against the API trie instead.
|
|
const matchedDocs = matchRoute("/api-docs/first", pages);
|
|
expect(matchedDocs).not.toBeNull();
|
|
expect(matchedDocs!.route.pattern).toBe("/api-docs/:slug+");
|
|
expect(matchedDocs!.route.filePath).toBe(path.join(pagesDir, "api-docs", "[...slug].tsx"));
|
|
|
|
const matchedInfo = matchRoute("/api-info", pages);
|
|
expect(matchedInfo).not.toBeNull();
|
|
expect(matchedInfo!.route.pattern).toBe("/api-info");
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// App Router routing tests
|
|
// ---------------------------------------------------------------
|
|
|
|
const APP_FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/app-basic/app");
|
|
|
|
describe("appRouter - route discovery", () => {
|
|
it("discovers page routes from the app directory", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const pagePatterns = routes.filter((r) => r.pagePath).map((r) => r.pattern);
|
|
|
|
expect(pagePatterns).toContain("/");
|
|
expect(pagePatterns).toContain("/about");
|
|
expect(pagePatterns).toContain("/blog/:slug");
|
|
});
|
|
|
|
it("discovers route handler (API) routes", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const apiRoutes = routes.filter((r) => r.routePath);
|
|
|
|
expect(apiRoutes.length).toBeGreaterThan(0);
|
|
const apiPatterns = apiRoutes.map((r) => r.pattern);
|
|
expect(apiPatterns).toContain("/api/hello");
|
|
});
|
|
|
|
it("caches the route manifest with the route list", async () => {
|
|
invalidateAppRouteCache();
|
|
|
|
const graph = await appRouteGraph(APP_FIXTURE_DIR);
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const cachedGraph = await appRouteGraph(APP_FIXTURE_DIR);
|
|
|
|
expect(routes).toBe(graph.routes);
|
|
expect(cachedGraph).toBe(graph);
|
|
expect(graph.routeManifest.segmentGraph.routes.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("rejects page and route handler files at the same app route", async () => {
|
|
// Next.js docs forbid page.js and route.js at the same normalized route:
|
|
// https://github.com/vercel/next.js/blob/ae61573e062e900050b8e6b24626e450accc4570/docs/01-app/01-getting-started/15-route-handlers.mdx#L150-L163
|
|
await withTempDir("vinext-app-page-route-conflict-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "foo"), { recursive: true });
|
|
await writeFile(path.join(appDir, "foo", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "foo", "route.ts"), EMPTY_ROUTE);
|
|
|
|
invalidateAppRouteCache();
|
|
await expect(appRouter(appDir)).rejects.toThrow(
|
|
"Conflicting route and page at /foo: route at /foo/route and page at /foo/page",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("discovers layouts from root to leaf", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const homeRoute = routes.find((r) => r.pattern === "/");
|
|
|
|
expect(homeRoute).toBeDefined();
|
|
expect(homeRoute!.layouts.length).toBeGreaterThan(0);
|
|
// Root layout should be the first
|
|
expect(homeRoute!.layouts[0]).toContain("layout.tsx");
|
|
});
|
|
|
|
it("detects dynamic segments", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const blogRoute = routes.find((r) => r.pattern === "/blog/:slug");
|
|
|
|
expect(blogRoute).toBeDefined();
|
|
expect(blogRoute!.isDynamic).toBe(true);
|
|
expect(blogRoute!.params).toEqual(["slug"]);
|
|
});
|
|
|
|
it("discovers dynamic params captured by a nested root layout", async () => {
|
|
await withTempDir("vinext-app-root-params-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "[lang]", "[locale]", "other", "[slug]"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(path.join(appDir, "[lang]", "[locale]", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "[lang]", "[locale]", "other", "[slug]", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const route = routes.find((r) => r.pattern === "/:lang/:locale/other/:slug");
|
|
|
|
expect(route).toBeDefined();
|
|
expect(route!.params).toEqual(["lang", "locale", "slug"]);
|
|
expect(route!.rootParamNames).toEqual(["lang", "locale"]);
|
|
});
|
|
});
|
|
|
|
it("preserves root layout params on synthetic parallel slot routes", async () => {
|
|
await withTempDir("vinext-app-root-params-parallel-slot-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "[lang]", "@modal", "details"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(path.join(appDir, "[lang]", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "[lang]", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "[lang]", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "[lang]", "@modal", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "[lang]", "@modal", "details", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const route = routes.find((r) => r.pattern === "/:lang/details");
|
|
|
|
expect(route).toBeDefined();
|
|
expect(route!.rootParamNames).toEqual(["lang"]);
|
|
});
|
|
});
|
|
|
|
it("computes root layout params for dynamic and catch-all segments", () => {
|
|
expect(computeRootParamNames(["[lang]", "[...slug]", "page"], [2])).toEqual(["lang", "slug"]);
|
|
expect(computeRootParamNames(["[[...rest]]"], [1])).toEqual(["rest"]);
|
|
});
|
|
|
|
it("sorts static routes before dynamic routes at the same depth", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
// Verify that top-level static routes (e.g. /about) come before
|
|
// top-level dynamic routes without static prefixes (e.g. /blog/:slug).
|
|
// Note: dynamic routes with static prefixes (e.g. /_sites/:subdomain)
|
|
// may legitimately sort before pure-dynamic routes due to precedence.
|
|
const aboutIdx = routes.findIndex((r) => r.pattern === "/about");
|
|
const blogIdx = routes.findIndex((r) => r.pattern === "/blog/:slug");
|
|
|
|
expect(aboutIdx).not.toBe(-1);
|
|
expect(blogIdx).not.toBe(-1);
|
|
expect(aboutIdx).toBeLessThan(blogIdx);
|
|
});
|
|
|
|
it("rejects non-terminal catch-all pages during discovery", async () => {
|
|
await withTempDir("vinext-app-nonterminal-catchall-page-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "[...slug]", "edit"), { recursive: true });
|
|
await writeFile(path.join(appDir, "[...slug]", "edit", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
|
|
expect(routes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("rejects non-terminal optional catch-all route handlers during discovery", async () => {
|
|
await withTempDir("vinext-app-nonterminal-optional-catchall-route-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "[[...slug]]", "edit"), { recursive: true });
|
|
await writeFile(path.join(appDir, "[[...slug]]", "edit", "route.ts"), EMPTY_ROUTE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
|
|
expect(routes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("rejects non-terminal catch-all routes even when the suffix is behind a route group", async () => {
|
|
await withTempDir("vinext-app-nonterminal-catchall-group-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "[...slug]", "(admin)", "edit"), { recursive: true });
|
|
await writeFile(path.join(appDir, "[...slug]", "(admin)", "edit", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
|
|
expect(routes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("allows terminal catch-all when only transparent route groups follow on disk", async () => {
|
|
await withTempDir("vinext-app-terminal-catchall-group-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "[...slug]", "(admin)"), { recursive: true });
|
|
await writeFile(path.join(appDir, "[...slug]", "(admin)", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
|
|
expect(routes.map((route) => route.pattern)).toEqual(["/:slug+"]);
|
|
});
|
|
});
|
|
|
|
it("allows terminal optional catch-all when only transparent route groups follow on disk", async () => {
|
|
await withTempDir("vinext-app-terminal-optional-catchall-group-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "[[...slug]]", "(admin)"), { recursive: true });
|
|
await writeFile(path.join(appDir, "[[...slug]]", "(admin)", "route.ts"), EMPTY_ROUTE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
|
|
expect(routes.map((route) => route.pattern)).toEqual(["/:slug*"]);
|
|
});
|
|
});
|
|
|
|
it("rejects non-terminal catch-all in synthetic @slot subroutes", async () => {
|
|
await withTempDir("vinext-app-slot-nonterminal-catchall-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "dashboard", "@modal", "[...slug]", "edit"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(path.join(appDir, "dashboard", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "dashboard", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "dashboard", "@modal", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "dashboard", "@modal", "[...slug]", "edit", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const patterns = routes.map((route) => route.pattern);
|
|
|
|
expect(patterns).toContain("/dashboard");
|
|
expect(patterns).not.toContain("/dashboard/:slug+/edit");
|
|
});
|
|
});
|
|
|
|
it("rejects non-terminal optional catch-all in synthetic @slot subroutes", async () => {
|
|
await withTempDir("vinext-app-slot-nonterminal-optional-catchall-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "dashboard", "@modal", "[[...slug]]", "edit"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(path.join(appDir, "dashboard", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "dashboard", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "dashboard", "@modal", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "dashboard", "@modal", "[[...slug]]", "edit", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const patterns = routes.map((route) => route.pattern);
|
|
|
|
expect(patterns).toContain("/dashboard");
|
|
expect(patterns).not.toContain("/dashboard/:slug*/edit");
|
|
});
|
|
});
|
|
|
|
it("allows terminal synthetic @slot catch-all when only route groups follow", async () => {
|
|
await withTempDir("vinext-app-slot-terminal-catchall-group-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "dashboard", "@modal", "[...slug]", "(admin)"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(path.join(appDir, "dashboard", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "dashboard", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "dashboard", "@modal", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "dashboard", "@modal", "[...slug]", "(admin)", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const patterns = routes.map((route) => route.pattern);
|
|
|
|
expect(patterns).toContain("/dashboard/:slug+");
|
|
const match = matchAppRoute("/dashboard/a/b", routes);
|
|
expect(match).not.toBeNull();
|
|
expect(match!.route.pattern).toBe("/dashboard/:slug+");
|
|
expect(match!.params.slug).toEqual(["a", "b"]);
|
|
});
|
|
});
|
|
|
|
it("does not create nested @slot sub-routes without a children default fallback", async () => {
|
|
await withTempDir("vinext-app-slot-missing-children-default-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "inbox", "@modal", "profile"), { recursive: true });
|
|
await writeFile(path.join(appDir, "inbox", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "inbox", "@modal", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "inbox", "@modal", "profile", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const patterns = routes.map((route) => route.pattern);
|
|
|
|
expect(patterns).toContain("/inbox");
|
|
expect(patterns).not.toContain("/inbox/profile");
|
|
expect(matchAppRoute("/inbox/profile", routes)).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("does not discover nested @slot sub-routes when the slot root has no page or default", async () => {
|
|
await withTempDir("vinext-app-slot-nested-only-rootless-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "inbox", "@modal", "profile"), { recursive: true });
|
|
await writeFile(path.join(appDir, "inbox", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "inbox", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "inbox", "@modal", "profile", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const patterns = routes.map((route) => route.pattern);
|
|
|
|
expect(patterns).toContain("/inbox");
|
|
expect(patterns).not.toContain("/inbox/profile");
|
|
expect(matchAppRoute("/inbox/profile", routes)).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("rejects non-terminal catch-all intercept targets", async () => {
|
|
await withTempDir("vinext-app-intercept-nonterminal-catchall-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "feed", "@modal", "(...)photos", "[...slug]", "edit"), {
|
|
recursive: true,
|
|
});
|
|
await mkdir(path.join(appDir, "photos", "[id]"), { recursive: true });
|
|
await writeFile(path.join(appDir, "feed", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "photos", "[id]", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "feed", "@modal", "(...)photos", "[...slug]", "edit", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const feedRoute = routes.find((route) => route.pattern === "/feed");
|
|
|
|
expect(feedRoute).toBeDefined();
|
|
const modalSlot = feedRoute!.parallelSlots.find((slot) => slot.name === "modal");
|
|
expect(modalSlot).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("rejects non-terminal optional catch-all intercept targets", async () => {
|
|
await withTempDir("vinext-app-intercept-nonterminal-optional-catchall-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "feed", "@modal", "(...)photos", "[[...slug]]", "edit"), {
|
|
recursive: true,
|
|
});
|
|
await mkdir(path.join(appDir, "photos", "[id]"), { recursive: true });
|
|
await writeFile(path.join(appDir, "feed", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "photos", "[id]", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "feed", "@modal", "(...)photos", "[[...slug]]", "edit", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const feedRoute = routes.find((route) => route.pattern === "/feed");
|
|
|
|
expect(feedRoute).toBeDefined();
|
|
const modalSlot = feedRoute!.parallelSlots.find((slot) => slot.name === "modal");
|
|
expect(modalSlot).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("allows terminal catch-all intercept targets when only route groups follow", async () => {
|
|
await withTempDir("vinext-app-intercept-terminal-catchall-group-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "feed", "@modal", "(...)photos", "[...slug]", "(admin)"), {
|
|
recursive: true,
|
|
});
|
|
await mkdir(path.join(appDir, "photos", "[id]"), { recursive: true });
|
|
await writeFile(path.join(appDir, "feed", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "photos", "[id]", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "feed", "@modal", "(...)photos", "[...slug]", "(admin)", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const feedRoute = routes.find((route) => route.pattern === "/feed");
|
|
|
|
expect(feedRoute).toBeDefined();
|
|
const modalSlot = feedRoute!.parallelSlots.find((slot) => slot.name === "modal");
|
|
expect(modalSlot).toBeDefined();
|
|
expect(modalSlot!.interceptingRoutes).toHaveLength(1);
|
|
expect(modalSlot!.interceptingRoutes[0].targetPattern).toBe("/photos/:slug+");
|
|
expect(modalSlot!.interceptingRoutes[0].params).toEqual(["slug"]);
|
|
});
|
|
});
|
|
|
|
it("rejects (..) intercepting routes at the normalized root", async () => {
|
|
// Ported from Next.js:
|
|
// packages/next/src/shared/lib/router/utils/interception-routes.test.ts
|
|
// https://github.com/vercel/next.js/blob/ae61573e062e900050b8e6b24626e450accc4570/packages/next/src/shared/lib/router/utils/interception-routes.test.ts#L66-L75
|
|
await withTempDir("vinext-app-intercept-root-parent-marker-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "@modal", "(..)foo"), { recursive: true });
|
|
await writeFile(path.join(appDir, "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "@modal", "(..)foo", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
await expect(appRouter(appDir)).rejects.toThrow(
|
|
"Cannot use (..) marker at the root level, use (.) instead.",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("rejects (..) intercepting routes at the root after route group normalization", async () => {
|
|
// Next.js validates the normalized intercepting route, so transparent
|
|
// route groups do not make an upward marker valid at the URL root.
|
|
// https://github.com/vercel/next.js/blob/ae61573e062e900050b8e6b24626e450accc4570/packages/next/src/shared/lib/router/utils/interception-routes.ts#L60-L95
|
|
await withTempDir("vinext-app-intercept-group-root-parent-marker-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "(group)", "@modal", "(..)foo"), { recursive: true });
|
|
await writeFile(path.join(appDir, "(group)", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "(group)", "@modal", "(..)foo", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
await expect(appRouter(appDir)).rejects.toThrow(
|
|
"Cannot use (..) marker at the root level, use (.) instead.",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("rejects (..)(..) intercepting routes at root or one visible level up", async () => {
|
|
// Ported from Next.js:
|
|
// packages/next/src/shared/lib/router/utils/interception-routes.test.ts
|
|
// https://github.com/vercel/next.js/blob/ae61573e062e900050b8e6b24626e450accc4570/packages/next/src/shared/lib/router/utils/interception-routes.test.ts#L66-L75
|
|
await withTempDir("vinext-app-intercept-two-up-marker-root-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "@modal", "(..)(..)foo"), { recursive: true });
|
|
await writeFile(path.join(appDir, "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "@modal", "(..)(..)foo", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
await expect(appRouter(appDir)).rejects.toThrow(
|
|
"Cannot use (..)(..) marker at the root level or one level up.",
|
|
);
|
|
});
|
|
|
|
await withTempDir("vinext-app-intercept-two-up-marker-one-level-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "feed", "@modal", "(..)(..)foo"), { recursive: true });
|
|
await writeFile(path.join(appDir, "feed", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "feed", "@modal", "(..)(..)foo", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
await expect(appRouter(appDir)).rejects.toThrow(
|
|
"Cannot use (..)(..) marker at the root level or one level up.",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("allows (..) intercepting routes in route groups with one visible parent segment", async () => {
|
|
// Next.js validates the normalized intercepting route: /(group) is root,
|
|
// but /shop/(group) has one visible segment and can climb to /.
|
|
// https://github.com/vercel/next.js/blob/ae61573e062e900050b8e6b24626e450accc4570/packages/next/src/shared/lib/router/utils/interception-routes.ts#L60-L95
|
|
await withTempDir("vinext-app-intercept-group-with-visible-parent-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "shop", "(group)", "@modal", "(..)foo"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(path.join(appDir, "shop", "(group)", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "shop", "(group)", "@modal", "(..)foo", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const shopRoute = routes.find((route) => route.pattern === "/shop");
|
|
expect(shopRoute).toBeDefined();
|
|
|
|
const modalSlot = shopRoute!.parallelSlots.find((slot) => slot.name === "modal");
|
|
expect(modalSlot).toBeDefined();
|
|
expect(modalSlot!.interceptingRoutes).toHaveLength(1);
|
|
expect(modalSlot!.interceptingRoutes[0].targetPattern).toBe("/foo");
|
|
});
|
|
});
|
|
|
|
it("(..) climbs visible route segments, not filesystem dirs (route group between segments)", async () => {
|
|
// Bug: computeInterceptTarget uses path.dirname() which counts filesystem dirs,
|
|
// but (..) should count visible route segments (skipping route groups).
|
|
// Structure: app/a/(group)/b/@modal/(..)(..)target/page.tsx
|
|
// Visible path: /a/b → (..)(..) should climb 2 visible segments → root → /target
|
|
// Bug produces: /a/target (climbs past "b" and "(group)", lands on "a")
|
|
await withTempDir("vinext-app-intercept-route-group-climb-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "a", "(group)", "b", "@modal", "(..)(..)target"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(path.join(appDir, "a", "(group)", "b", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "a", "(group)", "b", "@modal", "(..)(..)target", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
// The actual target route
|
|
await mkdir(path.join(appDir, "target"), { recursive: true });
|
|
await writeFile(path.join(appDir, "target", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const bRoute = routes.find((r) => r.pattern === "/a/b");
|
|
expect(bRoute).toBeDefined();
|
|
|
|
const modalSlot = bRoute!.parallelSlots.find((s) => s.name === "modal");
|
|
expect(modalSlot).toBeDefined();
|
|
expect(modalSlot!.interceptingRoutes).toHaveLength(1);
|
|
// (..)(..) should climb 2 visible segments (b → a → root), landing at /target
|
|
expect(modalSlot!.interceptingRoutes[0].targetPattern).toBe("/target");
|
|
});
|
|
});
|
|
|
|
it("(..) with single route group between segments resolves correctly", async () => {
|
|
// Structure: app/shop/(featured)/products/@modal/(..)cart/page.tsx
|
|
// Visible path: /shop/products → (..) climbs 1 visible segment above products → /shop
|
|
// Target: /shop/cart
|
|
await withTempDir("vinext-app-intercept-single-group-climb-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "shop", "(featured)", "products", "@modal", "(..)cart"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(path.join(appDir, "shop", "(featured)", "products", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "shop", "(featured)", "products", "@modal", "(..)cart", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
await mkdir(path.join(appDir, "shop", "cart"), { recursive: true });
|
|
await writeFile(path.join(appDir, "shop", "cart", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const productsRoute = routes.find((r) => r.pattern === "/shop/products");
|
|
expect(productsRoute).toBeDefined();
|
|
|
|
const modalSlot = productsRoute!.parallelSlots.find((s) => s.name === "modal");
|
|
expect(modalSlot).toBeDefined();
|
|
expect(modalSlot!.interceptingRoutes).toHaveLength(1);
|
|
expect(modalSlot!.interceptingRoutes[0].targetPattern).toBe("/shop/cart");
|
|
});
|
|
});
|
|
|
|
it("(..) skips multiple consecutive route groups when climbing", async () => {
|
|
// Structure: app/a/(g1)/(g2)/b/@modal/(..)(..)target/page.tsx
|
|
// Visible path: /a/b → (..)(..) climbs 2 visible segments → root → /target
|
|
await withTempDir("vinext-app-intercept-multi-group-climb-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "a", "(g1)", "(g2)", "b", "@modal", "(..)(..)target"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(path.join(appDir, "a", "(g1)", "(g2)", "b", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "a", "(g1)", "(g2)", "b", "@modal", "(..)(..)target", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
await mkdir(path.join(appDir, "target"), { recursive: true });
|
|
await writeFile(path.join(appDir, "target", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const bRoute = routes.find((r) => r.pattern === "/a/b");
|
|
expect(bRoute).toBeDefined();
|
|
|
|
const modalSlot = bRoute!.parallelSlots.find((s) => s.name === "modal");
|
|
expect(modalSlot).toBeDefined();
|
|
expect(modalSlot!.interceptingRoutes).toHaveLength(1);
|
|
expect(modalSlot!.interceptingRoutes[0].targetPattern).toBe("/target");
|
|
});
|
|
});
|
|
|
|
it("rejects sibling intercept targets that differ only by param name", async () => {
|
|
await withTempDir("vinext-app-intercept-dynamic-conflict-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "feed", "@modal", "(.)photos", "[id]"), { recursive: true });
|
|
await mkdir(path.join(appDir, "feed", "@modal", "(.)photos", "[slug]"), {
|
|
recursive: true,
|
|
});
|
|
await mkdir(path.join(appDir, "feed", "photos", "[id]"), { recursive: true });
|
|
await writeFile(path.join(appDir, "feed", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "feed", "photos", "[id]", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "feed", "@modal", "(.)photos", "[id]", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
await writeFile(
|
|
path.join(appDir, "feed", "@modal", "(.)photos", "[slug]", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
await expect(appRouter(appDir)).rejects.toThrow(/different slug names/);
|
|
});
|
|
});
|
|
|
|
it("excludes private folders (underscore prefix) from route discovery", async () => {
|
|
await withTempDir("vinext-app-private-folder-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
// _components is a private folder — should be excluded
|
|
await mkdir(path.join(appDir, "_components"), { recursive: true });
|
|
await writeFile(path.join(appDir, "_components", "page.tsx"), EMPTY_PAGE);
|
|
// _lib/utils is also private — nested private folder
|
|
await mkdir(path.join(appDir, "_lib", "utils"), { recursive: true });
|
|
await writeFile(path.join(appDir, "_lib", "utils", "page.tsx"), EMPTY_PAGE);
|
|
// Regular routes should still be discovered
|
|
await mkdir(path.join(appDir, "about"), { recursive: true });
|
|
await writeFile(path.join(appDir, "about", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
expect(patterns).toContain("/");
|
|
expect(patterns).toContain("/about");
|
|
expect(patterns).not.toContain("/_components");
|
|
expect(patterns).not.toContain("/_lib/utils");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("matchAppRoute - URL matching", () => {
|
|
it("matches static routes", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/");
|
|
|
|
const aboutResult = matchAppRoute("/about", routes);
|
|
expect(aboutResult).not.toBeNull();
|
|
expect(aboutResult!.route.pattern).toBe("/about");
|
|
});
|
|
|
|
it("matches dynamic routes and extracts params", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/blog/hello-world", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/blog/:slug");
|
|
expect(result!.params).toEqual({ slug: "hello-world" });
|
|
});
|
|
|
|
it("returns null for unmatched routes", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/nonexistent", routes);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("matches API route handlers", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/api/hello", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.routePath).toBeTruthy();
|
|
});
|
|
|
|
it("route groups are transparent in URL pattern", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
// (marketing)/features -> /features (no "(marketing)" in URL)
|
|
expect(patterns).toContain("/features");
|
|
expect(patterns.some((p) => p.includes("marketing"))).toBe(false);
|
|
});
|
|
|
|
it("matches catch-all routes with multiple segments", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/docs/getting-started/install", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/docs/:slug+");
|
|
expect(result!.params.slug).toEqual(["getting-started", "install"]);
|
|
});
|
|
|
|
it("matches catch-all routes with single segment", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/docs/intro", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.params.slug).toEqual(["intro"]);
|
|
});
|
|
|
|
it("does not match catch-all with zero segments", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
// /docs alone should NOT match [...slug]
|
|
const result = matchAppRoute("/docs", routes);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("matches optional catch-all with zero segments", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/optional", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/optional/:path*");
|
|
expect(result!.params).not.toHaveProperty("path");
|
|
});
|
|
|
|
it("matches optional catch-all with multiple segments", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/optional/a/b/c", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.params.path).toEqual(["a", "b", "c"]);
|
|
});
|
|
|
|
it("rejects malformed non-terminal catch-all patterns in the matcher", () => {
|
|
const malformedRoute = makeTestAppRoute("/:slug+/edit", [":slug+", "edit"]);
|
|
|
|
expect(matchAppRoute("/foo", [malformedRoute])).toBeNull();
|
|
expect(matchAppRoute("/foo/edit", [malformedRoute])).toBeNull();
|
|
});
|
|
|
|
it("rejects malformed non-terminal optional catch-all patterns in the matcher", () => {
|
|
const malformedRoute = makeTestAppRoute("/:slug*/edit", [":slug*", "edit"]);
|
|
|
|
expect(matchAppRoute("/", [malformedRoute])).toBeNull();
|
|
expect(matchAppRoute("/foo/edit", [malformedRoute])).toBeNull();
|
|
});
|
|
|
|
it("skips malformed catch-all patterns and continues to later valid routes", () => {
|
|
const malformedRoute = makeTestAppRoute("/:slug+/edit", [":slug+", "edit"]);
|
|
const validRoute = makeTestAppRoute("/foo", ["foo"]);
|
|
|
|
const result = matchAppRoute("/foo", [malformedRoute, validRoute]);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/foo");
|
|
});
|
|
|
|
it("discovers template.tsx files", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
// Root template should be discovered for the home page
|
|
const homeRoute = routes.find((r) => r.pattern === "/");
|
|
expect(homeRoute).toBeDefined();
|
|
expect(homeRoute!.templates.length).toBeGreaterThan(0);
|
|
expect(homeRoute!.templates[0]).toContain("template");
|
|
});
|
|
|
|
it("includes templates array even when no template exists", async () => {
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
// All routes should have a templates array (may be empty or populated)
|
|
for (const route of routes) {
|
|
expect(Array.isArray(route.templates)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("@slot directories do not appear in URL patterns", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
// No pattern should contain "@team" or "@analytics"
|
|
expect(patterns.some((p) => p.includes("@"))).toBe(false);
|
|
// Specifically, there should be no route like /dashboard/@team
|
|
expect(patterns).not.toContain("/dashboard/@team");
|
|
expect(patterns).not.toContain("/dashboard/@analytics");
|
|
});
|
|
|
|
it("@slot/page.tsx files do not create standalone routes", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
// Slot pages should not generate their own routes
|
|
expect(patterns).not.toContain("/dashboard/team");
|
|
expect(patterns).not.toContain("/dashboard/analytics");
|
|
});
|
|
|
|
it("discovers parallel slots on dashboard route", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const dashboardRoute = routes.find((r) => r.pattern === "/dashboard");
|
|
|
|
expect(dashboardRoute).toBeDefined();
|
|
expect(dashboardRoute!.parallelSlots.length).toBe(2);
|
|
|
|
const slotNames = dashboardRoute!.parallelSlots.map((s) => s.name).sort();
|
|
expect(slotNames).toEqual(["analytics", "team"]);
|
|
});
|
|
|
|
it("parallel slot pages and defaults are discovered", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const dashboardRoute = routes.find((r) => r.pattern === "/dashboard");
|
|
expect(dashboardRoute).toBeDefined();
|
|
|
|
const teamSlot = dashboardRoute!.parallelSlots.find((s) => s.name === "team");
|
|
expect(teamSlot).toBeDefined();
|
|
expect(teamSlot!.pagePath).not.toBeNull();
|
|
expect(teamSlot!.pagePath).toContain("@team");
|
|
expect(teamSlot!.defaultPath).not.toBeNull();
|
|
expect(teamSlot!.defaultPath).toContain("@team");
|
|
|
|
const analyticsSlot = dashboardRoute!.parallelSlots.find((s) => s.name === "analytics");
|
|
expect(analyticsSlot).toBeDefined();
|
|
expect(analyticsSlot!.pagePath).not.toBeNull();
|
|
expect(analyticsSlot!.defaultPath).not.toBeNull();
|
|
});
|
|
|
|
it("discovers layout.tsx inside parallel slot directories", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const dashboardRoute = routes.find((r) => r.pattern === "/dashboard");
|
|
expect(dashboardRoute).toBeDefined();
|
|
|
|
// @team has a layout.tsx
|
|
const teamSlot = dashboardRoute!.parallelSlots.find((s) => s.name === "team");
|
|
expect(teamSlot).toBeDefined();
|
|
expect(teamSlot!.layoutPath).not.toBeNull();
|
|
expect(teamSlot!.layoutPath).toContain("@team");
|
|
expect(teamSlot!.layoutPath).toContain("layout.tsx");
|
|
|
|
// @analytics does NOT have a layout.tsx
|
|
const analyticsSlot = dashboardRoute!.parallelSlots.find((s) => s.name === "analytics");
|
|
expect(analyticsSlot).toBeDefined();
|
|
expect(analyticsSlot!.layoutPath).toBeNull();
|
|
});
|
|
|
|
it("inherited parallel slots preserve layoutPath from parent", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const settingsRoute = routes.find((r) => r.pattern === "/dashboard/settings");
|
|
expect(settingsRoute).toBeDefined();
|
|
|
|
// @team slot inherited from dashboard should still have layoutPath
|
|
const teamSlot = settingsRoute!.parallelSlots.find((s) => s.name === "team");
|
|
expect(teamSlot).toBeDefined();
|
|
expect(teamSlot!.layoutPath).not.toBeNull();
|
|
expect(teamSlot!.layoutPath).toContain("@team");
|
|
});
|
|
|
|
it("routes without @slot dirs have empty parallelSlots", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const homeRoute = routes.find((r) => r.pattern === "/");
|
|
|
|
expect(homeRoute).toBeDefined();
|
|
expect(homeRoute!.parallelSlots).toEqual([]);
|
|
});
|
|
|
|
it("decodes URL-encoded directory names into URL patterns", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
// %5Fsites directory should decode to _sites in the URL pattern
|
|
expect(patterns).toContain("/_sites/:subdomain");
|
|
// The raw percent-encoded form should NOT appear
|
|
expect(patterns.some((p) => p.includes("%5F"))).toBe(false);
|
|
});
|
|
|
|
it("matches requests against decoded URL-encoded routes", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/_sites/my-site", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/_sites/:subdomain");
|
|
expect(result!.params).toEqual({ subdomain: "my-site" });
|
|
});
|
|
|
|
it("keeps encoded slashes distinct from real nested routes", async () => {
|
|
await withTempDir("vinext-app-encoded-slash-route-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
await mkdir(path.join(appDir, "a%2Fb"), { recursive: true });
|
|
await mkdir(path.join(appDir, "a", "b"), { recursive: true });
|
|
await writeFile(path.join(appDir, "a%2Fb", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "a", "b", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const patterns = routes.map((route) => route.pattern);
|
|
|
|
expect(patterns).toContain("/a%2Fb");
|
|
expect(patterns).toContain("/a/b");
|
|
|
|
expect(matchAppRoute("/a%2Fb", routes)?.route.pattern).toBe("/a%2Fb");
|
|
expect(matchAppRoute("/a/b", routes)?.route.pattern).toBe("/a/b");
|
|
});
|
|
});
|
|
|
|
it("prioritizes static-prefix routes over bare catch-all routes", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
// /_sites/:subdomain has a static prefix "_sites" and should sort
|
|
// before bare dynamic routes like /:slug at the same depth
|
|
const sitesIdx = routes.findIndex((r) => r.pattern === "/_sites/:subdomain");
|
|
const optionalCatchAllIdx = routes.findIndex((r) => r.pattern === "/optional/:path*");
|
|
|
|
expect(sitesIdx).not.toBe(-1);
|
|
expect(optionalCatchAllIdx).not.toBe(-1);
|
|
// Static-prefix route should come before optional catch-all
|
|
expect(sitesIdx).toBeLessThan(optionalCatchAllIdx);
|
|
});
|
|
|
|
it("child routes inherit parent parallel slots with default.tsx fallback", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const settingsRoute = routes.find((r) => r.pattern === "/dashboard/settings");
|
|
|
|
expect(settingsRoute).toBeDefined();
|
|
// Settings inherits @team and @analytics from dashboard layout
|
|
expect(settingsRoute!.parallelSlots.length).toBe(2);
|
|
|
|
const slotNames = settingsRoute!.parallelSlots.map((s) => s.name).sort();
|
|
expect(slotNames).toEqual(["analytics", "team"]);
|
|
|
|
// Inherited slots should NOT have pagePath (page.tsx is for /dashboard only)
|
|
const teamSlot = settingsRoute!.parallelSlots.find((s) => s.name === "team");
|
|
expect(teamSlot).toBeDefined();
|
|
expect(teamSlot!.pagePath).toBeNull();
|
|
// But should have defaultPath
|
|
expect(teamSlot!.defaultPath).not.toBeNull();
|
|
expect(teamSlot!.defaultPath).toContain("@team");
|
|
|
|
const analyticsSlot = settingsRoute!.parallelSlots.find((s) => s.name === "analytics");
|
|
expect(analyticsSlot).toBeDefined();
|
|
expect(analyticsSlot!.pagePath).toBeNull();
|
|
expect(analyticsSlot!.defaultPath).not.toBeNull();
|
|
expect(analyticsSlot!.defaultPath).toContain("@analytics");
|
|
});
|
|
|
|
it("discovers intercepting routes inside parallel slots", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const feedRoute = routes.find((r) => r.pattern === "/feed");
|
|
|
|
expect(feedRoute).toBeDefined();
|
|
expect(feedRoute!.parallelSlots.length).toBe(1);
|
|
|
|
const modalSlot = feedRoute!.parallelSlots.find((s) => s.name === "modal");
|
|
expect(modalSlot).toBeDefined();
|
|
expect(modalSlot!.interceptingRoutes.length).toBe(1);
|
|
|
|
const intercept = modalSlot!.interceptingRoutes[0];
|
|
expect(intercept.convention).toBe("...");
|
|
expect(intercept.targetPattern).toBe("/photos/:id");
|
|
expect(intercept.params).toEqual(["id"]);
|
|
expect(intercept.pagePath).toContain("(...)photos");
|
|
});
|
|
|
|
it("discovers intercept layout chains inside parallel slots", async () => {
|
|
// Ported from Next.js:
|
|
// test/e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts
|
|
await withTempDir("vinext-app-intercept-layout-chain-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
|
|
await mkdir(path.join(appDir, "@modal", "(.)explicit-layout", "deeper"), {
|
|
recursive: true,
|
|
});
|
|
await mkdir(path.join(appDir, "explicit-layout", "deeper"), {
|
|
recursive: true,
|
|
});
|
|
|
|
await writeFile(path.join(appDir, "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "@modal", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "@modal", "(.)explicit-layout", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "@modal", "(.)explicit-layout", "deeper", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
await writeFile(path.join(appDir, "explicit-layout", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "explicit-layout", "deeper", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const homeRoute = routes.find((route) => route.pattern === "/");
|
|
|
|
expect(homeRoute).toBeDefined();
|
|
|
|
const modalSlot = homeRoute!.parallelSlots.find((slot) => slot.name === "modal");
|
|
expect(modalSlot).toBeDefined();
|
|
expect(modalSlot!.interceptingRoutes).toHaveLength(1);
|
|
expect(modalSlot!.interceptingRoutes[0]).toMatchObject({
|
|
convention: ".",
|
|
targetPattern: "/explicit-layout/deeper",
|
|
layoutPaths: [path.join(appDir, "@modal", "(.)explicit-layout", "layout.tsx")],
|
|
});
|
|
});
|
|
});
|
|
|
|
it("discovers nested intercept layout chains in outermost-to-innermost order", async () => {
|
|
await withTempDir("vinext-app-intercept-layout-depth-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
|
|
await mkdir(path.join(appDir, "@modal", "(.)foo", "bar", "baz"), {
|
|
recursive: true,
|
|
});
|
|
await mkdir(path.join(appDir, "foo", "bar", "baz"), {
|
|
recursive: true,
|
|
});
|
|
|
|
await writeFile(path.join(appDir, "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "@modal", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "@modal", "(.)foo", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "@modal", "(.)foo", "bar", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "@modal", "(.)foo", "bar", "baz", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "foo", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "foo", "bar", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "foo", "bar", "baz", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const homeRoute = routes.find((route) => route.pattern === "/");
|
|
|
|
expect(homeRoute).toBeDefined();
|
|
|
|
const modalSlot = homeRoute!.parallelSlots.find((slot) => slot.name === "modal");
|
|
expect(modalSlot).toBeDefined();
|
|
expect(modalSlot!.interceptingRoutes).toHaveLength(1);
|
|
expect(modalSlot!.interceptingRoutes[0]?.layoutPaths).toEqual([
|
|
path.join(appDir, "@modal", "(.)foo", "layout.tsx"),
|
|
path.join(appDir, "@modal", "(.)foo", "bar", "layout.tsx"),
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("allows inherited intercepting slots to reuse the same target pattern", async () => {
|
|
await withTempDir("vinext-app-intercept-inherited-slot-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
|
|
await mkdir(path.join(appDir, "intercepting-routes", "@modal", "(.)photo", "[id]"), {
|
|
recursive: true,
|
|
});
|
|
await mkdir(path.join(appDir, "intercepting-routes", "photo", "[id]"), {
|
|
recursive: true,
|
|
});
|
|
|
|
await writeFile(path.join(appDir, "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "intercepting-routes", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "intercepting-routes", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "intercepting-routes", "photo", "[id]", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
await writeFile(
|
|
path.join(appDir, "intercepting-routes", "@modal", "default.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
await writeFile(
|
|
path.join(appDir, "intercepting-routes", "@modal", "(.)photo", "[id]", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
|
|
const galleryRoute = routes.find((route) => route.pattern === "/intercepting-routes");
|
|
const detailRoute = routes.find(
|
|
(route) => route.pattern === "/intercepting-routes/photo/:id",
|
|
);
|
|
|
|
expect(galleryRoute).toBeDefined();
|
|
expect(detailRoute).toBeDefined();
|
|
|
|
const galleryModal = galleryRoute!.parallelSlots.find((slot) => slot.name === "modal");
|
|
const detailModal = detailRoute!.parallelSlots.find((slot) => slot.name === "modal");
|
|
|
|
expect(galleryModal?.interceptingRoutes[0]?.targetPattern).toBe(
|
|
"/intercepting-routes/photo/:id",
|
|
);
|
|
expect(detailModal?.interceptingRoutes[0]?.targetPattern).toBe(
|
|
"/intercepting-routes/photo/:id",
|
|
);
|
|
expect(detailModal?.interceptingRoutes[0]?.pagePath).toBe(
|
|
galleryModal?.interceptingRoutes[0]?.pagePath,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("intercepting route pages are not standalone routes", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
// The intercepting page should not create a standalone route at its intercept path
|
|
// (it lives inside @modal which is filtered from route discovery)
|
|
expect(patterns.some((p) => p.includes("("))).toBe(false);
|
|
// But the actual target route should exist
|
|
expect(patterns).toContain("/photos/:id");
|
|
});
|
|
|
|
it("discovers the full photo page as a regular route", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const photoRoute = routes.find((r) => r.pattern === "/photos/:id");
|
|
|
|
expect(photoRoute).toBeDefined();
|
|
expect(photoRoute!.isDynamic).toBe(true);
|
|
expect(photoRoute!.params).toEqual(["id"]);
|
|
expect(photoRoute!.pagePath).toContain("photos/[id]/page.tsx");
|
|
});
|
|
|
|
it("discovers forbidden.tsx boundary file at the root", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const homeRoute = routes.find((r) => r.pattern === "/");
|
|
expect(homeRoute).toBeDefined();
|
|
expect(homeRoute!.forbiddenPath).toBeTruthy();
|
|
expect(homeRoute!.forbiddenPath).toContain("forbidden.tsx");
|
|
});
|
|
|
|
it("discovers unauthorized.tsx boundary file at the root", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const homeRoute = routes.find((r) => r.pattern === "/");
|
|
expect(homeRoute).toBeDefined();
|
|
expect(homeRoute!.unauthorizedPath).toBeTruthy();
|
|
expect(homeRoute!.unauthorizedPath).toContain("unauthorized.tsx");
|
|
});
|
|
|
|
// --- Parallel slot sub-routes ---
|
|
|
|
it("generates routes for nested pages inside parallel slots", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
// @team/members/page.tsx should create a route at /dashboard/members
|
|
const membersRoute = routes.find((r) => r.pattern === "/dashboard/members");
|
|
expect(membersRoute).toBeDefined();
|
|
});
|
|
|
|
it("slot sub-route uses parent default.tsx as page", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const membersRoute = routes.find((r) => r.pattern === "/dashboard/members");
|
|
expect(membersRoute).toBeDefined();
|
|
// The children slot uses dashboard/default.tsx as the page component
|
|
expect(membersRoute!.pagePath).not.toBeNull();
|
|
expect(membersRoute!.pagePath).toContain("default.tsx");
|
|
expect(membersRoute!.pagePath).toContain("dashboard");
|
|
});
|
|
|
|
it("slot sub-route has matching slot with sub-page path", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const membersRoute = routes.find((r) => r.pattern === "/dashboard/members");
|
|
expect(membersRoute).toBeDefined();
|
|
|
|
// @team slot should point to the sub-page
|
|
const teamSlot = membersRoute!.parallelSlots.find((s) => s.name === "team");
|
|
expect(teamSlot).toBeDefined();
|
|
expect(teamSlot!.pagePath).not.toBeNull();
|
|
expect(teamSlot!.pagePath).toContain("@team");
|
|
expect(teamSlot!.pagePath).toContain("members");
|
|
|
|
// @analytics slot has no members sub-page, should have null pagePath
|
|
const analyticsSlot = membersRoute!.parallelSlots.find((s) => s.name === "analytics");
|
|
expect(analyticsSlot).toBeDefined();
|
|
expect(analyticsSlot!.pagePath).toBeNull();
|
|
// But should still have defaultPath
|
|
expect(analyticsSlot!.defaultPath).not.toBeNull();
|
|
});
|
|
|
|
it("slot sub-route inherits parent layouts", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const membersRoute = routes.find((r) => r.pattern === "/dashboard/members");
|
|
const dashboardRoute = routes.find((r) => r.pattern === "/dashboard");
|
|
expect(membersRoute).toBeDefined();
|
|
expect(dashboardRoute).toBeDefined();
|
|
|
|
// Should have same layouts as the parent route
|
|
expect(membersRoute!.layouts).toEqual(dashboardRoute!.layouts);
|
|
});
|
|
|
|
it("discovers layout routes whose own content is parallel slot pages", async () => {
|
|
await withTempDir("vinext-app-layout-parallel-slot-route-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
|
|
await mkdir(path.join(appDir, "users", "[username]", "@feed"), { recursive: true });
|
|
await mkdir(path.join(appDir, "users", "[username]", "@modal"), { recursive: true });
|
|
await writeFile(path.join(appDir, "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "users", "[username]", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "users", "[username]", "@feed", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "users", "[username]", "@feed", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "users", "[username]", "@modal", "default.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const route = routes.find((r) => r.pattern === "/users/:username");
|
|
|
|
expect(route).toBeDefined();
|
|
expect(route!.pagePath).toBeNull();
|
|
expect(route!.parallelSlots.map((slot) => slot.name).sort()).toEqual(["feed", "modal"]);
|
|
expect(route!.parallelSlots.find((slot) => slot.name === "feed")!.pagePath).toContain(
|
|
path.join("@feed", "page.tsx"),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("discovers nested parallel slot sub-routes from layout-only parent", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts (line 510)
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts
|
|
// Next.js fixture: app/parallel-nested/home/layout.tsx + @parallelB/default.tsx + @parallelB/nested/page.tsx (no home/page.tsx)
|
|
await withTempDir("vinext-app-nested-parallel-slot-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
|
|
await mkdir(path.join(appDir, "parallel-nested", "home", "@parallelB", "nested"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(path.join(appDir, "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "parallel-nested", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "parallel-nested", "home", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(
|
|
path.join(appDir, "parallel-nested", "home", "@parallelB", "default.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
await writeFile(
|
|
path.join(appDir, "parallel-nested", "home", "@parallelB", "nested", "page.tsx"),
|
|
EMPTY_PAGE,
|
|
);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
// Parent layout-only route should exist
|
|
expect(patterns).toContain("/parallel-nested/home");
|
|
const parentRoute = routes.find((r) => r.pattern === "/parallel-nested/home")!;
|
|
expect(parentRoute.pagePath).toBeNull();
|
|
expect(parentRoute.parallelSlots.map((slot) => slot.name).sort()).toEqual(["parallelB"]);
|
|
|
|
// Nested slot sub-route should be discovered even though parent has no page.tsx
|
|
expect(patterns).toContain("/parallel-nested/home/nested");
|
|
const nestedRoute = routes.find((r) => r.pattern === "/parallel-nested/home/nested")!;
|
|
expect(nestedRoute.parallelSlots.map((slot) => slot.name).sort()).toEqual(["parallelB"]);
|
|
const parallelBSlot = nestedRoute.parallelSlots.find((slot) => slot.name === "parallelB")!;
|
|
expect(parallelBSlot.pagePath).toContain(path.join("@parallelB", "nested", "page.tsx"));
|
|
});
|
|
});
|
|
|
|
// --- Hyphenated param names (issue #71) ---
|
|
|
|
it("discovers optional catch-all with hyphenated param name [[...sign-in]]", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
// [[...sign-in]] should produce :sign-in* pattern
|
|
expect(patterns).toContain("/sign-in/:sign-in*");
|
|
});
|
|
|
|
it("hyphenated optional catch-all has correct params and isDynamic", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const route = routes.find((r) => r.pattern === "/sign-in/:sign-in*");
|
|
|
|
expect(route).toBeDefined();
|
|
expect(route!.isDynamic).toBe(true);
|
|
expect(route!.params).toContain("sign-in");
|
|
});
|
|
|
|
it("matches hyphenated optional catch-all with zero segments", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/sign-in", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/sign-in/:sign-in*");
|
|
expect(result!.params).not.toHaveProperty("sign-in");
|
|
});
|
|
|
|
it("matches hyphenated optional catch-all with segments", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/sign-in/sso/callback", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/sign-in/:sign-in*");
|
|
expect(result!.params["sign-in"]).toEqual(["sso", "callback"]);
|
|
});
|
|
|
|
it("discovers dynamic segment with hyphenated param name [auth-method]", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
// [auth-method] should produce :auth-method pattern
|
|
expect(patterns).toContain("/auth/:auth-method");
|
|
});
|
|
|
|
it("matches dynamic segment with hyphenated param name", async () => {
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(APP_FIXTURE_DIR);
|
|
|
|
const result = matchAppRoute("/auth/google", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/auth/:auth-method");
|
|
expect(result!.params["auth-method"]).toBe("google");
|
|
});
|
|
|
|
// --- Inherited parallel slots ---
|
|
|
|
it("does not attach pre-root parallel slots when the root layout is below app", async () => {
|
|
// Next.js allows multiple root layouts when app/layout is omitted:
|
|
// https://github.com/vercel/next.js/blob/ae61573e062e900050b8e6b24626e450accc4570/docs/01-app/03-api-reference/03-file-conventions/layout.mdx#L140-L145
|
|
// Parallel route children above the selected root layout are not available to that root:
|
|
// https://github.com/vercel/next.js/blob/ae61573e062e900050b8e6b24626e450accc4570/packages/next/src/server/app-render/create-component-tree.tsx#L1273-L1290
|
|
await withTempDir("vinext-app-pre-root-parallel-slot-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
|
|
await mkdir(path.join(appDir, "@modal"), { recursive: true });
|
|
await mkdir(path.join(appDir, "(group)"), { recursive: true });
|
|
await writeFile(path.join(appDir, "@modal", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "(group)", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "(group)", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const homeRoute = routes.find((route) => route.pattern === "/");
|
|
|
|
expect(homeRoute).toBeDefined();
|
|
expect(homeRoute!.layouts).toEqual([path.join(appDir, "(group)", "layout.tsx")]);
|
|
expect(homeRoute!.parallelSlots.find((slot) => slot.name === "modal")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("attaches parallel slots inside the selected root layout subtree", async () => {
|
|
await withTempDir("vinext-app-root-subtree-parallel-slot-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
|
|
await mkdir(path.join(appDir, "(group)", "@modal"), { recursive: true });
|
|
await writeFile(path.join(appDir, "(group)", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "(group)", "page.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "(group)", "@modal", "default.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const homeRoute = routes.find((route) => route.pattern === "/");
|
|
|
|
expect(homeRoute).toBeDefined();
|
|
const modalSlot = homeRoute!.parallelSlots.find((slot) => slot.name === "modal");
|
|
expect(modalSlot).toBeDefined();
|
|
expect(modalSlot!.ownerDir).toBe(path.join(appDir, "(group)", "@modal"));
|
|
expect(modalSlot!.layoutIndex).toBe(0);
|
|
expect(modalSlot!.defaultPath).toBe(path.join(appDir, "(group)", "@modal", "default.tsx"));
|
|
});
|
|
});
|
|
|
|
it("preserves same-named parallel slots from multiple layout levels", async () => {
|
|
await withTempDir("vinext-app-parallel-slot-priority-", async (tmpDir) => {
|
|
const appDir = path.join(tmpDir, "app");
|
|
|
|
// Root-level @sidebar
|
|
await mkdir(path.join(appDir, "@sidebar"), { recursive: true });
|
|
await writeFile(path.join(appDir, "@sidebar", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "page.tsx"), EMPTY_PAGE);
|
|
|
|
// Dashboard-level @sidebar (closer ancestor to /dashboard/settings)
|
|
await mkdir(path.join(appDir, "dashboard", "@sidebar"), { recursive: true });
|
|
await writeFile(path.join(appDir, "dashboard", "@sidebar", "default.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "dashboard", "layout.tsx"), EMPTY_PAGE);
|
|
await writeFile(path.join(appDir, "dashboard", "page.tsx"), EMPTY_PAGE);
|
|
|
|
// The leaf route
|
|
await mkdir(path.join(appDir, "dashboard", "settings"), { recursive: true });
|
|
await writeFile(path.join(appDir, "dashboard", "settings", "page.tsx"), EMPTY_PAGE);
|
|
|
|
invalidateAppRouteCache();
|
|
const routes = await appRouter(appDir);
|
|
const settingsRoute = routes.find((r) => r.pattern === "/dashboard/settings");
|
|
expect(settingsRoute).toBeDefined();
|
|
|
|
const sidebarSlots = settingsRoute!.parallelSlots.filter((s) => s.name === "sidebar");
|
|
expect(sidebarSlots).toHaveLength(2);
|
|
|
|
const sidebarByOwner = new Map(
|
|
sidebarSlots.map((slot) => [
|
|
path.relative(appDir, slot.ownerDir).replace(/\\/g, "/"),
|
|
slot,
|
|
]),
|
|
);
|
|
|
|
expect([...sidebarByOwner.keys()].sort()).toEqual(["@sidebar", "dashboard/@sidebar"]);
|
|
expect(sidebarByOwner.get("@sidebar")!.layoutIndex).toBe(0);
|
|
expect(sidebarByOwner.get("dashboard/@sidebar")!.layoutIndex).toBe(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- Pages Router: hyphenated param names ---
|
|
|
|
describe("pagesRouter - hyphenated param names", () => {
|
|
it("discovers optional catch-all with hyphenated param name [[...sign-up]]", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
// [[...sign-up]] should produce :sign-up* pattern
|
|
expect(patterns).toContain("/sign-up/:sign-up*");
|
|
});
|
|
|
|
it("hyphenated optional catch-all has correct params and isDynamic", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
const route = routes.find((r) => r.pattern === "/sign-up/:sign-up*");
|
|
|
|
expect(route).toBeDefined();
|
|
expect(route!.isDynamic).toBe(true);
|
|
expect(route!.params).toContain("sign-up");
|
|
});
|
|
|
|
it("matches hyphenated optional catch-all with zero segments", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const result = matchRoute("/sign-up", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/sign-up/:sign-up*");
|
|
expect(result!.params).not.toHaveProperty("sign-up");
|
|
});
|
|
|
|
it("matches hyphenated optional catch-all with segments", async () => {
|
|
const routes = await pagesRouter(FIXTURE_DIR);
|
|
|
|
const result = matchRoute("/sign-up/step/2", routes);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/sign-up/:sign-up*");
|
|
expect(result!.params["sign-up"]).toEqual(["step", "2"]);
|
|
});
|
|
});
|
|
|
|
describe("pagesRouter - dotted/colon param names (Next.js parity)", () => {
|
|
it("discovers dynamic segments with dots and colons", async () => {
|
|
await withTempDir("vinext-pages-dotted-param-", async (tmpDir) => {
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
await mkdir(path.join(pagesDir, "products"), { recursive: true });
|
|
await mkdir(path.join(pagesDir, "repos"), { recursive: true });
|
|
await writeFile(
|
|
path.join(pagesDir, "products", "[variant.id].tsx"),
|
|
"export default function Page() { return null; }",
|
|
);
|
|
await writeFile(
|
|
path.join(pagesDir, "repos", "[repo:name].tsx"),
|
|
"export default function Page() { return null; }",
|
|
);
|
|
|
|
invalidateRouteCache(pagesDir);
|
|
const routes = await pagesRouter(pagesDir);
|
|
const patterns = routes.map((r) => r.pattern);
|
|
|
|
expect(patterns).toContain("/products/:variant.id");
|
|
expect(patterns).toContain("/repos/:repo:name");
|
|
invalidateRouteCache(pagesDir);
|
|
});
|
|
});
|
|
|
|
it("extracts params correctly for dotted dynamic segments", async () => {
|
|
await withTempDir("vinext-pages-dotted-match-", async (tmpDir) => {
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
await mkdir(pagesDir, { recursive: true });
|
|
await writeFile(
|
|
path.join(pagesDir, "[variant.id].tsx"),
|
|
"export default function Page() { return null; }",
|
|
);
|
|
|
|
invalidateRouteCache(pagesDir);
|
|
const routes = await pagesRouter(pagesDir);
|
|
const result = matchRoute("/abc", routes);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.route.pattern).toBe("/:variant.id");
|
|
expect(result!.params["variant.id"]).toBe("abc");
|
|
invalidateRouteCache(pagesDir);
|
|
});
|
|
});
|
|
|
|
it("skips routes whose param names end in + or * (would collide with internal modifiers)", async () => {
|
|
await withTempDir("vinext-pages-skip-plus-", async (tmpDir) => {
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
await mkdir(pagesDir, { recursive: true });
|
|
await writeFile(
|
|
path.join(pagesDir, "[id+].tsx"),
|
|
"export default function Page() { return null; }",
|
|
);
|
|
await writeFile(
|
|
path.join(pagesDir, "[id*].tsx"),
|
|
"export default function Page() { return null; }",
|
|
);
|
|
|
|
invalidateRouteCache(pagesDir);
|
|
const routes = await pagesRouter(pagesDir);
|
|
|
|
// Skipped entirely to avoid ambiguity with internal :name+ / :name* modifiers
|
|
expect(routes).toHaveLength(0);
|
|
invalidateRouteCache(pagesDir);
|
|
});
|
|
});
|
|
});
|