Skip to content

fix: move NotFoundBoundary inside Template in per-segment wiring#784

Merged
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:fix/notfound-inside-template
Apr 5, 2026
Merged

fix: move NotFoundBoundary inside Template in per-segment wiring#784
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:fix/notfound-inside-template

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

  • NotFoundBoundary was placed outside Template in the per-segment layout loop, producing Layout > NotFound > Template > Error > Page
  • Next.js wraps NotFound inside Template via TemplateContext — the correct order is Layout > Template > Error > NotFound > Page
  • When notFound() fired, the NotFound fallback replaced the entire Template subtree instead of rendering inside it

Moves the layoutNotFoundComponent wrapping to the top of the loop body (before ErrorBoundary), making it the innermost wrapper. Since the loop builds bottom-up, this produces the correct outer-to-inner nesting.

This is a pre-existing bug surfaced by the comment introduced in #780.

Test plan

  • New structural test (NotFoundBoundary is nested inside Template) walks the React element tree and asserts RootTemplate appears at a shallower depth than NotFoundBoundaryInner
  • Test failed before fix (NotFoundBoundary was depth 2, RootTemplate depth 3 — inverted)
  • All 10 tests in tests/app-page-route-wiring.test.ts pass after fix
  • vp check passes with no type errors

Add a structural element-tree test that verifies NotFoundBoundary is
nested inside the Template component (not outside it) in the per-segment
wrapping chain. Walks the React element tree to compare component depths,
asserting Template appears at a shallower depth than NotFoundBoundary.
The per-segment wrapping loop was applying NotFoundBoundary after
the Template wrap (building bottom-up), which placed it outside the
Template in the final tree:

  Layout > NotFoundBoundary > Template > Error > Page  (wrong)

When notFound() fires, NotFoundBoundary catches the error and renders
its fallback — but because it was outside Template, the Template was
no longer in the tree. Next.js's correct order is:

  Layout > Template > Error > NotFoundBoundary > Page  (correct)

Fix: move the layoutNotFoundComponent wrapping block to the start of
the loop body (before the ErrorBoundary wrap), so it becomes the
innermost wrapper. Bottom-up build order is now:

  1st wrap: NotFoundBoundary  (innermost)
  2nd wrap: ErrorBoundary
  3rd wrap: Template
  4th wrap: Layout             (outermost)

This matches Next.js semantics: templateValue wraps ErrorBoundary >
NotFoundBoundary > Page, and the Template component receives that
subtree as children.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 5, 2026

Open in StackBlitz

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

commit: 88c0e29

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

Fix is correct. Verified against Next.js source (packages/next/src/client/components/layout-router.tsx) — the per-segment nesting order is Layout > Template > ErrorBoundary > LoadingBoundary > NotFoundBoundary > children. The old code had NotFoundBoundary outside Template, which meant notFound() would replace the entire Template subtree. Moving it inside ErrorBoundary/Template is the right fix.

The code change is minimal and targeted — just moving the layoutNotFoundComponent wrapping to the top of the loop body so it becomes the innermost wrapper when building bottom-up. The test is well-structured and validates the element tree nesting order.

Two minor nits below, neither blocking.


Not in this diff but worth a follow-up: The doc comment on NotFoundBoundaryInner in error-boundary.tsx:107-108 says "This must be placed above the ErrorBoundary in the component tree" — that was the old (incorrect) behavior. With this fix, NotFoundBoundary is now correctly placed below (inside) ErrorBoundary. The ErrorBoundary.getDerivedStateFromError re-throw ensures notFound errors propagate regardless, so no correctness issue, but the comment could confuse future readers.

const templateDepth = depthMap.get("RootTemplate");
// NotFoundBoundary renders as NotFoundBoundaryInner at the class level.
// We search for the class component by its name.
const notFoundDepth = depthMap.get("NotFoundBoundaryInner") ?? depthMap.get("NotFoundBoundary");
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 is slightly inaccurate. Since walkDepth operates on the static React element tree (not the rendered component tree), it will find "NotFoundBoundary" (the function wrapper) but never "NotFoundBoundaryInner" (which is only instantiated when NotFoundBoundary renders). The ?? fallback here will always resolve to the right-hand side depthMap.get("NotFoundBoundary").

Not a correctness issue — the test works as intended — but the comment on line 264 implies NotFoundBoundaryInner is the primary lookup target when it's actually unreachable in a static tree walk.

Suggested change
const notFoundDepth = depthMap.get("NotFoundBoundaryInner") ?? depthMap.get("NotFoundBoundary");
// NotFoundBoundary is a function component wrapping the NotFoundBoundaryInner class.
// In the static element tree (before render), we see the outer NotFoundBoundary.
const notFoundDepth = depthMap.get("NotFoundBoundaryInner") ?? depthMap.get("NotFoundBoundary");

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 5, 2026

Review submitted. Here's a summary of my findings:

Approved — the fix is correct and matches Next.js's actual component nesting order.

Key findings:

  1. Fix is correct. Next.js nests per-segment components as Layout > Template > ErrorBoundary > LoadingBoundary > NotFoundBoundary > children (verified against layout-router.tsx). The old code placed NotFoundBoundary outside Template, so notFound() would blow away the Template subtree. Moving it inside (top of the loop body in bottom-up construction) produces the correct nesting.

  2. Test is solid. The tree-walk approach validates the structural invariant directly on the React element tree, independent of rendering. It correctly finds NotFoundBoundary (the function wrapper) at a deeper depth than RootTemplate.

  3. Two minor nits (neither blocking):

    • A stale doc comment in error-boundary.tsx:107-108 says NotFoundBoundary "must be placed above the ErrorBoundary" — that was the old incorrect behavior. Worth updating as a follow-up.
    • A comment in the test implies NotFoundBoundaryInner is the primary lookup target, but the static tree walk only sees NotFoundBoundary (the function wrapper); NotFoundBoundaryInner is never reached because it's only instantiated during rendering.

github run

@james-elicx james-elicx merged commit ccb299a into cloudflare:main Apr 5, 2026
24 checks passed
@NathanDrake2406 NathanDrake2406 deleted the fix/notfound-inside-template branch April 5, 2026 11:05
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