Repository navigation
fix(app-router): render the boundary above a layout that calls notFound() - #4145
Conversation
@vinext/cloudflare
create-vinext-app
@vinext/types
vinext
@cloudflare/workers-response-store
commit: |
|
| forbiddenTreePosition: forbiddenEntry?.treePosition ?? null, | ||
| unauthorizedPath, | ||
| unauthorizedTreePosition: unauthorizedEntry?.treePosition ?? null, | ||
| segmentNotFoundPaths: segmentNotFounds.map((entry) => entry.path), |
There was a problem hiding this comment.
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.
|
Review: 1 findings. Posted one actionable inline comment. |
Performance benchmarksCompared 0 improved · 0 regressed · 7 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
291fd0c to
7e047ca
Compare
|
/bigbonk review for issues Please review exact head |
|
@james-elicx Bonk workflow timed out. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues Please review exact head |
|
LGTM! |
|
✅ 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: ✅ LGTMNo security impact.
📖 AGENTS.md: ✅ LGTMThe change follows the repository guidelines:
📋 Code Quality: ✅ LGTMThe core logic matches the description:
The new helpers have unit tests, and there are integration tests for the issue's next-intl shape. ⚡ Performance: ✅ LGTMNo measurable regressions.
📝 Documentation: ✅ LGTMThe 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: |
7e047ca to
e7b9b8f
Compare
|
/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); |
There was a problem hiding this comment.
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.
|
Review: 1 findings. Posted one actionable inline comment. |
e7b9b8f to
7e5cd15
Compare
|
/bigbonk review for issues. Head: 7e5cd15. You have an 8-minute time limit. |
|
LGTM! |
…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
7e5cd15 to
56a14ba
Compare
|
/bigbonk review for issues. Head: 56a14ba. You have an 8-minute time limit. |

Part of #4045.
Summary
When a layout calls
notFound(),forbidden()orunauthorized(), 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 callsnotFound(), with a[locale]/not-found.tsxthat 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.tsxare 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
renderLayoutSpecialErrorchose the fallback from boundaries aligned to layouts, then the manifest's root module, thenroute.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.tsxrendered without the[locale]layout'sNextIntlClientProvider, 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
not-found,forbiddenandunauthorizedfile with its tree position (segmentNotFoundsand 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 itsgraphVersion), as layoutlesserror.tsxfiles already did. Route handlers don't record them, so aroute.tsrequest imports none of them, as Next.js's App Route entry loads only the handler.renderLayoutSpecialError:app/with no boundary of its own inherits the root segment's boundary, as Turbopack does (check_and_update_module_referencesinapp_structure.rs). That boundary is the customapp/not-foundor the built-in one. So the 404 renders inside the group's layout, which is often the root layout.app/: when there's noapp/layout.tsx(for example the root layout isapp/[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.tsxwithapp/(site)/[lang]/layout.tsx) renders inside it too. This matches where the rendered tree puts the built-in not-found.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.Tests
tests/next-intl-static-rendering.test.ts(production server, HTML and.rsc) andtests/ecosystem.test.ts(dev): the issue's next-intl fixture, plus a new[locale]/not-found.tsxthat uses next-intl'sLink./missing.webpreturns 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 fixturenot-found-layout-segment/[lang]/…:notFound().generateMetadatagets{ lang }.tests/app-layout-not-found-route-group.test.ts, based on Next'snot-found-defaultgroup test: a layout'snotFound()below a first-layer route group, with the built-in not-found and with a customapp/not-foundwhere the root layout is inside the group.app/(tests/app-page-boundary.test.ts)app/[lang]/layout.tsxand a boundary file above a deeper root layout (tests/app-page-dispatch.test.ts)tests/routing.test.ts)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.tsvp test run tests/app-route-graph.test.ts tests/browser-route-manifest.test.tsvp test run tests/app-page-dispatch.test.ts tests/app-page-boundary-render.test.ts tests/app-fallback-renderer.test.tsvp test run tests/nextjs-compat/not-found.test.tsvp test run tests/app-layout-not-found-route-group.test.tsvp test run tests/next-intl-static-rendering.test.tsvp test run tests/ecosystem.test.ts -t next-intlvp test run tests/app-router-dev-server.test.ts -t "thrown from a layout uses the"vp checkon the touched filesnext@16.2.7(Turbopack and webpack builds,next start) on the issue's shape, layoutless boundaries, route groups, multiple root layouts, andforbidden()/unauthorized()from layouts.Known limitations (unchanged by this PR)
loading.tsxare handled by the in-tree wiring, which the follow-up PR covers.forbidden()andunauthorized()without a boundary. vinext returns a plain status response.notFound()(Next errors in dev), vinext shows the built-in 404.