fix: rewrite useSelectedLayoutSegment(s) to use context-based child segments#341
Conversation
…egments The previous depth-based approach sliced the pathname by a numeric URL depth, which broke when route groups were present — they don't appear in URLs but Next.js includes them in the segments array. This also meant dynamic params were returned as raw URL strings rather than resolved values matching the route tree. Replace `layoutSegmentDepths: number[]` with two new fields: - `routeSegments: string[]` — filesystem segments from app/ root - `layoutTreePositions: number[]` — directory depth per layout Each layout now receives pre-computed child segments through React context (including route groups, with dynamic params resolved to actual values and catch-all segments joined with "/"). Additional fixes found during code review: - Pass matched params to error/fallback rendering paths so dynamic segments resolve correctly during error page rendering - Skip optional catch-all segments when param is empty/undefined - Extend routeSegments for parallel slot sub-routes - Update stale type declaration in next-shims.d.ts Ported from Next.js: test/e2e/app-dir/hooks/hooks.test.ts https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/hooks/hooks.test.ts
commit: |
|
/bigbonk review |
There was a problem hiding this comment.
Review: fix useSelectedLayoutSegment(s) to use context-based child segments
This is a well-designed fix. The old approach (slicing pathname by URL depth) was fundamentally broken for route groups because groups exist in the filesystem tree but not in URLs. By pre-computing the child segments from the route tree and passing them through React context, the hooks now correctly report route groups in the segments array, matching Next.js behavior.
The architecture is sound:
routeSegmentscaptures the full filesystem path (including route groups, dynamic templates)layoutTreePositionscounts ALL directory levels (not just URL-contributing ones), so slicingrouteSegmentsat each layout's position gives the correct "below this layout" segments__resolveChildSegmentsresolves dynamic templates to actual param values at render time- Leaf pages get wrapped with
childSegments: []so hooks return empty at leaf level - Error/not-found paths correctly receive
paramsto maintain segment consistency
A few minor observations below, none blocking.
| } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") { | ||
| var pn2 = seg.slice(4, -1); | ||
| var v2 = params[pn2]; | ||
| result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg)); |
There was a problem hiding this comment.
Nit: when v2 is falsy (e.g. params[pn2] is undefined), this falls back to the raw template string like [...slug]. That's a reasonable defensive fallback, but catch-all params should always be present when the route matches. Consider whether logging a warning here would help catch routing bugs, or if the silent fallback is fine.
Same applies to line 304 for simple dynamic segments.
| // Resolve route tree segments to actual values using matched params. | ||
| // Dynamic segments like [id] are replaced with param values, catch-all | ||
| // segments like [...slug] are joined with "/", and route groups are kept as-is. | ||
| function __resolveChildSegments(routeSegments, treePosition, params) { |
There was a problem hiding this comment.
Nit: this function uses var declarations with suffixed names (pn, pn2, pn3) to avoid scoping issues in the if/else if chain. This is consistent with the rest of the generated code in this file, but using let with a shared variable name would be cleaner since each branch is mutually exclusive:
| function __resolveChildSegments(routeSegments, treePosition, params) { | |
| function __resolveChildSegments(routeSegments, treePosition, params) { | |
| let raw = routeSegments.slice(treePosition); | |
| let result = []; | |
| for (let j = 0; j < raw.length; j++) { | |
| let seg = raw[j]; |
Not a correctness issue — just readability.
| */ | ||
| import { createElement, type ReactNode } from "react"; | ||
| import { getLayoutSegmentContext } from "next/navigation"; | ||
| import { getLayoutSegmentContext } from "./navigation.js"; |
There was a problem hiding this comment.
Good change — switching from "next/navigation" to the relative "./navigation.js" avoids a potential circular resolution through the virtual module system. Since layout-segment-context.tsx is itself resolved via vinext/layout-segment-context, importing next/navigation could create an unnecessary resolution hop.
| @@ -0,0 +1,149 @@ | |||
| /** | |||
There was a problem hiding this comment.
Nice test coverage. The fixture structure exercises the key scenarios well (simple path, dynamic params, route groups, catch-all, leaf page).
One scenario that might be worth adding in a follow-up: optional catch-all [[...slug]] where the page is visited at the base path (no slug provided). The __resolveChildSegments function handles this by skipping the segment, but it's not tested here.
|
Review submitted successfully. Here's a summary of the review: Approved with minor comments. The PR correctly fixes Key observations left on the PR:
|
Summary
useSelectedLayoutSegment(s), fixing incorrect behavior when route groups are present in the App Router treeLayoutSegmentProvidervalues stay consistent withbuildPageElement()during error page renderingrouteSegmentsfor parallel slot sub-routesTest plan
tests/shims.test.ts,tests/app-router.test.ts)tests/nextjs-compat/layout-segments.test.ts) with fixture pages covering: