fix(app-router): render the boundary above a layout that calls notFound() by james-elicx · Pull Request #4145 · cloudflare/vinext · GitHub
Skip to content

fix(app-router): render the boundary above a layout that calls notFound() - #4145

Merged
james-elicx merged 1 commit into
mainfrom
t3/fix-issue-4045
Oct 21, 2026
Merged

james-elicx merged 1 commit into
mainfrom
t3/fix-issue-4045

Conversation

@james-elicx

@james-elicx james-elicx commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Part of #4045.

Summary

When a layout calls notFound(), forbidden() or unauthorized(), vinext now renders the boundary from the nearest segment above that layout, as Next.js does. The issue's app (a next-intl [locale] layout that calls notFound(), with a [locale]/not-found.tsx that needs the layout's provider) now returns a 404 instead of a 500.

This PR covers layouts that vinext probes before rendering, which is the issue's shape. Layouts below a loading.tsx are not probed. The in-tree boundary wiring handles them, and it still only places boundaries at layout directories. A stacked follow-up PR will fix that path using the data added here.

Root cause

renderLayoutSpecialError chose the fallback from boundaries aligned to layouts, then the manifest's root module, then route.notFound. Each of these can sit inside or below the throwing layout, or in an unrelated subtree. Segments without a layout were invisible. So [locale]/not-found.tsx rendered without the [locale] layout's NextIntlClientProvider, and the render failed.

In Next.js (create-component-tree.tsx), every segment wraps its children in its own boundaries, whether or not it has a layout. A layout's own boundary sits inside it, so it can't catch the layout's error. The boundary replaces its segment's children, so layouts below it don't render.

Fix

  • Route graph, manifest, module loader: record every segment's not-found, forbidden and unauthorized file with its tree position (segmentNotFounds and the others), lazy-load them, and register them as action and client-reference owners. Boundaries in segments without a layout also enter the static segment graph (and its graphVersion), as layoutless error.tsx files already did. Route handlers don't record them, so a route.ts request imports none of them, as Next.js's App Route entry loads only the handler.
  • renderLayoutSpecialError:
    • Picks the deepest segment boundary strictly above the throwing layout's segment.
    • With none, 404 renders the built-in not-found. 403 and 401 keep vinext's plain status response.
    • Renders only the layouts at or above the boundary's segment.
  • First-layer route groups: a group directly under app/ with no boundary of its own inherits the root segment's boundary, as Turbopack does (check_and_update_module_references in app_structure.rs). That boundary is the custom app/not-found or the built-in one. So the 404 renders inside the group's layout, which is often the root layout.
  • Root layout below app/: when there's no app/layout.tsx (for example the root layout is app/[lang]/layout.tsx), the root boundary renders inside that root layout, so the 404 keeps its <html> and <body>. A boundary file above that root layout (e.g. app/(site)/not-found.tsx with app/(site)/[lang]/layout.tsx) renders inside it too. This matches where the rendered tree puts the built-in not-found.
  • Head:
    • Like Next.js (resolve-metadata.ts), the head takes the boundary metadata from the route's deepest boundary file, with that segment's params. That file can sit below the boundary that catches the error.
    • The head still uses the metadata of every layout above the throwing one, including layouts that no longer render around the boundary.

Tests

  • tests/next-intl-static-rendering.test.ts (production server, HTML and .rsc) and tests/ecosystem.test.ts (dev): the issue's next-intl fixture, plus a new [locale]/not-found.tsx that uses next-intl's Link. /missing.webp returns 404 with the built-in page. Both failed with a 500 before the fix.
  • tests/nextjs-compat/not-found.test.ts, on a new app-basic fixture not-found-layout-segment/[lang]/…:
    • A boundary in a segment without a layout catches a deeper layout's notFound().
    • The throwing layout's own boundary and the layouts in between don't render.
    • The head uses the deepest not-found's metadata and keeps intermediate layout metadata.
    • The boundary's generateMetadata gets { lang }.
  • tests/app-layout-not-found-route-group.test.ts, based on Next's not-found-default group test: a layout's notFound() below a first-layer route group, with the built-in not-found and with a custom app/not-found where the root layout is inside the group.
  • Unit tests:
    • boundary selection, group placement, and a root layout below app/ (tests/app-page-boundary.test.ts)
    • dispatch layouts for 404 and 403, including the built-in under app/[lang]/layout.tsx and a boundary file above a deeper root layout (tests/app-page-dispatch.test.ts)
    • route graph positions, and none for a route handler (tests/routing.test.ts)
    • segment graph facts for layoutless boundaries (tests/app-route-graph.test.ts)

