Skip to content

fix(routing): correct (.) interception target for nested slot subdirectories#1751

Merged
james-elicx merged 3 commits into
cloudflare:mainfrom
Divkix:fix/interception-compute-target
Jun 5, 2026
Merged

fix(routing): correct (.) interception target for nested slot subdirectories#1751
james-elicx merged 3 commits into
cloudflare:mainfrom
Divkix:fix/interception-compute-target

Conversation

@Divkix
Copy link
Copy Markdown
Contributor

@Divkix Divkix commented Jun 5, 2026

Description

When the (.) interception marker lives inside a subdirectory of the @slot dir (e.g. app/@modal/sub/(.)target/[id]/page.tsx), computeInterceptTarget was computing baseParts from routeDir (the parent of the slot, always the route that owns @slot). That value omits any subdirectory segments between the slot root and the marker directory, producing /target/:id instead of the correct /sub/target/:id.

Root cause: case "." used routeSegments = path.relative(appDir, routeDir), which is correct only when the marker is directly inside @slot. When the marker is nested deeper, routeDir still points to the slot owner, dropping the intermediate visible segments.

Fix: Derive baseParts from path.dirname(interceptRoot) (the directory containing the marker) relative to appDir, stripping invisible segments via convertSegmentsToRouteParts — the same pattern used by computeInterceptSourceMatchPattern and the (..) / (...) branches.

Fixes issue #1364 Part A.

Related Issue

Part of #1364

Potential Risk & Impact

  • Scoped to the case "." branch of computeInterceptTarget. The (..), (../..), and (...) branches are unaffected.
  • The direct-inside-slot case (app/@modal/(.)photo) is unchanged: path.dirname(interceptRoot) == @modal dir, which strips the invisible @modal segment, yielding empty baseParts — same as before.
  • All 42 existing app-route-graph tests pass.

How Has This Been Tested?

  • Added a regression test in tests/app-route-graph.test.ts ("computes target with subdirectory prefix for (.) slot nested in a slot subdirectory") that fails before the fix and passes after.
  • All 42 tests in tests/app-route-graph.test.ts pass.
  • vp check reports only pre-existing lint errors (unchanged from base).

…ctories

When the (.) interception marker lives inside a subdirectory of the @slot
dir (e.g. app/@modal/sub/(.)target/[id]/page.tsx), baseParts was computed
from routeDir (the slot owner) instead of the directory containing the
marker. This caused visible subdirectory segments to be dropped, producing
/target/:id instead of /sub/target/:id.

Fix: derive baseParts from path.dirname(interceptRoot) relative to appDir,
stripping invisible segments via convertSegmentsToRouteParts — the same
pattern used by computeInterceptSourceMatchPattern and the (..) / (...)
convention branches.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 5, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1751
npm i https://pkg.pr.new/vinext@1751

commit: 99f18a8

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 5, 2026

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx
Copy link
Copy Markdown
Member

/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.

Review: (.) interception target for nested slot subdirectories

The core fix is correct for the URL pattern and matches Next.js' extractInterceptionRouteInformation logic: for (.), the interceptedRoute is normalizeAppPath(<path before marker>) + '/' + <intercepted>. So app/@modal/sub/(.)target/[id]/sub/target/:id is exactly right. Nice catch on the root cause (using routeDir-relative segments instead of the marker's parent dir).

I did find one latent bug and a test gap.

Bug: dynamic ancestor params are dropped (double-conversion)

The new case "." sets baseParts to already-converted URL segments (convertedParent.urlSegments, e.g. :locale). Those segments are then re-fed into convertSegmentsToRouteParts(allSegments) at line 2240. On the second pass, :locale no longer matches the [param] regex, so it falls through to the static branch and the locale param is dropped from params.

Reproduction (logic-level): app/[locale]/@modal/(.)photos/[id]

  • pattern: /:locale/photos/:id ✅ (correct)
  • params: ['id'] ❌ (should be ['locale', 'id'])

The (..)/(../..) branches don't have this problem because they slice raw filesystem segments (routeSegments.slice(...), still bracketed) and let the single final conversion extract params. The new (.) branch breaks that invariant.

The cleanest fix is to keep baseParts as the raw segments (dirname of the marker, relative to appDir) and let the existing final convertSegmentsToRouteParts(allSegments) do all the stripping/param extraction in one place — matching the (..) branch. I verified this yields correct results for all three cases: nested-subdir (/sub/target/:id, params ['id']), dynamic-ancestor (/:locale/photos/:id, params ['locale','id']), and root-level (/nested, params []). See the inline suggestion.

Test gap

collectIntercepts in the test doesn't expose params, so the new regression test can't catch the dropped-param case. Please add a case with a dynamic ancestor segment (e.g. app/[locale]/@modal/(.)photos/[id]) and assert on params to lock in ['locale','id'].

Overall: the direction is right and matches Next.js, but I'd hold merge until the params double-conversion is addressed and covered by a test.

const convertedParent = convertSegmentsToRouteParts(interceptParentSegments);
baseParts = convertedParent
? convertedParent.urlSegments
: interceptParentSegments.filter((s) => !isInvisibleSegment(s));
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.

Bug: baseParts here are already-converted URL segments (e.g. :locale). They get re-fed into convertSegmentsToRouteParts(allSegments) at line 2240, where :locale no longer matches the [param] regex and is treated as static — so dynamic ancestor params (like locale) are dropped from params.

Keep baseParts as raw segments and let the single final conversion at line 2240 extract params, matching the (..) branch:

