Skip to content

test: regression tests for inherited intercepting route slot deduplication#721

Merged
james-elicx merged 7 commits intocloudflare:mainfrom
Debbl:fix/intercepting-routes
Apr 5, 2026
Merged

test: regression tests for inherited intercepting route slot deduplication#721
james-elicx merged 7 commits intocloudflare:mainfrom
Debbl:fix/intercepting-routes

Conversation

@Debbl
Copy link
Copy Markdown
Contributor

@Debbl Debbl commented Mar 30, 2026

Summary

The fix for this bug (inherited parallel slots causing the same intercepting page to be counted multiple times during validation) has already been merged into main. This PR retains the regression tests and the intercepting-routes playground example so the behavior stays covered going forward.

What's included

  • tests/intercepting-routes-build.test.ts — production build regression test: verifies a route structure with an inherited @modal slot and an intercepting (.)photo/[id] route builds successfully
  • tests/routing.test.ts — unit regression test: verifies that inherited intercepting slots sharing the same target pattern are correctly allowed (not rejected as duplicates)
  • examples/app-router-playground/app/intercepting-routes/ — playground demo showing the intercepting routes pattern with a modal slot

Background

When a parallel slot is inherited by child routes, the same intercepting page can appear on multiple discovered routes. The old validation flattened all intercepted target patterns and treated repeated entries from inherited slots as separate route definitions, incorrectly throwing:

You cannot have two routes that resolve to the same path

The fix (de-duplicating by target pattern before validation) landed in main separately. These tests guard against regressions.

Comment on lines +176 to +187
const uniqueInterceptTargetPatterns = new Map<string, string>();
for (const route of routes) {
for (const slot of route.parallelSlots) {
for (const intercept of slot.interceptingRoutes) {
// Inherited slots can surface the same intercepting page on multiple
// child routes. De-dupe by page path so we only validate distinct
// physical intercept definitions while still rejecting real conflicts.
uniqueInterceptTargetPatterns.set(intercept.pagePath, intercept.targetPattern);
}
}
}
validateRoutePatterns(Array.from(uniqueInterceptTargetPatterns.values()));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old approach could have been wrapped in a set to achieve the same result I imagine.

I wonder if it could potentially be possible to have two conflicting pagePath values and therefore one overwrites the others targetPattern 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@james-elicx I changed it a bit. Mind taking another look?

Copy link
Copy Markdown
Collaborator

@james-elicx james-elicx Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it could potentially be possible for two interceptions to target the same route pattern

What I meant when I wrote this was that you could potentially achieve the same desired outcome with:

    [...new Set(routes.flatMap((route) =>
      route.parallelSlots.flatMap((slot) =>
        slot.interceptingRoutes.map((intercept) => intercept.targetPattern),
      ),
    ))]

just deduping the array we originally created

what do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the PR to follow your suggestion.

After discussing it a bit more with AI, I think the targetPattern dedupe is probably fine for this PR. What I still want to validate is the behavior when there are multiple intercepting routes targeting the same route, for example:

app/feed/page.tsx
app/feed/@modal/(..)photo/[id]/page.tsx

app/profile/page.tsx
app/profile/@modal/(..)photo/[id]/page.tsx

app/photo/[id]/page.tsx

I’m going to put together a small demo around this case to compare the behavior in Next.js and vinext, just to make sure we’re matching the intended semantics.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries, I'll mark as draft for the moment while you're looking at that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I opened #730 to track this issue separately.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Set dedupe was merging in another PR so I'm going to merge this as additional regression tests.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 30, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@721

commit: 9479f3d

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good bug find and clean fix. The root cause analysis is correct — inherited slots cause the same physical intercepting page to appear on multiple discovered routes, and the old flatMap approach counted them as separate entries.

The fix is sound for the problem it solves. I have one concern about a remaining edge case and a few minor nits.

Remaining edge case (not a blocker for this PR): Two different physical intercepting pages (different pagePath) that happen to target the same pattern (e.g. app/a/@modal/(...)photo/[id]/page.tsx and app/b/@modal/(...)photo/[id]/page.tsx both targeting /photo/:id) would still produce duplicate values from Array.from(uniqueInterceptTargetPatterns.values()), which validateRoutePatterns would reject. In Next.js, multiple intercepting routes from different locations can intercept the same target — interception is scoped to the navigation context. But this is a pre-existing issue (the old flatMap code had the same behavior), so it's not a regression introduced here.

}