Before the fix, the new integration tests fail for the reported reasons (500, wrong boundary, missing layouts, wrong metadata).

Validation

  • vp test run tests/app-page-boundary.test.ts tests/routing.test.ts
  • vp test run tests/app-route-graph.test.ts tests/browser-route-manifest.test.ts
  • vp test run tests/app-page-dispatch.test.ts tests/app-page-boundary-render.test.ts tests/app-fallback-renderer.test.ts
  • vp test run tests/nextjs-compat/not-found.test.ts
  • vp test run tests/app-layout-not-found-route-group.test.ts
  • vp test run tests/next-intl-static-rendering.test.ts
  • vp test run tests/ecosystem.test.ts -t next-intl
  • vp test run tests/app-router-dev-server.test.ts -t "thrown from a layout uses the"
  • vp check on the touched files
  • Compared with real next@16.2.7 (Turbopack and webpack builds, next start) on the issue's shape, layoutless boundaries, route groups, multiple root layouts, and forbidden()/unauthorized() from layouts.

Known limitations (unchanged by this PR)

  • Loading boundaries: layouts below a loading.tsx are handled by the in-tree wiring, which the follow-up PR covers.
  • Metadata from lower layouts: Next also collects metadata from the throwing layout and the layouts below it. vinext uses the layouts above the throwing one.
  • RSC status: for an RSC request, Next answers 200 and puts the not-found in the flight. vinext answers 404, as it already did.
  • No built-in 403/401 pages: Next renders built-in pages for forbidden() and unauthorized() without a boundary. vinext returns a plain status response.
  • Root layout: when the root layout itself calls notFound() (Next errors in dev), vinext shows the built-in 404.

@james-elicx

Copy link
Copy Markdown
Member Author

@pkg-pr-new

pkg-pr-new Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

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

create-vinext-app

npm i https://pkg.pr.new/create-vinext-app@4145

@vinext/types

npm i https://pkg.pr.new/@vinext/types@4145

vinext

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

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@4145

commit: 56a14ba

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
response-store-demo preview production
workers-cache preview production
kv preview production
static-assets-cache preview production
static-assets-pages preview production
static-assets-pages-i18n preview production
static-export preview production
web preview production

forbiddenTreePosition: forbiddenEntry?.treePosition ?? null,
unauthorizedPath,
unauthorizedTreePosition: unauthorizedEntry?.treePosition ?? null,
segmentNotFoundPaths: segmentNotFounds.map((entry) => entry.path),

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.

These newly recorded layoutless HTTP-access boundaries never enter routeManifest.segmentGraph.boundaries: createStaticSegmentGraph() only adds HTTP boundaries while iterating layouts, then separately adds layoutless error.tsx facts. As a result, adding or removing not-found.tsx, forbidden.tsx, or unauthorized.tsx in a segment without a layout changes runtime rendering while leaving both the static graph and its graphVersion unchanged. Please materialize these segment boundary facts, analogous to addRouteManifestSegmentErrorBoundaryFacts(), and cover the layoutless case in app-route-graph.test.ts.

@ask-bonk

ask-bonk Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Review: 1 findings.

Posted one actionable inline comment.

github run

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 56a14ba against base c12f603 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 7 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 151.4 KB 151.4 KB ⚫ 0.0%
Client entry size (gzip) vinext 140.9 KB 140.9 KB ⚫ 0.0%
Dev server cold start vinext 2.90 s 2.91 s ⚫ +0.4%
Dev server cold start (large Pages Router app) vinext 28.12 s 28.07 s ⚫ -0.2%
Production build time vinext 3.70 s 3.75 s ⚫ +1.3%
RSC entry closure size (gzip) vinext 142.5 KB 143.0 KB ⚫ +0.3%
Server bundle size (gzip) vinext 247.5 KB 248.0 KB ⚫ +0.2%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 7e047ca40864b041e8bdcaeab2b3a0b889ac9a78 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow timed out.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 7e047ca40864b041e8bdcaeab2b3a0b889ac9a78 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@james-elicx
james-elicx marked this pull request as ready for review October 21, 2026 09:42
@clanky-the-clanker

clanky-the-clanker Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

✅ Approved — No problems found. Boundary selection, the layout filtering, the head metadata wiring and the route-graph discovery do what the description says.

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.


✅ All checks passed

🔒 Security: ✅ LGTM

No security impact.

  • Boundary selection: the boundary modules come from the app's own app/ tree. Request data does not affect which module is chosen.
  • 403 and 401: with no boundary above the throwing layout, these still return the plain status response, so no page content can be exposed.
  • Route handlers: route.ts requests load none of the new boundary modules.

📖 AGENTS.md: ✅ LGTM

The change follows the repository guidelines:

  • The runtime logic lives in server/* helpers, which have focused unit tests.
  • The new fixtures were added under tests/fixtures.
  • The behaviour was checked against Next.js, with references to create-component-tree.tsx, resolve-metadata.ts and Turbopack's app_structure.rs.

📋 Code Quality: ✅ LGTM

The core logic matches the description:

  • Boundary selection: resolveAppPageLayoutHttpAccessBoundary picks the deepest segment boundary strictly above the throwing layout's tree position.
  • Layouts rendered: renderLayoutSpecialError renders only the layouts at or above the boundary's segment. The root-layout and first-layer route-group placement is limited by resolveAppPageRootHttpAccessBoundaryTreePosition.
  • Head metadata: headBoundaryModule and headLayouts are passed through app-fallback-renderer.ts (as headLayoutModules) to the head resolution in app-page-boundary-render.ts.
  • Root-level boundaries: discoverSegmentBoundaryFiles includes tree position 0, so app/forbidden.tsx and app/unauthorized.tsx are still found after the old root-module fallback was removed.
  • Route handlers: route handlers skip discovery, so a route.ts request imports none of these files.

The new helpers have unit tests, and there are integration tests for the issue's next-intl shape.


⚡ Performance: ✅ LGTM

No measurable regressions.

  • Route scan: discoverSegmentBoundaryFiles adds three findFile passes per page route. They run once, when routes are scanned, not per request, and route handlers skip them.
  • Per request: the segment boundary modules are lazy and cached, like the existing per-layout boundary arrays.
  • Error path: the selection loops run only when a layout throws, and they are linear in route depth.

📝 Documentation: ✅ LGTM

The new and changed helpers have accurate JSDoc, and the inline comments match the behaviour. The pull request description documents the known differences from Next.js.


Reviewed commit: 56a14baf

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues. Head: e7b9b8f. You have an 8-minute time limit.

pushArrayLoads(loads, route.notFounds, route.__loadNotFounds);
pushArrayLoads(loads, route.forbiddens, route.__loadForbiddens);
pushArrayLoads(loads, route.unauthorizeds, route.__loadUnauthorizeds);
pushArrayLoads(loads, route.segmentNotFounds, route.__loadSegmentNotFounds);

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.

These segment-boundary imports also run for route.ts matches. app-rsc-handler.ts calls ensureRouteLoaded before checking route.routeHandler (lines 2752-2814), so an API request now evaluates every ancestor not-found.tsx/forbidden.tsx/unauthorized.tsx, including boundaries beyond the nearest one that were not previously loaded. A module-scope throw or side effect in an otherwise unrelated boundary can therefore break or alter the route handler, whereas Next's App Route entry imports only the handler userland module. Please omit these arrays for route-handler manifests or gate their hydration to page routes.

@ask-bonk

ask-bonk Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Review: 1 findings.

Posted one actionable inline comment.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues. Head: 7e5cd15. You have an 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

…nd()

A layout's notFound(), forbidden() or unauthorized() was rendered with
boundaries aligned to layouts, which could sit inside or below the
throwing layout or in an unrelated subtree. Segments without a layout
were invisible. With next-intl, a [locale]/not-found.tsx rendered
without the [locale] layout's provider and the request failed with 500.

Record every segment's boundary files with their tree positions and
pick the deepest one strictly above the throwing layout's segment, as
Next.js does. Without one, 404 renders the built-in not-found. A
first-layer route group inherits the root segment's boundary, as it
does in Turbopack, so a root boundary renders inside the group's
layout. Only layouts at or above the boundary's segment render around
it. The head keeps every layout above the throwing one and, like
Next.js, takes the boundary metadata from the route's deepest boundary
file with that segment's params.

Part of #4045
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues. Head: 56a14ba. You have an 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

@james-elicx
james-elicx added this pull request to stack #4151 October 21, 2026 20:03
@james-elicx
james-elicx merged commit 3fc60e4 into main Oct 21, 2026
104 checks passed
@james-elicx
james-elicx deleted the t3/fix-issue-4045 branch October 21, 2026 20:04
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.

1 participant