Skip to content

refactor: extract route wiring from generated entry into typed runtime module#748

Open
NathanDrake2406 wants to merge 7 commits intocloudflare:mainfrom
NathanDrake2406:feat/layout-persistence-pr-2b
Open

refactor: extract route wiring from generated entry into typed runtime module#748
NathanDrake2406 wants to merge 7 commits intocloudflare:mainfrom
NathanDrake2406:feat/layout-persistence-pr-2b

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Apr 2, 2026

Summary

Part of #726 (PR 2b). Moves boundary/layout/segment wiring logic out of the generated entry template (app-rsc-entry.ts) into a typed runtime module (server/app-page-route-wiring.tsx). This follows the project's architectural guideline that generated entries should stay thin: codegen glue, not runtime logic.

What moved

The generated buildPageElement() function in app-rsc-entry.ts previously contained ~250 lines of inline logic for:

  • Resolving dynamic route segments to param values (catch-all, optional catch-all, dynamic)
  • Building the layout -> boundary -> template -> page nesting chain
  • Wiring LayoutSegmentProvider with computed segmentMap at each layout level
  • Attaching ErrorBoundary, NotFoundBoundary, and Suspense (loading) at correct tree positions
  • Mounting parallel slot content with per-slot layouts, loading, and error boundaries

All of this is now in packages/vinext/src/server/app-page-route-wiring.tsx as typed, unit-testable functions:

Export Purpose
buildAppPageRouteElement() Main orchestrator that takes route config + page element and returns the full wired React tree
createAppPageLayoutEntries() Computes layout entry metadata (tree path, ID, associated error/notFound modules)
resolveAppPageChildSegments() Resolves route segments against matched params (dynamic [id], catch-all [...slug], optional [[...slug]])
createAppPageTreePath() Builds tree path string from route segments (includes route groups for collision avoidance)

The generated entry now imports and calls __buildAppPageRouteElement() instead of inlining the logic. Template snapshots updated accordingly, and the generated code is much shorter.

ErrorBoundary pathname-based reset

ErrorBoundary now resets error state on pathname changes, matching the existing pattern in NotFoundBoundary. This was identified as a required fix in #726 because the boundary instance persists at the same tree position across client navigations.

Implementation details:

  • ErrorBoundary is now a function wrapper that reads usePathname() and passes it to ErrorBoundaryInner.
  • getDerivedStateFromProps() clears the caught error when the pathname changes.
  • getDerivedStateFromError() preserves the existing previousPathname instead of overwriting it, which avoids immediately clearing freshly caught client errors before error.tsx can render.

Important scope note: this reset is pathname-based only. It does not reset on search-param-only navigations because usePathname() does not include the query string.

Why this matters for layout persistence

PR 2c ("The Switch") needs to restructure how the route wiring is built from a single nested tree to flat map entries with Slot references. Having the wiring logic in a typed module instead of embedded in a template string means PR 2c can modify buildAppPageRouteElement() directly with type safety, IDE support, and focused unit tests.

Test plan

  • tests/app-page-route-wiring.test.ts — unit coverage for the extracted helpers (segment resolution, layout entries, tree paths)
  • tests/error-boundary.test.ts — pathname reset coverage, including a regression test that caught the immediate-clear bug in the client error boundary state machine
  • tests/entry-templates.test.ts — snapshot updated to verify generated entry delegates to the extracted helper
  • Targeted app-router E2E coverage for the browser error flows that regressed during refactor
vp test run tests/error-boundary.test.ts tests/app-page-route-wiring.test.ts tests/entry-templates.test.ts
vp check packages/vinext/src/entries/app-rsc-entry.ts packages/vinext/src/server/app-page-route-wiring.tsx packages/vinext/src/shims/error-boundary.tsx tests/error-boundary.test.ts
vp run test:e2e -- tests/e2e/app-router/error-handling.spec.ts tests/e2e/app-router/error-interactive.spec.ts tests/e2e/app-router/nextjs-compat/error-nav.spec.ts tests/e2e/app-router/navigation-flows.spec.ts --project app-router

Refs #726

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 2, 2026

Open in StackBlitz

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

commit: 98174ca

@NathanDrake2406 NathanDrake2406 changed the title Extract app page route wiring helpers refactor: extract route wiring from generated entry into typed runtime module Apr 2, 2026
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 2, 2026 01:58
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Code review

Found 1 issue:

  1. ErrorBoundary pathname-based reset is a behavioral change without documented Next.js verification. CLAUDE.md requires: "Always verify Next.js behavior first" and "This is a required step for all feature work and bug fixes" -- search the Next.js test suite for how their error boundary handles pathname-based reset, and link the reference. Tests should include // Ported from Next.js: comments where applicable.

*/
export class ErrorBoundaryInner extends React.Component<
ErrorBoundaryInnerProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryInnerProps) {
super(props);
this.state = { error: null, previousPathname: props.pathname };
}
static getDerivedStateFromProps(
props: ErrorBoundaryInnerProps,
state: ErrorBoundaryState,
): ErrorBoundaryState | null {
if (props.pathname !== state.previousPathname && state.error) {
return { error: null, previousPathname: props.pathname };
}
return { error: state.error, previousPathname: props.pathname };
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
// notFound(), forbidden(), unauthorized(), and redirect() must propagate
// past error boundaries. Re-throw them so they bubble up to the
// framework's HTTP access fallback / redirect handler.
if (error && typeof error === "object" && "digest" in error) {
const digest = String(error.digest);
if (
digest === "NEXT_NOT_FOUND" || // legacy compat
digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") ||
digest.startsWith("NEXT_REDIRECT;")
) {
throw error;
}
}
return { error };
}
reset = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
const FallbackComponent = this.props.fallback;
return <FallbackComponent error={this.state.error} reset={this.reset} />;
}
return this.props.children;
}
}
export function ErrorBoundary({ fallback, children }: ErrorBoundaryProps) {
const pathname = usePathname();
return (
<ErrorBoundaryInner pathname={pathname} fallback={fallback}>
{children}
</ErrorBoundaryInner>
);
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

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

Review: Extract route wiring from generated entry into typed runtime module

This is a well-structured refactor that aligns with the project's architectural guideline that generated entries should stay thin. The extraction is mechanically faithful with a few intentional improvements, the type definitions are thorough, and the test coverage is solid.

Overall Assessment

The core extraction is correct. The generated buildPageElement() now delegates to __buildAppPageRouteElement() with a clean options bag, and the inline __resolveChildSegments is replaced by the typed resolveAppPageChildSegments. The RSC/SSR boundary is properly maintained — error-boundary.tsx has "use client", so the RSC plugin converts its imports to client references in app-page-route-wiring.tsx.

Behavioral Changes

I identified two intentional behavioral differences in resolveAppPageChildSegments vs the old __resolveChildSegments:

  1. == null=== undefined for optional catch-all: The old code skipped both null and undefined param values; the new code only skips undefined. This is more correct for the AppPageParams type which doesn't include null.

  2. ||?? for fallback values in catch-all and dynamic segments: The old code treated empty string "" as falsy and fell back to the raw segment syntax (e.g., "[slug]"); the new code preserves empty strings. This is a correctness improvement — leaking raw segment syntax to useSelectedLayoutSegments() consumers was a bug.

Both are improvements, but neither is covered by a dedicated test case (see inline comments).

ErrorBoundary Pathname Reset

The getDerivedStateFromErrorgetDerivedStateFromProps interaction is correctly implemented. Returning Partial<ErrorBoundaryState> from getDerivedStateFromError (without previousPathname) ensures React merges the error into existing state without overwriting the tracked pathname. The test at line 212 validates the critical sequence where an error is caught and then getDerivedStateFromProps runs on the same pathname without clearing it.

The existing PR comment about verifying against Next.js source is valid — the PR description mentions this was "identified as a required fix in #726" but doesn't link to the specific Next.js source/test. The test file header (lines 22-28) references Next.js source paths which is good, but the individual test cases for pathname reset don't have // Ported from Next.js: comments.

if (
segment.startsWith("[[...") &&
segment.endsWith("]]") &&
segment.length > "[[...x]]".length - 1
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 length check is a bit convoluted. "[[...x]]".length - 1 evaluates to 8, so the condition is segment.length > 8, meaning minimum 9 characters. Since "[[...x]]" (the shortest valid optional catch-all) is exactly 9 characters, a clearer way to express this would be >= "[[...x]]".length:

Suggested change
segment.length > "[[...x]]".length - 1
segment.length >= "[[...x]]".length

if (Array.isArray(paramValue) && paramValue.length === 0) {
continue;
}
if (paramValue === undefined) {
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.

Behavioral change: the old code used v == null (loose equality, skips both null and undefined), but this uses === undefined (strict, only skips undefined). This is more correct for the AppPageParams type, but it's not covered by a test. Consider adding a test case for an explicitly null param value to lock down the intentional change:

it("skips null param values for optional catch-all", () => {
  expect(
    resolveAppPageChildSegments(["docs", "[[...slug]]"], 0, { slug: null as any }),
  ).toEqual(["docs"]);
});

(Or if you'd prefer it to push null, document that as the intentional difference.)

resolvedSegments.push(paramValue.join("/"));
continue;
}
resolvedSegments.push(paramValue ?? segment);
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.

Behavioral change: ||??. The old code used v2 || seg which fell back to the raw segment for any falsy value (including empty string ""). The new ?? only falls back for null/undefined, preserving empty strings. This is more correct but not tested. Consider adding:

it("preserves empty-string param values instead of falling back to raw segment", () => {
  expect(
    resolveAppPageChildSegments(["blog", "[...slug]"], 0, { slug: "" }),
  ).toEqual(["blog", ""]);
});


const routeSlots = options.route.slots ?? {};
const layoutEntries = createAppPageLayoutEntries(options.route);
const routeThenableParams = options.makeThenableParams(options.matchedParams);
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.

Minor optimization: the old code called makeThenableParams(params) fresh for every layout iteration, creating a new Promise each time. The new code calls it once and reuses the result. This is fine (all resolve to the same value), but worth noting this is an intentional change if anyone ever depends on referential inequality between layout params promises.

}

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
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 use of Partial<ErrorBoundaryState> here. By not including previousPathname in the return, React merges just { error } into existing state, preserving the tracked pathname. This prevents the immediate-clear bug where getDerivedStateFromProps would see a mismatched pathname and reset the error before it could render.

One concern: React guarantees that getDerivedStateFromProps runs after getDerivedStateFromError during the error re-render, and the merged state (with preserved previousPathname) is passed to it. The test at error-boundary.test.ts:212 validates this sequence correctly. However, the test simulates the merge manually with spread ({ ...baseState, ...getDerivedStateFromError(error) }). Consider adding a rendered-component test (using @testing-library/react or similar) that throws an error inside the boundary and verifies the fallback actually renders, to validate the full React lifecycle integration.

if (props.pathname !== state.previousPathname && state.error) {
return { error: null, previousPathname: props.pathname };
}
return { error: state.error, previousPathname: props.pathname };
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 always returns a new state object even when nothing changed (same pathname, no error). React will still skip the re-render if the values are the same (shallow comparison), so this isn't a correctness issue, but returning null when props.pathname === state.previousPathname && !state.error would be slightly more idiomatic:

Suggested change
return { error: state.error, previousPathname: props.pathname };
if (props.pathname !== state.previousPathname) {
return { error: state.error ? null : state.error, previousPathname: props.pathname };
}
return null;

Actually, on reflection, the current approach is simpler and easier to reason about. The always-return-state pattern avoids subtle bugs where the pathname tracking falls out of sync. Feel free to ignore this — it's a style preference, not a correctness issue.

slug: "post",
}),
).toEqual(["blog", "post", "a/b"]);
});
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 coverage of the happy path. Missing test cases for edge cases in resolveAppPageChildSegments:

  1. Optional catch-all with empty array { slug: [] } (should skip the segment)
  2. Optional catch-all with undefined (should skip the segment)
  3. Dynamic segment [id] with undefined param (should fall back to raw segment)
  4. Route group segments like (auth) (should be passed through unchanged)

Some of these are implicitly covered by the integration test below, but dedicated unit tests for the segment resolver would lock down the behavioral changes from || to ??.

}));
// The error boundary is primarily a client-side component.
//
// Verified against Next.js source:
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: the file header references the Next.js source paths. Per the existing PR comment from the auto-reviewer, the individual pathname-reset test cases (lines 184-245) should ideally include // Ported from Next.js: comments linking to the specific test file that validates this behavior in Next.js, if one exists.

If Next.js doesn't have an explicit test for pathname-based error boundary reset (they may handle it differently via the router), that's worth documenting too — e.g., // No direct Next.js test equivalent; behavior inferred from packages/next/src/client/components/error-boundary.tsx.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 2, 2026

Review posted successfully. Here's a summary of the findings:

The refactoring is well-done overall. The extraction from the generated entry template into app-page-route-wiring.tsx is mechanically faithful, properly typed, and aligns with the project's "thin entries" guideline.

Key findings in the review (8 inline comments):

  1. Two intentional behavioral changes in resolveAppPageChildSegments that lack dedicated test coverage:

    • == null=== undefined for optional catch-all params (more correct for the type system, but changes null handling)
    • ||?? for fallback values (preserves empty strings instead of leaking raw segment syntax — a correctness improvement)
  2. A nit on the optional catch-all length checksegment.length > "[[...x]]".length - 1 is equivalent to >= "[[...x]]".length but harder to read.

  3. The ErrorBoundary pathname reset is correctly implemented — the Partial<ErrorBoundaryState> return from getDerivedStateFromError preserves previousPathname during React's state merge, and the test validates the critical non-clearing sequence. Suggested adding a rendered-component test for full lifecycle validation.

  4. RSC/SSR boundary is properly maintainederror-boundary.tsx has "use client", the RSC plugin handles the import as a client reference, and usePathname() has a safe SSR fallback.

  5. Missing // Ported from Next.js: comments on the pathname-reset test cases, as noted by the existing auto-review comment.

