diff --git a/crates/next-core/src/app_structure.rs b/crates/next-core/src/app_structure.rs index 0a0b8ee02ea06..7d05af4c592be 100644 --- a/crates/next-core/src/app_structure.rs +++ b/crates/next-core/src/app_structure.rs @@ -865,7 +865,7 @@ fn directory_tree_to_loader_tree_internal( // the path). let is_root_layout = app_path.is_root() && modules.layout.is_some(); - if (is_root_directory || is_root_layout) { + if is_root_directory || is_root_layout { if modules.not_found.is_none() { modules.not_found = Some( get_next_package(app_dir).join("dist/client/components/not-found-error.js".into()), diff --git a/packages/next/src/client/components/forbidden-error.tsx b/packages/next/src/client/components/forbidden-error.tsx index 1eb84a3ed8480..9f0f2821c91e7 100644 --- a/packages/next/src/client/components/forbidden-error.tsx +++ b/packages/next/src/client/components/forbidden-error.tsx @@ -1,6 +1,6 @@ import { HTTPAccessErrorFallback } from './http-access-fallback/error-fallback' -export default function NotFound() { +export default function Forbidden() { return ( @@ -121,9 +119,16 @@ class HTTPAccessFallbackErrorBoundary extends React.Component< {process.env.NODE_ENV === 'development' && ( )} - {(triggeredStatus === HTTPAccessErrorStatus.NOT_FOUND && notFound) ? notFound : null} - {(triggeredStatus === HTTPAccessErrorStatus.FORBIDDEN && forbidden) ? forbidden : null} - {(triggeredStatus === HTTPAccessErrorStatus.UNAUTHORIZED && unauthorized) ? unauthorized : null} + {triggeredStatus === HTTPAccessErrorStatus.NOT_FOUND && notFound + ? notFound + : null} + {triggeredStatus === HTTPAccessErrorStatus.FORBIDDEN && forbidden + ? forbidden + : null} + {triggeredStatus === HTTPAccessErrorStatus.UNAUTHORIZED && + unauthorized + ? unauthorized + : null} ) } diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index 0f5d842d7c01c..a8ee010ccd27e 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -514,6 +514,7 @@ export default function OuterLayoutRouter({ template, notFound, forbidden, + unauthorized, }: { parallelRouterKey: string segmentPath: FlightSegmentPath @@ -525,6 +526,7 @@ export default function OuterLayoutRouter({ template: React.ReactNode notFound: React.ReactNode | undefined forbidden: React.ReactNode | undefined + unauthorized: React.ReactNode | undefined }) { const context = useContext(LayoutRouterContext) if (!context) { @@ -581,7 +583,11 @@ export default function OuterLayoutRouter({ errorScripts={errorScripts} > - + ) } diff --git a/packages/next/src/client/components/unauthorized.ts b/packages/next/src/client/components/unauthorized.ts index 51da5498890d4..1a5b6597b925c 100644 --- a/packages/next/src/client/components/unauthorized.ts +++ b/packages/next/src/client/components/unauthorized.ts @@ -25,7 +25,9 @@ export function unauthorized(): never { !process.env.__NEXT_TEST_MODE && !process.env.NEXT_PRIVATE_SKIP_CANARY_CHECK ) { - throw new Error(`\`unauthorized()\` is experimental and not allowed allowed to used in canary builds.`) + throw new Error( + `\`unauthorized()\` is experimental and only allowed to be used in canary builds.` + ) } // eslint-disable-next-line no-throw-literal diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index b62740e69f0d1..f8517198c35de 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -114,7 +114,15 @@ async function createComponentTreeInternal({ const { page, layoutOrPagePath, segment, modules, parallelRoutes } = parseLoaderTree(tree) - const { layout, template, error, loading, 'not-found': notFound, forbidden } = modules + const { + layout, + template, + error, + loading, + 'not-found': notFound, + forbidden, + unauthorized, + } = modules const injectedCSSWithCurrentLayout = new Set(injectedCSS) const injectedJSWithCurrentLayout = new Set(injectedJS) @@ -194,7 +202,7 @@ async function createComponentTreeInternal({ injectedJS: injectedJSWithCurrentLayout, }) : [] - + const [Forbidden, forbiddenStyles] = forbidden ? await createComponentStylesAndScripts({ ctx, @@ -205,6 +213,16 @@ async function createComponentTreeInternal({ }) : [] + const [Unauthorized, unauthorizedStyles] = unauthorized + ? await createComponentStylesAndScripts({ + ctx, + filePath: unauthorized[1], + getComponent: unauthorized[0], + injectedCSS: injectedCSSWithCurrentLayout, + injectedJS: injectedJSWithCurrentLayout, + }) + : [] + let dynamic = layoutOrPageMod?.dynamic if (nextConfigOutput === 'export') { @@ -363,8 +381,8 @@ async function createComponentTreeInternal({ ) : undefined - - const forbiddenComponent = + + const forbiddenComponent = Forbidden && isChildrenRouteKey ? ( <> {forbiddenStyles} @@ -372,6 +390,14 @@ async function createComponentTreeInternal({ ) : undefined + const unauthorizedComponent = + Unauthorized && isChildrenRouteKey ? ( + <> + {unauthorizedStyles} + + + ) : undefined + // if we're prefetching and that there's a Loading component, we bail out // otherwise we keep rendering for the prefetch. // We also want to bail out if there's no Loading component in the tree. @@ -468,6 +494,7 @@ async function createComponentTreeInternal({ templateScripts={templateScripts} notFound={notFoundComponent} forbidden={forbiddenComponent} + unauthorized={unauthorizedComponent} />, childCacheNodeSeedData, ] @@ -640,6 +667,7 @@ async function createComponentTreeInternal({ ) } + // TODO: support forbidden and unauthorized in parallel routes if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) { // TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`. // This ensures that a `HTTPAccessFallbackBoundary` is available for when that happens, @@ -662,20 +690,6 @@ async function createComponentTreeInternal({ params={currentParams} /> ) - const forbiddenClientSegment = ( - - {forbiddenStyles} - - - ), - }} - params={currentParams} - /> - ) segmentNode = ( ) + // TODO: support forbidden and unauthorized in parallel routes if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) { // TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`. // This ensures that a `HTTPAccessFallbackBoundary` is available for when that happens, diff --git a/test/development/acceptance-app/hydration-error.test.ts b/test/development/acceptance-app/hydration-error.test.ts index d4162ce6be17e..9430a3d66d57f 100644 --- a/test/development/acceptance-app/hydration-error.test.ts +++ b/test/development/acceptance-app/hydration-error.test.ts @@ -434,9 +434,34 @@ describe('Error overlay for hydration errors in App router', () => { expect(await getRedboxTotalErrorCount(browser)).toBe(1) }) - expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` - "In HTML, whitespace text nodes cannot be a child of . Make sure you don't have any extra whitespace between tags on each line of your source code. - This will cause a hydration error. + if (process.env.TURBOPACK) { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` + "In HTML, whitespace text nodes cannot be a child of
. Make sure you don't have any extra whitespace between tags on each line of your source code. + This will cause a hydration error. + + ... + + + + + + + + + + + + + >
+ > {" "} + ... + ... + " + `) + } else { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` + "In HTML, whitespace text nodes cannot be a child of
. Make sure you don't have any extra whitespace between tags on each line of your source code. + This will cause a hydration error. ... @@ -444,8 +469,8 @@ describe('Error overlay for hydration errors in App router', () => { - - + + @@ -905,7 +930,7 @@ describe('Error overlay for hydration errors in App router', () => { if (isTurbopack) { expect(fullPseudoHtml).toMatchInlineSnapshot(` "... - + @@ -925,7 +950,7 @@ describe('Error overlay for hydration errors in App router', () => { } else { expect(fullPseudoHtml).toMatchInlineSnapshot(` "... - + diff --git a/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-not-found/[id]/page.js b/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/[id]/page.js similarity index 100% rename from test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-not-found/[id]/page.js rename to test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/[id]/page.js diff --git a/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-not-found/layout.js b/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/layout.js similarity index 100% rename from test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-not-found/layout.js rename to test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/layout.js diff --git a/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-not-found/page.js b/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/page.js similarity index 100% rename from test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-not-found/page.js rename to test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/page.js diff --git a/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/forbidden.js b/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/forbidden.js index 2d0d39c878c06..fc34d5c8ba032 100644 --- a/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/forbidden.js +++ b/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/forbidden.js @@ -1,3 +1,3 @@ -export default function NotFound() { +export default function Forbidden() { return
{`dynamic/[id] forbidden`}
} diff --git a/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/page.js b/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/page.js index 949ba4bb327b7..ffa29943c9bf6 100644 --- a/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/page.js +++ b/test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/page.js @@ -1,8 +1,5 @@ import { forbidden } from 'next/navigation' -// avoid static generation to fill the dynamic params -export const dynamic = 'force-dynamic' - export default async function Page(props) { const params = await props.params diff --git a/test/e2e/app-dir/forbidden/basic/app/forbidden.js b/test/e2e/app-dir/forbidden/basic/app/forbidden.js index 6de39c97e7e2d..ac06bff1cb4d6 100644 --- a/test/e2e/app-dir/forbidden/basic/app/forbidden.js +++ b/test/e2e/app-dir/forbidden/basic/app/forbidden.js @@ -1,11 +1,9 @@ -export default function NotFound() { +export default function Forbidden() { return ( <> -