Suggested change
: interceptParentSegments.filter((s) => !isInvisibleSegment(s));
const interceptParentDir = path.dirname(interceptRoot);
// Use raw filesystem segments here. Invisible segments (@slot, route
// groups) and dynamic [param] syntax are resolved by the single
// convertSegmentsToRouteParts call below; feeding already-converted
// segments would drop dynamic ancestor params on the second pass.
baseParts = path.relative(appDir, interceptParentDir).split(path.sep).filter(Boolean);

Note: convertSegmentsToRouteParts already skips invisible segments, so @modal/route groups are still stripped correctly. Verified for nested-subdir (/sub/target/:id), dynamic-ancestor (/:locale/photos/:id, params ['locale','id']), and root-level (/nested).

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 5, 2026

Review posted successfully: #1751 (review)

Summary

I reviewed PR #1751, which fixes (.) interception targets for markers nested inside slot subdirectories.

What's correct:

  • The core fix produces the right URL pattern (/sub/target/:id) and matches Next.js' authoritative extractInterceptionRouteInformation logic (verified against the Next.js source). The root-cause analysis (using routeDir-relative segments instead of the marker's parent dir) is accurate.
  • All 42 existing tests pass (run via vp test).

Issue found (would block merge):

  • Dropped params from double-conversion. The new case "." assigns baseParts from convertedParent.urlSegments — segments that are already converted to :param form. These get re-fed into convertSegmentsToRouteParts(allSegments) later, where :locale no longer matches the [param] regex and is treated as static, dropping dynamic ancestor params. For app/[locale]/@modal/(.)photos/[id], the pattern is correct (/:locale/photos/:id) but params becomes ['id'] instead of ['locale','id']. I confirmed this with a logic simulation.
    • The (..)/(../..) branches avoid this by slicing raw (bracketed) segments and converting once at the end. I posted an inline suggestion to keep baseParts raw — verified correct across all three cases.
  • Test gap: collectIntercepts doesn't expose params, so the regression test can't catch the dropped-param bug. Recommended adding a dynamic-ancestor case asserting on params.

I posted this as a single batched review (one summary + one inline suggestion) rather than editing any files, per fork-review-mode restrictions.

github run

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk re-review for issues

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.

Re-review: both prior concerns are resolved ✅

Thanks for the quick turnaround. I verified the latest state of the diff against the source.

1. Double-conversion param drop — fixed

case "." now derives baseParts from raw filesystem segments:

const interceptParentDir = path.dirname(interceptRoot);
baseParts = path.relative(appDir, interceptParentDir).split(path.sep).filter(Boolean);

Since interceptRoot is the marker directory itself (interceptDir, set at app-route-graph.ts:2024), path.dirname(interceptRoot) correctly yields the directory containing the marker. The raw [param] segments flow into the single convertSegmentsToRouteParts(allSegments) call (line 2237), which both skips invisible segments (isInvisibleSegment, line 2305) and extracts dynamic params (line 2333). This matches the (..)/(../..) raw-slice invariant and mirrors computeInterceptSourceMatchPattern (line 2146) exactly. No more double conversion.

Traced for app/[locale]/@modal/(.)photos/[id]:

  • interceptParentDir = app/[locale]/@modal
  • baseParts = ["[locale]", "@modal"] (raw)
  • after conversion: @modal stripped, [locale]:locale → pattern /:locale/photos/:id, params ["locale", "id"]

2. Test gap — fixed

The new test "includes dynamic ancestor params for (.) slot with a dynamic ancestor segment" exposes params via collectIntercepts and asserts params: ["locale", "id"], locking in the regression. The nested-subdir test covers /sub/target/:id + sourceMatchPattern: "/sub".

Verification

  • vp test run tests/app-route-graph.test.ts43 passed.
  • Behavior matches Next.js extractInterceptionRouteInformation: for (.), interceptedRoute = normalizeAppPath(<path before marker>) + '/' + <intercepted>.

LGTM.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 5, 2026

Posted an approving review on PR #1751.

Summary

This is a re-review (/bigbonk) of PR #1751, which fixes (.) interception targets for markers nested inside slot subdirectories. The previous bonk review (pullrequestreview-4436212283) flagged two blocking issues — both are now resolved in the current diff:

  1. Double-conversion param drop (was the blocker): The author adopted the suggested fix — baseParts now uses raw filesystem segments via path.relative(appDir, path.dirname(interceptRoot)) instead of already-converted URL segments. I traced this against the source:

    • interceptRoot is the marker dir (interceptDir, app-route-graph.ts:2024), so path.dirname correctly points at the marker's parent.
    • Raw [param] segments flow into the single convertSegmentsToRouteParts call, which strips invisible segments (@modal, route groups) and extracts params in one pass — matching the (..) branch invariant and computeInterceptSourceMatchPattern.
    • For app/[locale]/@modal/(.)photos/[id] → pattern /:locale/photos/:id, params ["locale","id"]
  2. Test gap: A new test now asserts on params: ["locale","id"] for the dynamic-ancestor case, plus the original nested-subdir test (/sub/target/:id, sourceMatchPattern: "/sub").

I ran vp test run tests/app-route-graph.test.ts43 passed. The behavior matches Next.js extractInterceptionRouteInformation. Approved.

github run

@james-elicx james-elicx merged commit b25be5e into cloudflare:main Jun 5, 2026
29 checks passed
@github-actions github-actions Bot mentioned this pull request Jun 5, 2026
@Divkix Divkix deleted the fix/interception-compute-target branch June 5, 2026 22:32
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