Skip to content

fix(app-router): forbidden()/unauthorized() escalate past intermediate layouts (#1547)#1647

Merged
james-elicx merged 1 commit into
mainfrom
fix/issue-1547-forbidden-unauthorized-escalation
May 28, 2026
Merged

fix(app-router): forbidden()/unauthorized() escalate past intermediate layouts (#1547)#1647
james-elicx merged 1 commit into
mainfrom
fix/issue-1547-forbidden-unauthorized-escalation

Conversation

@james-elicx
Copy link
Copy Markdown
Member

Summary

  • When a deep page called forbidden(), unauthorized(), or notFound() and the intermediate layout segments had no matching boundary file, vinext's page-error fast path wrapped the resolved root boundary in all of the route's layouts. That left the intermediate layout's chrome ("Dynamic with Layout") rendered next to the boundary fallback, diverging from Next.js where the nearest ancestor boundary owns the entire fallback subtree.
  • renderPageSpecialError now resolves the deepest layout-level boundary via a new resolveAppPageParentHttpAccessBoundary helper and slices the layouts to [0..boundaryLayoutIndex], so layouts beneath the boundary owner stop rendering. When the route-level boundary file lives next to the page (not aligned to a layout), all layouts are kept as before.
  • Ported the Next.js regression cases from test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts and test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts.

Fixes #1547.

Test plan

  • pnpm test tests/app-router.test.ts -t "1547" — new escalation cases pass
  • pnpm test tests/app-router.test.ts -t "boundary|escalate|deep|access|nearest" — all 11 related tests pass (incl. the existing layout-thrown forbidden/unauthorized tests and notFound() escalation)
  • pnpm test tests/app-page-dispatch.test.ts tests/app-fallback-renderer.test.ts tests/error-boundary.test.ts
  • pnpm run check (format + lint + types)
  • CI: full suite + Playwright

…intermediate layouts (#1547)

When a deep page called `forbidden()`/`unauthorized()`/`notFound()` and the
intermediate layout segments had no matching boundary file, vinext's
page-error fast path wrapped the resolved root boundary in *all* of the
route's layouts. That left the intermediate layout's chrome ("Dynamic with
Layout") rendered next to the boundary fallback, diverging from Next.js
where the nearest ancestor boundary owns the entire fallback subtree.

`renderPageSpecialError` now resolves the deepest layout-level boundary
via `resolveAppPageParentHttpAccessBoundary` and slices the layouts to
`[0..boundaryLayoutIndex]`, so layouts beneath the boundary owner stop
rendering. When the route-level boundary file lives next to the page
(not aligned to a layout), all layouts are kept as before.

Ported the Next.js regression cases from
`test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts` and
`test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts`.
@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 28, 2026

Open in StackBlitz

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

commit: 8526208

@github-actions
Copy link
Copy Markdown

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
web preview production

@james-elicx
Copy link
Copy Markdown
Member Author

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

Clean fix that correctly addresses boundary escalation for forbidden()/unauthorized()/notFound() thrown from pages.

What I verified

The core fix is correct. The key insight is that discoverBoundaryFilePerLayout maps boundary files per-layout directory, and when the root forbidden.tsx lives alongside app/layout.tsx, it appears at forbiddens[0]. So resolveAppPageParentHttpAccessBoundary returns layoutIndex: 0 (not null), and the layout slice [0, boundaryLayoutIndex + 1] correctly keeps only the root layout, excluding intermediate layouts that lack a boundary.

The useLayoutAlignedBoundary guard is sound. When a boundary file sits next to the page (not at a layout level), routeBoundaryModule !== parentBoundaryModule causes the code to fall through to the default path (all layouts render), which matches Next.js behavior where a page-level boundary wraps just the page subtree.

Reference equality works here because route.forbidden (from discoverBoundaryFile) and route.forbiddens[0] (from discoverBoundaryFilePerLayout) resolve to the same import variable when they point to the same file, so they're the same module object at runtime.

Tests are well-structured. The fixtures are faithful ports of the Next.js test cases, and the assertions correctly verify both positive content (boundary renders) and negative content (intermediate layout UI is suppressed).

Minor observations

The existing notFound() escalation test in tests/nextjs-compat/not-found.test.ts:118 ("notFound() escalates to root not-found when no local boundary exists") exercises the same scenario but doesn't assert that the intermediate layout's "Dynamic with Layout" text is suppressed. With this PR's fix, that suppression now happens for notFound() too (the resolveAppPageRouteBoundaryModule handles status 404). Consider adding expect(html).not.toContain("Dynamic with Layout") to that test in a follow-up — it would strengthen the regression coverage for the notFound() case specifically.

No blocking issues.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 28, 2026

Review posted. Approved PR #1647.

Summary: The fix correctly resolves the boundary escalation bug by introducing resolveAppPageParentHttpAccessBoundary which returns both the boundary module and its layout index. In renderPageSpecialError, the layout array is sliced to [0..boundaryLayoutIndex] so intermediate layouts without a matching boundary file are excluded from the fallback render — matching Next.js's create-component-tree.tsx behavior.

The one minor follow-up I noted: the existing notFound() escalation test at tests/nextjs-compat/not-found.test.ts:118 could be strengthened with an assertion that the intermediate layout's UI is suppressed, since this fix now applies to all three error types (403, 401, 404).

github run

@james-elicx james-elicx marked this pull request as ready for review May 28, 2026 20:00
@james-elicx james-elicx merged commit 3ff0678 into main May 28, 2026
38 checks passed
@james-elicx james-elicx deleted the fix/issue-1547-forbidden-unauthorized-escalation branch May 28, 2026 20:00
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.

App Router: forbidden() / unauthorized() do not escalate to parent layout boundaries

1 participant