Root Not Found

+

Root Forbidden

{Date.now()}
) } - -NotFound.displayName = 'NotFound' diff --git a/test/e2e/app-dir/forbidden/basic/basic.test.ts b/test/e2e/app-dir/forbidden/basic/basic.test.ts index 4286508f8a8e4..73b06d4280e43 100644 --- a/test/e2e/app-dir/forbidden/basic/basic.test.ts +++ b/test/e2e/app-dir/forbidden/basic/basic.test.ts @@ -1,174 +1,41 @@ import { nextTestSetup } from 'e2e-utils' -import { check } from 'next-test-utils' describe('app dir - forbidden - basic', () => { - const { next, isNextDev, isNextStart, skipped } = nextTestSetup({ + const { next } = nextTestSetup({ files: __dirname, - skipDeployment: true, }) - if (skipped) { - return - } - - it("should propagate notFound errors past a segment's error boundary", async () => { - // let browser = await next.browser('/error-boundary') - // await browser.elementByCss('button').click() - // expect(await browser.elementByCss('h1').text()).toBe('Root Not Found') - - // browser = await next.browser('/error-boundary/nested/nested-2') - // await browser.elementByCss('button').click() - // expect(await browser.elementByCss('h1').text()).toBe( - // 'Not Found (error-boundary/nested)' - // ) - - // browser = await next.browser('/error-boundary/nested/trigger-not-found') - // expect(await browser.elementByCss('h1').text()).toBe( - // 'Not Found (error-boundary/nested)' - // ) + it('should match dynamic route forbidden boundary correctly', async () => { + // `/dynamic` display works + const browserDynamic = await next.browser('/dynamic') + expect(await browserDynamic.elementByCss('main').text()).toBe('dynamic') + + // `/dynamic/403` calling forbidden() will match the same level forbidden boundary + const browserError = await next.browser('/dynamic/403') + expect(await browserError.elementByCss('#forbidden').text()).toBe( + 'dynamic/[id] forbidden' + ) + + const browserDynamicId = await next.browser('/dynamic/123') + expect(await browserDynamicId.elementByCss('#page').text()).toBe( + 'dynamic [id]' + ) }) - // it('should return 404 status code for custom not-found page', async () => { - // const res = await next.fetch('/_not-found') - // expect(res.status).toBe(404) - // }) - - if (isNextStart) { - // it('should include not found client reference manifest in the file trace', async () => { - // const fileTrace = JSON.parse( - // await next.readFile('.next/server/app/_not-found/page.js.nft.json') - // ) - - // const isTraced = fileTrace.files.some((filePath) => - // filePath.includes('page_client-reference-manifest.js') - // ) - - // expect(isTraced).toBe(true) - // }) - - // it('should not output /404 in tree view logs', async () => { - // const output = await next.cliOutput - // expect(output).not.toContain('○ /404') - // }) - - // it('should use root not-found content for 404 html', async () => { - // // static /404 page will use /_not-found content - // const page404 = await next.readFile('.next/server/pages/404.html') - // expect(page404).toContain('Root Not Found') - // }) - } - - const runTests = ({ isEdge }: { isEdge: boolean }) => { - it('should use the forbidden page for non-matching routes', async () => { - const browser = await next.browser('/random-content') - expect(await browser.elementByCss('h1').text()).toContain( - 'Root Not Found' - ) - // should contain root layout content - expect(await browser.elementByCss('#layout-nav').text()).toBe('Navbar') - }) - - it('should match dynamic route forbidden boundary correctly', async () => { - // `/dynamic` display works - const browserDynamic = await next.browser('/dynamic') - expect(await browserDynamic.elementByCss('main').text()).toBe('dynamic') - - // `/dynamic/404` calling notFound() will match the same level forbidden boundary - const browserDynamic404 = await next.browser('/dynamic/403') - expect(await browserDynamic404.elementByCss('#forbidden').text()).toBe( - 'dynamic/[id] not found' - ) - - const browserDynamicId = await next.browser('/dynamic/123') - expect(await browserDynamicId.elementByCss('#page').text()).toBe( - 'dynamic [id]' - ) - }) - - it('should escalate notFound to parent layout if no forbidden boundary present in current layer', async () => { - const browserDynamic = await next.browser( - '/dynamic-layout-without-forbidden' - ) - expect(await browserDynamic.elementByCss('h1').text()).toBe( - 'Dynamic with Layout' - ) - - // no forbidden boundary in /dynamic-layout-without-forbidden, escalate to parent layout to render root forbidden - const browserDynamicId = await next.browser( - '/dynamic-layout-without-forbidden/403' - ) - expect(await browserDynamicId.elementByCss('h1').text()).toBe( - 'Root Not Found' - ) - - // const browserDynamic404 = await next.browser( - // '/dynamic-layout-without-forbidden/123' - // ) - // expect(await browserDynamic404.elementByCss('#page').text()).toBe( - // 'dynamic-layout-without-forbidden [id]' - // ) - }) - - if (isNextDev) { - it('should not reload the page', async () => { - const browser = await next.browser('/random-content') - const timestamp = await browser.elementByCss('#timestamp').text() - - await new Promise((resolve) => { - setTimeout(resolve, 3000) - }) - - await check(async () => { - const newTimestamp = await browser.elementByCss('#timestamp').text() - return newTimestamp !== timestamp ? 'failure' : 'success' - }, 'success') - }) - - // Disabling for Edge because it is too flakey. - // @TODO investigate a proper for fix for this flake - if (!isEdge) { - it('should render the 404 page when the file is removed, and restore the page when re-added', async () => { - const browser = await next.browser('/') - await check(() => browser.elementByCss('h1').text(), 'My page') - await next.renameFile('./app/page.js', './app/foo.js') - await check(() => browser.elementByCss('h1').text(), 'Root Not Found') - await next.renameFile('./app/foo.js', './app/page.js') - await check(() => browser.elementByCss('h1').text(), 'My page') - }) - } - } - - if (!isNextDev && !isEdge) { - it('should create the 404 mapping and copy the file to pages', async () => { - const html = await next.readFile('.next/server/pages/404.html') - expect(html).toContain('Root Not Found') - expect( - await next.readFile('.next/server/pages-manifest.json') - ).toContain('"pages/404.html"') - }) - } - } - - describe('with default runtime', () => { - runTests({ isEdge: false }) - }) - - describe('with runtime = edge', () => { - let originalLayout = '' - - beforeAll(async () => { - await next.stop() - originalLayout = await next.readFile('app/layout.js') - await next.patchFile( - 'app/layout.js', - `export const runtime = 'edge'\n${originalLayout}` - ) - await next.start() - }) - afterAll(async () => { - await next.patchFile('app/layout.js', originalLayout) - }) - - runTests({ isEdge: true }) + it('should escalate forbidden to parent layout if no forbidden boundary present in current layer', async () => { + const browserDynamic = await next.browser( + '/dynamic-layout-without-forbidden' + ) + expect(await browserDynamic.elementByCss('h1').text()).toBe( + 'Dynamic with Layout' + ) + + // no forbidden boundary in /dynamic-layout-without-forbidden, escalate to parent layout to render root forbidden + const browserDynamicId = await next.browser( + '/dynamic-layout-without-forbidden/403' + ) + expect(await browserDynamicId.elementByCss('h1').text()).toBe( + 'Root Forbidden' + ) }) }) diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-not-found/[id]/page.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-not-found/[id]/page.js deleted file mode 100644 index 8638f5df50c1b..0000000000000 --- a/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-not-found/[id]/page.js +++ /dev/null @@ -1,16 +0,0 @@ -import { notFound } from 'next/navigation' - -// avoid static generation to fill the dynamic params -export const dynamic = 'force-dynamic' - -export default async function Page(props) { - const params = await props.params - - const { id } = params - - if (id === '404') { - notFound() - } - - return

{`dynamic-layout-without-not-found [id]`}

-} diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/[id]/page.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/[id]/page.js new file mode 100644 index 0000000000000..3e25b65a549d0 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/[id]/page.js @@ -0,0 +1,13 @@ +import { unauthorized } from 'next/navigation' + +export default async function Page(props) { + const params = await props.params + + const { id } = params + + if (id === '401') { + unauthorized() + } + + return

{`dynamic-layout-without-unauthorized [id]`}

+} diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-not-found/layout.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/layout.js similarity index 100% rename from test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-not-found/layout.js rename to test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/layout.js diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-not-found/page.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/page.js similarity index 100% rename from test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-not-found/page.js rename to test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/page.js diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/not-found.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/not-found.js deleted file mode 100644 index f3fab9c3db02c..0000000000000 --- a/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/not-found.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function NotFound() { - return
{`dynamic/[id] not found`}
-} diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/page.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/page.js index ec018524b925e..8d7218f10abd8 100644 --- a/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/page.js +++ b/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/page.js @@ -1,15 +1,12 @@ -import { notFound } from 'next/navigation' - -// avoid static generation to fill the dynamic params -export const dynamic = 'force-dynamic' +import { unauthorized } from 'next/navigation' export default async function Page(props) { const params = await props.params const { id } = params - if (id === '404') { - notFound() + if (id === '401') { + unauthorized() } return

{`dynamic [id]`}

diff --git a/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/unauthorized.js b/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/unauthorized.js new file mode 100644 index 0000000000000..7ffc5beaefbde --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/dynamic/[id]/unauthorized.js @@ -0,0 +1,3 @@ +export default function Unauthorized() { + return
{`dynamic/[id] unauthorized`}
+} diff --git a/test/e2e/app-dir/unauthorized/basic/app/not-found.js b/test/e2e/app-dir/unauthorized/basic/app/not-found.js deleted file mode 100644 index 6de39c97e7e2d..0000000000000 --- a/test/e2e/app-dir/unauthorized/basic/app/not-found.js +++ /dev/null @@ -1,11 +0,0 @@ -export default function NotFound() { - return ( - <> -

Root Not Found

- -
{Date.now()}
- - ) -} - -NotFound.displayName = 'NotFound' diff --git a/test/e2e/app-dir/unauthorized/basic/app/unauthorized.js b/test/e2e/app-dir/unauthorized/basic/app/unauthorized.js new file mode 100644 index 0000000000000..cb04c0c2df4fb --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/unauthorized.js @@ -0,0 +1,9 @@ +export default function Forbidden() { + return ( + <> +

Root Unauthorized

+ +
{Date.now()}
+ + ) +} diff --git a/test/e2e/app-dir/unauthorized/basic/basic.test.ts b/test/e2e/app-dir/unauthorized/basic/basic.test.ts index 4feaa70b746eb..f71a60b0c03a2 100644 --- a/test/e2e/app-dir/unauthorized/basic/basic.test.ts +++ b/test/e2e/app-dir/unauthorized/basic/basic.test.ts @@ -1,174 +1,41 @@ import { nextTestSetup } from 'e2e-utils' -import { check } from 'next-test-utils' -describe('app dir - not-found - basic', () => { - const { next, isNextDev, isNextStart, skipped } = nextTestSetup({ +describe('app dir - unauthorized - basic', () => { + const { next } = nextTestSetup({ files: __dirname, - skipDeployment: true, }) - if (skipped) { - return - } + it('should match dynamic route unauthorized boundary correctly', async () => { + // `/dynamic` display works + const browserDynamic = await next.browser('/dynamic') + expect(await browserDynamic.elementByCss('main').text()).toBe('dynamic') - it("should propagate notFound errors past a segment's error boundary", async () => { - let browser = await next.browser('/error-boundary') - await browser.elementByCss('button').click() - expect(await browser.elementByCss('h1').text()).toBe('Root Not Found') - - browser = await next.browser('/error-boundary/nested/nested-2') - await browser.elementByCss('button').click() - expect(await browser.elementByCss('h1').text()).toBe( - 'Not Found (error-boundary/nested)' + // `/dynamic/401` calling unauthorized() will match the same level unauthorized boundary + const browserError = await next.browser('/dynamic/401') + expect(await browserError.elementByCss('#unauthorized').text()).toBe( + 'dynamic/[id] unauthorized' ) - browser = await next.browser('/error-boundary/nested/trigger-not-found') - expect(await browser.elementByCss('h1').text()).toBe( - 'Not Found (error-boundary/nested)' + const browserDynamicId = await next.browser('/dynamic/123') + expect(await browserDynamicId.elementByCss('#page').text()).toBe( + 'dynamic [id]' ) }) - it('should return 404 status code for custom not-found page', async () => { - const res = await next.fetch('/_not-found') - expect(res.status).toBe(404) - }) - - if (isNextStart) { - it('should include not found client reference manifest in the file trace', async () => { - const fileTrace = JSON.parse( - await next.readFile('.next/server/app/_not-found/page.js.nft.json') - ) - - const isTraced = fileTrace.files.some((filePath) => - filePath.includes('page_client-reference-manifest.js') - ) - - expect(isTraced).toBe(true) - }) - - it('should not output /404 in tree view logs', async () => { - const output = await next.cliOutput - expect(output).not.toContain('○ /404') - }) - - it('should use root not-found content for 404 html', async () => { - // static /404 page will use /_not-found content - const page404 = await next.readFile('.next/server/pages/404.html') - expect(page404).toContain('Root Not Found') - }) - } - - const runTests = ({ isEdge }: { isEdge: boolean }) => { - it('should use the not-found page for non-matching routes', async () => { - const browser = await next.browser('/random-content') - expect(await browser.elementByCss('h1').text()).toContain( - 'Root Not Found' - ) - // should contain root layout content - expect(await browser.elementByCss('#layout-nav').text()).toBe('Navbar') - }) - - it('should match dynamic route not-found boundary correctly', async () => { - // `/dynamic` display works - const browserDynamic = await next.browser('/dynamic') - expect(await browserDynamic.elementByCss('main').text()).toBe('dynamic') - - // `/dynamic/404` calling notFound() will match the same level not-found boundary - const browserDynamic404 = await next.browser('/dynamic/404') - expect(await browserDynamic404.elementByCss('#not-found').text()).toBe( - 'dynamic/[id] not found' - ) - - const browserDynamicId = await next.browser('/dynamic/123') - expect(await browserDynamicId.elementByCss('#page').text()).toBe( - 'dynamic [id]' - ) - }) - - it('should escalate notFound to parent layout if no not-found boundary present in current layer', async () => { - const browserDynamic = await next.browser( - '/dynamic-layout-without-not-found' - ) - expect(await browserDynamic.elementByCss('h1').text()).toBe( - 'Dynamic with Layout' - ) - - // no not-found boundary in /dynamic-layout-without-not-found, escalate to parent layout to render root not-found - const browserDynamicId = await next.browser( - '/dynamic-layout-without-not-found/404' - ) - expect(await browserDynamicId.elementByCss('h1').text()).toBe( - 'Root Not Found' - ) - - const browserDynamic404 = await next.browser( - '/dynamic-layout-without-not-found/123' - ) - expect(await browserDynamic404.elementByCss('#page').text()).toBe( - 'dynamic-layout-without-not-found [id]' - ) - }) - - if (isNextDev) { - it('should not reload the page', async () => { - const browser = await next.browser('/random-content') - const timestamp = await browser.elementByCss('#timestamp').text() - - await new Promise((resolve) => { - setTimeout(resolve, 3000) - }) - - await check(async () => { - const newTimestamp = await browser.elementByCss('#timestamp').text() - return newTimestamp !== timestamp ? 'failure' : 'success' - }, 'success') - }) - - // Disabling for Edge because it is too flakey. - // @TODO investigate a proper for fix for this flake - if (!isEdge) { - it('should render the 404 page when the file is removed, and restore the page when re-added', async () => { - const browser = await next.browser('/') - await check(() => browser.elementByCss('h1').text(), 'My page') - await next.renameFile('./app/page.js', './app/foo.js') - await check(() => browser.elementByCss('h1').text(), 'Root Not Found') - await next.renameFile('./app/foo.js', './app/page.js') - await check(() => browser.elementByCss('h1').text(), 'My page') - }) - } - } - - if (!isNextDev && !isEdge) { - it('should create the 404 mapping and copy the file to pages', async () => { - const html = await next.readFile('.next/server/pages/404.html') - expect(html).toContain('Root Not Found') - expect( - await next.readFile('.next/server/pages-manifest.json') - ).toContain('"pages/404.html"') - }) - } - } - - describe('with default runtime', () => { - runTests({ isEdge: false }) - }) - - describe('with runtime = edge', () => { - let originalLayout = '' - - beforeAll(async () => { - await next.stop() - originalLayout = await next.readFile('app/layout.js') - await next.patchFile( - 'app/layout.js', - `export const runtime = 'edge'\n${originalLayout}` - ) - await next.start() - }) - afterAll(async () => { - await next.patchFile('app/layout.js', originalLayout) - }) + it('should escalate unauthorized to parent layout if no unauthorized boundary present in current layer', async () => { + const browserDynamic = await next.browser( + '/dynamic-layout-without-unauthorized' + ) + expect(await browserDynamic.elementByCss('h1').text()).toBe( + 'Dynamic with Layout' + ) - runTests({ isEdge: true }) + // no unauthorized boundary in /dynamic-layout-without-unauthorized, escalate to parent layout to render root unauthorized + const browserDynamicId = await next.browser( + '/dynamic-layout-without-unauthorized/401' + ) + expect(await browserDynamicId.elementByCss('h1').text()).toBe( + 'Root Unauthorized' + ) }) })