github run

- Fix optional catch-all length check readability (> len-1 → >= len)
- Add dedicated tests for behavioral changes: strict undefined check
  on optional catch-all params, nullish coalescing preserving empty
  strings instead of falling back to raw segment syntax
- Add edge case tests: route groups, empty array, undefined dynamic
  params
- Add Next.js source reference comments on pathname-reset error
  boundary tests (no direct Next.js test equivalent exists)
Copilot AI review requested due to automatic review settings April 2, 2026 10:39
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Refactors the App Router generated RSC entry so it delegates route tree wiring (layouts/templates/boundaries/segment context/parallel slots) to a typed runtime module, and updates the client ErrorBoundary shim to reset on pathname changes.

Changes:

  • Extracted page route wiring logic into packages/vinext/src/server/app-page-route-wiring.tsx and updated the generated entry to call it.
  • Updated ErrorBoundary shim to reset error state when usePathname() changes, and added focused unit tests.
  • Added unit tests for the extracted route-wiring helpers and updated entry template snapshots.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/error-boundary.test.ts Adds regression/behavior tests for pathname-based ErrorBoundary resets and digest classification.
tests/app-page-route-wiring.test.ts New unit tests covering segment resolution, tree paths/layout entries, and end-to-end wiring composition.
tests/snapshots/entry-templates.test.ts.snap Updates snapshots to reflect thinner generated entry delegating to the new runtime helper.
packages/vinext/src/shims/error-boundary.tsx Implements pathname-based reset for ErrorBoundary via an inner class + hook wrapper.
packages/vinext/src/server/app-page-route-wiring.tsx New typed runtime module implementing the extracted wiring logic.
packages/vinext/src/entries/app-rsc-entry.ts Updates generator to import and call the extracted runtime wiring helper.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +140 to +175
for (const segment of rawSegments) {
if (
segment.startsWith("[[...") &&
segment.endsWith("]]") &&
segment.length >= "[[...x]]".length
) {
const paramName = segment.slice(5, -2);
const paramValue = params[paramName];
if (Array.isArray(paramValue) && paramValue.length === 0) {
continue;
}
if (paramValue === undefined) {
continue;
}
resolvedSegments.push(Array.isArray(paramValue) ? paramValue.join("/") : paramValue);
continue;
}

if (segment.startsWith("[...") && segment.endsWith("]")) {
const paramName = segment.slice(4, -1);
const paramValue = params[paramName];
if (Array.isArray(paramValue)) {
resolvedSegments.push(paramValue.join("/"));
continue;
}
resolvedSegments.push(paramValue ?? segment);
continue;
}

if (segment.startsWith("[") && segment.endsWith("]") && !segment.includes(".")) {
const paramName = segment.slice(1, -1);
const paramValue = params[paramName];
resolvedSegments.push(
Array.isArray(paramValue) ? paramValue.join("/") : (paramValue ?? segment),
);
continue;
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

resolveAppPageChildSegments() treats missing params as undefined (and uses ?? fallbacks), but AppPageParams is imported as Record<string, string | string[]>, so params[paramName] is typed as always-present/non-nullish. In strict TS this makes checks like paramValue === undefined and expressions like paramValue ?? segment unreachable/ill-typed. Consider widening the param value type for indexed access (e.g., allow undefined when a key is absent) or updating AppPageParams to include undefined so this helper typechecks and matches runtime behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +49
type ErrorBoundaryInnerConstructor = {
getDerivedStateFromError(error: Error): {
error: Error | null;
previousPathname: string;
};
getDerivedStateFromProps(
props: {
children: React.ReactNode;
fallback: React.ComponentType<{ error: Error; reset: () => void }>;
pathname: string;
},
state: {
error: Error | null;
previousPathname: string;
},
): {
error: Error | null;
previousPathname: string;
} | null;
};
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

The local ErrorBoundaryInnerConstructor type says getDerivedStateFromError() returns a full { error, previousPathname } state object, but the shim’s ErrorBoundaryInner.getDerivedStateFromError() now returns a partial state (it intentionally preserves previousPathname). Updating the test’s constructor type to match the actual signature would prevent type drift and better reflect the behavior under test.

Copilot uses AI. Check for mistakes.
@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.

Review: Route wiring extraction + ErrorBoundary pathname reset

The extraction is well-structured and the generated entry is significantly cleaner. The type system around AppPageRouteWiringRoute, AppPageSlotOverride, etc. is thorough and will make PR 2c much easier to iterate on. Two issues worth fixing, a few minor items.

Summary

Must fix:

  1. The test's ErrorBoundaryInnerConstructor type claims getDerivedStateFromError returns a full ErrorBoundaryState, but the actual implementation now returns Partial<ErrorBoundaryState>. This type drift masks the exact behavior the test is validating.
  2. getDerivedStateFromProps unconditionally updates previousPathname even when the pathname hasn't changed and there's no error — this is fine for correctness but the early-out null return would be more idiomatic. More importantly, line 39 has a subtle condition ordering issue: it only clears the error when both pathname changed AND error exists, but it always updates previousPathname on line 42. If the pathname changes without an error, the previousPathname is silently updated on line 42 through the else branch, which is correct but the condition on line 39 could be simplified.

Should fix:
3. The resolveAppPageChildSegments null passthrough for optional catch-all (strict === undefined vs old == null) is tested but the test asserts ["docs", null] which means null leaks into the segment array. Consider whether downstream consumers of segment arrays (e.g. useSelectedLayoutSegments()) handle null entries correctly.

Nits / observations (no action needed):
4. The routeThenableParams memoization outside the loop (line 235) is a nice improvement over the old per-iteration makeThenableParams(params) calls.
5. Template params correctly receive raw matchedParams (not thenable), matching the old behavior.

getDerivedStateFromError(error: Error): {
error: Error | null;
previousPathname: 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 actual getDerivedStateFromError now returns Partial<ErrorBoundaryState> (omitting previousPathname), but this type says it returns a full { error, previousPathname }. This matters because the test at line 232-234 simulates React's state merge with { ...baseState, ...getDerivedStateFromError(error) } — if the test type matched reality, TypeScript would flag that previousPathname is optional in the spread result, making the merge semantics explicit.

Suggested change
};
getDerivedStateFromError(error: Error): Partial<{
error: Error | null;
previousPathname: string;
}>;

if (props.pathname !== state.previousPathname && state.error) {
return { error: null, previousPathname: props.pathname };
}
return { error: state.error, previousPathname: props.pathname };
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 always returns a new state object even when nothing changed (same pathname, no error). React will shallow-compare and skip the re-render, so this isn't a correctness bug, but it's worth noting that this branch unconditionally syncs previousPathname from props — meaning if the pathname changes but there's no error, the previousPathname is updated silently here. That's actually correct behavior (it needs to track the current pathname for the next error), but it might be clearer to split the two concerns:

if (props.pathname !== state.previousPathname) {
  return { error: state.error ? null : state.error, previousPathname: props.pathname };
}
if (state.error) {
  return state; // no change needed
}
return null; // no update

This is a style nit — the current code is correct. Feel free to ignore.

if (Array.isArray(paramValue) && paramValue.length === 0) {
continue;
}
if (paramValue === undefined) {
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 old code used v == null (loose equality, catches both null and undefined), this uses === undefined (strict). The test at app-page-route-wiring.test.ts:101-110 explicitly validates this change and documents that null passes through.

However, the test asserts ["docs", null] — meaning null ends up in the segment array as a value. Downstream, resolveAppPageChildSegments results are passed to LayoutSegmentProvider's segmentMap.children, which is consumed by useSelectedLayoutSegments(). Does that hook (and its consumers) handle null entries in the segments array? If not, this is a latent bug where null params leak through to user-facing hooks.

Since AppPageParams is typed as Record<string, string | string[]> (no null), the null case shouldn't occur at runtime. But the test casts null as unknown as string to test it. If the intent is that null is impossible at runtime, consider adding an early return for null with a comment explaining why, or removing the test case.

import { ErrorBoundary, NotFoundBoundary } from "../shims/error-boundary.js";
import { LayoutSegmentProvider } from "../shims/layout-segment-context.js";
import { MetadataHead, ViewportHead, type Metadata, type Viewport } from "../shims/metadata.js";
import type { AppPageParams } from "./app-page-boundary.js";
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.

Copilot's review raised a valid point: AppPageParams is Record<string, string | string[]>, so params[paramName] is typed as always-present. The === undefined checks and ?? fallbacks are runtime-correct (indexing a Record can return undefined at runtime) but technically unreachable under strict TS.

This isn't a blocking issue — Record<string, V> returning undefined on missing keys is a well-known TS gap that noUncheckedIndexedAccess addresses. But if this repo has that compiler flag enabled, these checks would be properly typed. If not, a small type adjustment like params: Record<string, string | string[] | undefined> on the function signature (not on the exported type) would make the intent explicit without affecting the public API.

continue;
}
const TemplateComponent = templateComponent;
element = <TemplateComponent params={options.matchedParams}>{element}</TemplateComponent>;
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.

Templates receive raw matchedParams (not thenable), matching the old { children: element, params } behavior. Correct.

However, the old code passed both children and params as named props via createElement(TemplateComponent, { children: element, params }). The JSX here passes children via JSX children syntax and params as a prop. The result is identical in React, but worth noting for anyone comparing the generated output.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 2, 2026

Review posted successfully. Here's a summary of the findings:

The refactoring is solid overall. The extraction from the generated entry template into app-page-route-wiring.tsx is mechanically faithful, well-typed, and aligns with the "thin entries" guideline. The ErrorBoundary pathname-reset implementation correctly handles the getDerivedStateFromErrorgetDerivedStateFromProps state merge sequence.

Key findings (5 inline comments):

  1. Test type driftErrorBoundaryInnerConstructor.getDerivedStateFromError in the test claims to return a full state object, but the implementation now returns Partial<ErrorBoundaryState>. Suggested a fix.

  2. getDerivedStateFromProps style — The always-return pattern is correct but could be simplified. Style nit, not blocking.

  3. null leaking through optional catch-all segments — The strict === undefined change (vs old == null) means null values pass through to segment arrays. The test explicitly validates this, but downstream consumers (useSelectedLayoutSegments()) may not handle null entries. Flagged for consideration.

  4. AppPageParams type vs runtime behaviorRecord<string, string | string[]> doesn't include undefined, making the === undefined checks technically unreachable under strict TS. Suggested a local type widening on the function signature.

  5. Template params — Confirmed the old and new behavior match (raw params, not thenable).

github run

- Widen resolveAppPageChildSegments params to include undefined,
  matching runtime behavior of Record indexing without
  noUncheckedIndexedAccess
- Fix ErrorBoundaryInnerConstructor test type to match actual
  Partial<ErrorBoundaryState> return from getDerivedStateFromError
- Remove null param test case (impossible at runtime given
  AppPageParams type, testing unreachable behavior)
- Remove vestigial type cast on empty-string test
@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.

Review: route wiring extraction + ErrorBoundary pathname reset

The refactoring is structurally sound and well-motivated. The generated entry is dramatically thinner, the new runtime module is properly typed and unit-testable, and the ErrorBoundary pathname reset matches Next.js's ErrorBoundaryHandler almost line-for-line. The previous two bot reviews covered the mechanical details thoroughly; this review focuses on a few remaining items.

ErrorBoundary: verified against Next.js

I fetched the current Next.js error-boundary.tsx from canary. The vinext implementation faithfully replicates the critical behavior:

  • getDerivedStateFromError returns { error } (partial), preserving previousPathname via React's state merge
  • getDerivedStateFromProps clears error only when pathname !== previousPathname && state.error
  • The unconditional previousPathname sync on line 42 matches Next.js exactly

The one difference is that Next.js uses useUntrackedPathname() (which skips router tracking) while vinext uses usePathname(). This is fine since vinext doesn't have the same router tracking infrastructure, but worth noting as a future divergence point if tracking semantics are added.

Remaining items

Two small issues and one observation below. The behavioral changes (== null=== undefined, ||??) have been thoroughly discussed in prior reviews and the tests now cover the important cases. The AppPageParams typing issue (indexed access returning undefined at runtime but not in the type) is a pre-existing gap — not introduced by this PR.

Overall this is good work that sets up PR 2c well.

if (props.pathname !== state.previousPathname && state.error) {
return { error: null, previousPathname: props.pathname };
}
return { error: state.error, previousPathname: props.pathname };
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 unconditional previousPathname sync (even when pathname hasn't changed) is correct and matches Next.js's getDerivedStateFromProps exactly — their implementation at error-boundary.tsx L97-L101 has the identical pattern.

One difference: Next.js has an additional handleHardNavError guard inside getDerivedStateFromProps (gated behind process.env.__NEXT_APP_NAV_FAIL_HANDLING) that clears the error and triggers a hard navigation for certain transient errors. This isn't needed today but may become relevant if vinext adds hard-nav recovery.

export function resolveAppPageChildSegments(
routeSegments: readonly string[],
treePosition: number,
params: Readonly<Record<string, string | string[] | undefined>>,
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 function signature correctly widens the param value type to include undefined, which is the right call — it matches runtime behavior even though AppPageParams doesn't include undefined. This local widening is better than changing the exported type.

However, there's an asymmetry: the string in the union means a non-array string param like { slug: "hello" } flows into the optional catch-all branch at line 154 as-is (pushing the raw string). This matches the old code, but is it actually correct for optional catch-all? If params.slug is "hello" (a string, not an array), should that be treated as a single-segment catch-all value or should it be skipped? The old code had the same behavior, so this isn't a regression — just worth thinking about for correctness.

continue;
}
const TemplateComponent = templateComponent;
element = <TemplateComponent params={options.matchedParams}>{element}</TemplateComponent>;
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.

Templates receive raw matchedParams (not thenable). This matches the old code which passed params directly:

element = createElement(TemplateComponent, { children: element, params });

But layouts at line 258 receive routeThenableParams (thenable). This asymmetry is intentional and matches the old code, but it's worth a brief comment explaining why templates get raw params while layouts get thenable params — otherwise it looks like an oversight to future contributors.

expect(state).toMatchObject({ error: e });
});

// No direct Next.js test equivalent; behavior inferred from
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. These comments adequately address the earlier review feedback about documenting the Next.js verification. The comment correctly notes that Next.js's E2E suite (test/e2e/app-dir/errors/index.test.ts) only tests the button-click reset path, not pathname-based reset, and that the behavior is inferred from their source at getDerivedStateFromProps.

I verified this against the current Next.js canary source — the implementation matches.

expect(resolveAppPageChildSegments(["blog", "[id]"], 0, {})).toEqual(["blog", "[id]"]);
});