export function collectInterceptTargetPatterns(routes: readonly AppRoute[]): string[] {
const uniqueInterceptTargetPatterns = new Map<string, string>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To address @james-elicx's review comment: the Map is keyed by pagePath (physical file) with targetPattern as the value. So two different physical intercepting pages that target the same pattern would both be stored (separate keys), and Array.from(values()) would emit that pattern twice — causing validateRoutePatterns to throw.

This isn't a regression (the old flatMap had the same problem), but worth noting that de-duplicating by targetPattern in the returned array would be more correct for the general case. The simplest approach would be to return values through a Set:

Suggested change
const uniqueInterceptTargetPatterns = new Map<string, string>();
const uniqueInterceptTargetPatterns = new Map<string, string>();

Actually the Map type is fine as-is — but consider de-duping the return value at line 159:

return [...new Set(uniqueInterceptTargetPatterns.values())];

This would handle the case where two different physical pages legitimately intercept the same target pattern (e.g. via (...) from different subtrees). Not a blocker for this PR, but a follow-up improvement.

}
}

return Array.from(uniqueInterceptTargetPatterns.values());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider de-duplicating the returned values to handle the case where two different physical pages (different pagePath) legitimately intercept the same targetPattern:

Suggested change
return Array.from(uniqueInterceptTargetPatterns.values());
return Array.from(new Set(uniqueInterceptTargetPatterns.values()));

Without this, two different @modal slots in sibling subtrees both using (...) to intercept the same root-level route would still trigger a false positive from validateRoutePatterns.

const builder = await createBuilder({
root,
configFile: false,
plugins: [vinext({ appDir: root })],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing appDir: root means appDir is the project root, not the app/ subdirectory. Is this intentional? I'd expect appDir: path.join(root, 'app') based on how other tests configure vinext — unless vinext() defaults to <root>/app when appDir is the project root.

Worth double-checking this doesn't accidentally work by falling through to a default.

import { afterEach, describe, expect, it } from "vite-plus/test";
import vinext from "../packages/vinext/src/index.js";

const tmpDirs: string[] = [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The tmpDirs array + afterEach cleanup pattern works, but the withTempDir helper from routing.test.ts is cleaner (auto-cleans in finally). Consider extracting it to a shared test utility if you want to reuse it, or just use the same pattern inline here. Not a blocker.

});
});