it("preserves empty-string param instead of falling back to raw segment", () => {
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 addition covering the ||?? behavioral change. This test locks down that empty-string params are preserved rather than falling back to raw segment syntax.

Note: the input { slug: "" } is typed as string but the catch-all branch at app-page-route-wiring.tsx:165 does paramValue ?? segment — since "" is not nullish, it pushes the empty string. The old v2 || seg would have pushed the raw [...slug] segment. This is a correctness improvement.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 2, 2026

Review posted successfully. Here's a summary of the findings:

The refactoring is solid. The extraction from the generated entry template into app-page-route-wiring.tsx is mechanically faithful, well-typed, and aligns with the "thin entries" guideline. The ErrorBoundary pathname reset correctly matches Next.js's ErrorBoundaryHandler implementation.

Key findings (5 inline comments):

  1. ErrorBoundary matches Next.js canary — Verified against the actual Next.js source. The getDerivedStateFromProps pattern is identical. Noted one minor difference: Next.js uses useUntrackedPathname() while vinext uses usePathname(), and Next.js has an additional handleHardNavError guard that vinext doesn't need yet.

  2. resolveAppPageChildSegments param type widening — The function signature correctly accepts undefined in the param value union even though the exported AppPageParams type doesn't include it. This local widening matches runtime behavior. Noted an edge case question about string values in optional catch-all segments (not a regression).

  3. Template vs layout param asymmetry — Templates receive raw matchedParams while layouts receive thenable params. This matches the old code but could use a comment explaining the intentional difference.

  4. Test Next.js verification comments — The error boundary test file now has adequate // No direct Next.js test equivalent documentation that addresses the earlier review feedback.

  5. ||?? behavioral change — Test at line 105 properly locks down the improvement where empty-string params are preserved instead of leaking raw segment syntax.

github run

Templates receive raw params (Next.js doesn't pass params to
templates at all), while layouts receive thenable params matching
the Next.js 15+ async params contract.
@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.

Review: route wiring extraction + ErrorBoundary pathname reset

This is a well-executed refactor that delivers on the "thin entries" guideline. The generated entry goes from ~250 lines of inline wiring logic to a clean delegation call, the new runtime module is thoroughly typed, and the ErrorBoundary pathname-reset matches the Next.js canary implementation faithfully.

Three prior bot reviews have covered this thoroughly. I have one issue to flag and a few observations.

One issue

Template params comment is inaccurate. The comment at line 230 says "Next.js doesn't pass params to templates at all" but this isn't clearly sourced and the old vinext code did pass params to templates. If this comment is wrong, it could mislead future contributors into removing the param passing. If it's correct, it should link to the Next.js source that demonstrates it. Either way, it's not blocking — the code does the right thing by preserving the existing behavior.

Observations

  1. The extraction is mechanically faithful. I traced the old inline code against the new typed module and the logic matches, with two intentional improvements (== null=== undefined, ||??) that are both correctness fixes covered by tests.

  2. ErrorBoundary implementation is correct. The getDerivedStateFromErrorgetDerivedStateFromProps interaction with Partial<ErrorBoundaryState> is the right approach. React merges partial state, preserving previousPathname, so getDerivedStateFromProps doesn't immediately clear a freshly caught error. The test at line 221 validates exactly this sequence.

  3. Type compatibility at the resolveChildSegments boundary is clean. resolveAppPageChildSegments accepts Record<string, string | string[] | undefined> while the existing call sites in app-page-boundary-render.ts and app-page-boundary.ts pass AppPageParams (Record<string, string | string[]>). This works correctly because the function parameter is contravariant — a function that accepts a wider type can be used where a narrower type is expected.

  4. The makeThenableParams memoization (called once at line 238 instead of per-layout-iteration) is a nice improvement. Since all layouts in a single route share the same params, there's no reason to create N identical Promise objects.

Overall this is solid work that sets up PR 2c well. The inline comments below are minor.

const TemplateComponent = templateComponent;
// Next.js doesn't pass params to templates at all (createElement(Template, null, children)).
// We pass raw params here for convenience; layouts below receive thenable (Promise) params
// to match the Next.js 15+ async params contract.
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 comment claims "Next.js doesn't pass params to templates at all" but I couldn't verify this against the Next.js source. In packages/next/src/client/components/layout-router.tsx, Next.js renders templates with React.createElement(template, segmentChild) where segmentChild contains childProp — params are passed indirectly through the tree rather than as explicit props.

The old vinext code passed params to templates, and this code preserves that behavior, which is fine. But the comment could mislead a future contributor into removing the param passing. Consider either:

  • Linking to the specific Next.js source that shows templates don't receive params as props, or
  • Softening the comment to something like: "Templates receive raw params (not thenable) for convenience. Next.js doesn't explicitly pass params as a prop to templates, but we do for backward compat."

if (paramValue === undefined) {
continue;
}
resolvedSegments.push(Array.isArray(paramValue) ? paramValue.join("/") : paramValue);
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 improvement: the old == null loose check treated both null and undefined as "skip", but the AppPageParams type (Record<string, string | string[]>) doesn't include null, so null params shouldn't occur at runtime. The strict === undefined is more correct for the type system.

The test at app-page-route-wiring.test.ts:93-98 covers both the undefined (implicit missing key) and empty array cases. Worth noting for anyone reading the diff: this is an intentional behavioral change, not an oversight.

};

for (const [slotName, slot] of Object.entries(routeSlots)) {
const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1;
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.

Subtle behavior: when slot.layoutIndex is -1 (meaning "attach to innermost layout"), this uses layoutEntries.length - 1. The old code used route.layouts.length - 1. These are equivalent because layoutEntries is built from route.layouts with a 1:1 mapping, but it's worth noting the indirection changed — if createAppPageLayoutEntries ever filters or transforms the layout list, this assumption would break.

error: null,
previousPathname: "/error-test",
};
const stateAfterError = {
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 correctly simulates React's state merge semantics: getDerivedStateFromError returns { error } (partial), and the spread { ...baseState, ...result } mirrors how React merges partial state into the existing state object, preserving previousPathname: "/error-test". Then getDerivedStateFromProps sees matching pathnames and preserves the error.

This is the critical regression test for the Partial<ErrorBoundaryState> return type change. Good coverage.

});
}
layoutProps[slotName] = slotElement;
return __buildAppPageRouteElement({
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.

Clean delegation. The options bag makes it very clear what data flows into the wiring logic, and the generated code is now ~15 lines instead of ~200. This is exactly what the "thin entries" guideline calls for.

One thing I noticed: the snapshot at entry-templates.test.ts.snap line 750 shows globalErrorModule: null for the basic fixture (no global-error.tsx), while this source template correctly interpolates the globalErrorVar. The snapshot correctly reflects the fixture state — just confirming the template conditional works both ways.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 2, 2026

Review posted successfully. Here's a summary:

The refactoring is solid. The extraction from the generated entry template into app-page-route-wiring.tsx is mechanically faithful, well-typed, and aligns with the project's "thin entries" guideline. The generated entry drops from ~250 lines of inline wiring to a ~15-line delegation call.

Key findings (5 inline comments):

  1. Template params comment accuracy — The comment at line 230 claims "Next.js doesn't pass params to templates at all" but this isn't clearly sourced. The code correctly preserves the existing behavior of passing raw params, but the comment could mislead future contributors. Suggested softening or linking to the source.

  2. == null=== undefined behavioral change — Confirmed this is a correctness improvement matching the AppPageParams type system. Tests cover the important cases.

  3. Slot layoutIndex indirection — Noted that the -1 sentinel now resolves against layoutEntries.length instead of route.layouts.length. These are equivalent today but the assumption could break if createAppPageLayoutEntries ever filters layouts.

  4. Error boundary state merge test — Confirmed the test at line 232 correctly simulates React's partial state merge semantics and is the critical regression test for the Partial<ErrorBoundaryState> change.

  5. Generated entry delegation — Clean options bag, snapshot correctly reflects both the basic (no global-error) and full fixture cases.

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@james-elicx The comment is correct I think

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.

3 participants