it("rejects a single intercepting page resolving to multiple target patterns", () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good test — this directly validates the new collectInterceptTargetPatterns function with a synthetic conflict. The test constructs a scenario where the same pagePath maps to two different targetPattern values and verifies it throws.

One thing to consider: it would also be valuable to test the inverse — two different pagePath values that map to the same targetPattern — to document the current behavior (which would currently throw, as discussed in my comment on the implementation).

was intercepted by the parallel slot.
</p>
<div className="font-mono text-sm text-cyan-300">
${product.price.toFixed(2)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This renders a literal $ followed by the price. In JSX, $ is not interpolated, so this renders correctly as $1.99 etc. — but it reads confusingly like a template literal that's missing backticks. Consider wrapping in braces for clarity:

Suggested change
${product.price.toFixed(2)}
{`$${product.price.toFixed(2)}`}

Loading this URL directly should render the standalone page. Navigating
from the gallery should keep the gallery visible and render this content
in the parallel modal slot instead.
</p>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same nit as the modal page — this JSX $ works but reads like a broken template literal:

Suggested change
</p>
{`$${product.price.toFixed(2)}`}

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 30, 2026

Review posted successfully. Here's a summary of the feedback I provided:

Overall assessment: The fix is correct and well-motivated. The root cause (inherited slots causing the same physical intercepting page to appear multiple times) is accurately diagnosed, and de-duplicating by pagePath is the right approach.

Key feedback points:

  1. Return value de-duplication (line 159) — The returned array should also be de-duplicated by value (use new Set(...values())), because two different physical pages intercepting the same target pattern would still produce duplicate entries that validateRoutePatterns would reject. This is a pre-existing issue but easy to fix here.

  2. Build test appDir parameter (line 20)vinext({ appDir: root }) passes the project root, not the app/ subdirectory. Worth verifying this is intentional rather than accidentally working via a default fallback.

  3. Build test cleanup pattern — The tmpDirs + afterEach approach works but the existing withTempDir helper from routing.test.ts is cleaner. Consider sharing it.

  4. Test coverage gap — The "rejects multiple target patterns" test covers same-pagePath-different-targetPattern, but there's no test for different-pagePath-same-targetPattern to document that behavior.

  5. JSX $ nits — The ${product.price.toFixed(2)} in JSX reads like a broken template literal; suggested using {$${...}} for clarity.

github run

@james-elicx james-elicx marked this pull request as draft March 30, 2026 19:51
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good bug fix. The root cause (inherited slots carrying interceptingRoutes to child routes, causing duplicates in validation) is correctly diagnosed and the fix is minimal. A few notes below.

The PR is marked as draft while the author investigates cross-subtree interception semantics (e.g. app/feed/@modal/(..)photo/[id] + app/profile/@modal/(..)photo/[id] both targeting /photo/:id). That's the right call — the Set de-dupe on targetPattern silently allows that case, and it's worth confirming Next.js behavior before merging.

cachedPageExtensionsKey = null;
}

export function collectInterceptTargetPatterns(routes: readonly AppRoute[]): string[] {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exporting this is good — it makes the de-duplication logic directly unit-testable (which the new test exercises). One thing to consider: the function name says "collect" but it also de-duplicates. A name like collectUniqueInterceptTargetPatterns would make the contract clearer, though this is a minor nit.

const builder = await createBuilder({
root,
configFile: false,
plugins: [vinext({ appDir: root })],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous bonk review flagged this as potentially wrong (appDir: root instead of path.join(root, 'app')), but this is actually correct. The appDir option is used as baseDir in the plugin, and path.join(baseDir, 'app') is derived internally (see index.ts:1200). So passing the project root is the intended usage.

However, I'd argue this test would be clearer without the appDir option at all — since the fixture has app/ at the root, the auto-detection logic (index.ts:1185-1196) would find it. Removing appDir: root would test the more common configuration path and avoid this confusion.

import { afterEach, describe, expect, it } from "vite-plus/test";
import vinext from "../packages/vinext/src/index.js";

const tmpDirs: string[] = [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The routing.test.ts file has a withTempDir helper that handles cleanup in a finally block, which is safer — if the test throws before tmpDirs.push(root), cleanup never happens. Consider using the same pattern:

async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>): Promise<T> {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
  try {
    return await fn(dir);
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
}

Or better yet, extract the one from routing.test.ts into a shared test utility.

},
];

expect(collectInterceptTargetPatterns([firstRoute, secondRoute])).toEqual(["/photo/:id"]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test directly validates that two different physical pages targeting the same targetPattern produce a single entry — good coverage for the Set de-duplication.

Worth noting: this also documents that vinext currently allows two different physical intercepting pages to target the same pattern (the Set silently merges them). If that turns out to differ from Next.js behavior, this test will need updating.

EMPTY_PAGE,
);

invalidateAppRouteCache();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the key regression test — it exercises the exact scenario from the bug report (inherited @modal slot causing the same intercepting page to appear on both /intercepting-routes and /intercepting-routes/photo/:id, which the old code rejected).

The assertions at lines 1122-1130 nicely verify that both routes see the same targetPattern and pagePath, confirming the inheritance behavior.

@@ -0,0 +1,46 @@
'use cache';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 'use cache' intentional here? This layout receives modal as a prop (a React node from a parallel slot). Caching a layout that renders dynamic slot content could produce stale modal content or cache key explosions depending on how the cache boundary interacts with RSC streaming.

Other layouts in the playground (e.g. parallel-routes/layout.tsx) don't use 'use cache'. If this was copied from another layout template, consider removing it.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 30, 2026

Review posted successfully. Here's a summary of the feedback I provided:

Overall assessment: Good bug fix. The root cause is correctly diagnosed — inherited parallel slots carry their interceptingRoutes to child routes, so the same physical intercepting page appears multiple times during validation. Wrapping the flatMap in new Set() (as james-elicx suggested) is the right minimal fix.

The PR is correctly marked as draft while the author investigates cross-subtree interception semantics.

Key feedback points:

  1. Function namingcollectInterceptTargetPatterns also de-duplicates; a name like collectUniqueInterceptTargetPatterns would make the contract clearer (minor nit).

  2. Build test appDir parameter — Clarified that appDir: root is actually correct (it's used as baseDir, not the app/ directory itself), but suggested removing the option entirely since auto-detection would work and avoid confusion.

  3. Temp dir cleanup pattern — The tmpDirs[] + afterEach approach is less robust than the withTempDir helper from routing.test.ts. Suggested sharing the helper.

  4. 'use cache' on the layout — Flagged that caching a layout that renders dynamic parallel slot content (modal) may be problematic. Other playground layouts don't use this directive.

  5. Test coverage — Noted that the de-duplication test documents that vinext currently allows two different physical intercepting pages to target the same pattern, which should be verified against Next.js behavior.

github run

- Merge origin/main into fix/intercepting-routes
- Rename collectInterceptTargetPatterns → collectUniqueInterceptTargetPatterns
- Fix $price JSX template literals in example modal and photo pages
- Remove 'use cache' from intercepting-routes layout (parallel slot renders dynamic content)
- Refactor intercepting-routes-build.test.ts: use withTempDir helper, remove appDir:root
@james-elicx james-elicx changed the title fix: allow inherited intercepting routes to share target patterns test: regression tests for inherited intercepting route slot deduplication Apr 5, 2026
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

@james-elicx james-elicx marked this pull request as ready for review April 5, 2026 09:21
Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds solid regression tests for the inherited intercepting route slot deduplication fix. The test coverage is well-targeted and the playground example is a good addition. A few things to clean up before merge.

Summary:

  • The two regression tests (unit + build) are well-structured and cover the exact scenario from the bug report
  • The playground example is clean and follows existing patterns (and the 'use cache' issue from prior reviews was addressed)
  • The .gitignore changes are unrelated to this PR and should be dropped
  • The formatting-only change in app-router.ts is noise — consider dropping it or mentioning it in the PR description
  • The duplicated withTempDir helper should ideally be shared, but that can be a follow-up

coverage/

# Worktrees
.worktrees/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR removes .claude (without trailing slash) and .vinext from .gitignore. These are unrelated to the intercepting routes regression tests and shouldn't be part of this PR.

  • Removing .claude (the file variant) while keeping .claude/ (the directory variant) is an odd partial cleanup. If the intent is to stop ignoring .claude files, that should be its own commit/PR.
  • Removing .vinext may cause it to show up as untracked if anyone has a .vinext file locally.

Please revert the .gitignore changes — they're accidental noise from the branch.

Comment on lines +261 to +262
converted: {
urlSegments: string[];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a formatting-only change (expanding the inline type literal to multi-line). It's fine stylistically but unrelated to the regression tests. Since the maintainer already noted the Set dedupe is merging in another PR, this formatting change may cause a merge conflict with that work. Consider dropping it to keep this PR focused on tests only.

import { describe, expect, it } from "vite-plus/test";
import vinext from "../packages/vinext/src/index.js";

async function withTempDir<T>(prefix: string, run: (tmpDir: string) => Promise<T>): Promise<T> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a duplicate of the withTempDir helper in routing.test.ts:22. Prior reviews flagged this too. Not a blocker for this PR, but worth extracting to a shared tests/_helpers.ts or similar as a follow-up to avoid drift between the two copies.

Comment on lines +1079 to +1130
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,
);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good regression test — this exercises exactly the scenario from the bug report: inherited @modal slot causing the same intercepting page to appear on both /intercepting-routes and /intercepting-routes/photo/:id. The assertions at lines 1121-1129 clearly verify that both routes see the same targetPattern and pagePath, confirming inheritance behavior and that the deduplication works correctly.

Comment on lines +35 to +153
it("builds when an inherited modal slot intercepts the same target route as a standalone page", async () => {
// Ported from Next.js route interception behavior:
// test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts
await withTempDir("vinext-intercept-build-", async (root) => {
fs.symlinkSync(
path.resolve(import.meta.dirname, "../node_modules"),
path.join(root, "node_modules"),
"junction",
);

writeFixtureFile(
root,
"package.json",
JSON.stringify({ name: "vinext-intercept-build", private: true, type: "module" }, null, 2),
);
writeFixtureFile(
root,
"tsconfig.json",
JSON.stringify(
{
compilerOptions: {
target: "ES2022",
module: "ESNext",
moduleResolution: "bundler",
jsx: "react-jsx",
strict: true,
skipLibCheck: true,
types: ["vite/client", "@vitejs/plugin-rsc/types"],
},
include: ["app", "*.ts", "*.tsx"],
},
null,
2,
),
);
writeFixtureFile(
root,
"app/layout.tsx",
`import type { ReactNode } from "react";

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
`,
);
writeFixtureFile(
root,
"app/page.tsx",
`export default function HomePage() {
return <main>home</main>;
}
`,
);
writeFixtureFile(
root,
"app/intercepting-routes/layout.tsx",
`import type { ReactNode } from "react";

export default function InterceptingLayout({
children,
modal,
}: {
children: ReactNode;
modal: ReactNode;
}) {
return (
<>
<div data-testid="gallery-shell">{children}</div>
{modal}
</>
);
}
`,
);
writeFixtureFile(
root,
"app/intercepting-routes/page.tsx",
`export default function GalleryPage() {
return <main>gallery</main>;
}
`,
);
writeFixtureFile(
root,
"app/intercepting-routes/photo/[id]/page.tsx",
`export default function PhotoPage() {
return <main>standalone photo page</main>;
}
`,
);
writeFixtureFile(
root,
"app/intercepting-routes/@modal/default.tsx",
`export default function ModalDefault() {
return null;
}
`,
);
writeFixtureFile(
root,
"app/intercepting-routes/@modal/(.)photo/[id]/page.tsx",
`export default function PhotoModalPage() {
return <div>photo modal</div>;
}
`,
);

await buildApp(root);

expect(fs.existsSync(path.join(root, "dist", "server", "index.js"))).toBe(true);
expect(fs.existsSync(path.join(root, "dist", "server", "ssr", "index.js"))).toBe(true);
expect(fs.existsSync(path.join(root, "dist", "client"))).toBe(true);
});
}, 60_000);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a thorough integration test — it creates a full fixture from scratch, symlinks node_modules, and runs a real createBuilder + buildApp(). The 60-second timeout is appropriate for a production build test.

One minor observation: the symlink + writeFixtureFile pattern for creating fixtures inline is verbose but clear. If more build tests like this are added in the future, consider putting the fixture on disk in tests/fixtures/ instead — it would be easier to maintain and wouldn't need the writeFixtureFile calls.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 5, 2026

Review posted. Here's a summary of my feedback:

Overall: The PR is in good shape. The regression tests are well-targeted and the playground example is a clean addition.

Key feedback:

  1. .gitignore changes should be reverted — The removal of .claude and .vinext entries is unrelated to the regression tests and is accidental noise from the branch.

  2. Formatting-only change in app-router.ts should be dropped — The multi-line expansion of the type literal is stylistic noise and may cause merge conflicts with the Set dedupe work landing in another PR.

  3. Duplicated withTempDir helper — Both routing.test.ts and the new intercepting-routes-build.test.ts have identical copies. Not a blocker, but worth extracting to a shared helper as a follow-up.

  4. Tests themselves are solid — The unit test in routing.test.ts directly exercises the inherited slot deduplication scenario, and the build test provides end-to-end coverage via createBuilder + buildApp().

  5. 'use cache' issue from prior reviews was addressed — the layout no longer uses it.

github run

@james-elicx james-elicx enabled auto-merge (squash) April 5, 2026 09:29
@james-elicx james-elicx merged commit a9d3455 into cloudflare:main Apr 5, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants