diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index 16c89ce73b35156..3153b835c7db558 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -1,7 +1,24 @@ import type webpack from 'webpack' +import type { ValueOf } from '../../../shared/lib/constants' import { NODE_RESOLVE_OPTIONS } from '../../webpack-config' import { getModuleBuildInfo } from './get-module-build-info' +export const FILE_TYPES = { + layout: 'layout', + template: 'template', + error: 'error', + loading: 'loading', +} as const + +// TODO-APP: check if this can be narrowed. +type ComponentModule = () => any +export type ComponentsType = { + readonly [componentKey in ValueOf]?: ComponentModule +} & { + readonly layoutOrPagePath?: string + readonly page?: ComponentModule +} + async function createTreeCodeFromPath({ pagePath, resolve, @@ -39,7 +56,7 @@ async function createTreeCodeFromPath({ const matchedPagePath = `${appDirPrefix}${parallelSegmentPath}` const resolvedPagePath = await resolve(matchedPagePath) // Use '' for segment as it's the page. There can't be a segment called '' so this is the safest way to add it. - props[parallelKey] = `['', {}, {filePath: ${JSON.stringify( + props[parallelKey] = `['', {}, {layoutOrPagePath: ${JSON.stringify( resolvedPagePath )}, page: () => require(${JSON.stringify(resolvedPagePath)})}]` continue @@ -50,31 +67,33 @@ async function createTreeCodeFromPath({ parallelSegment, ]) - // For segmentPath === '' avoid double `/` - const layoutPath = `${appDirPrefix}${parallelSegmentPath}/layout` - // For segmentPath === '' avoid double `/` - const loadingPath = `${appDirPrefix}${parallelSegmentPath}/loading` - - const resolvedLayoutPath = await resolve(layoutPath) - const resolvedLoadingPath = await resolve(loadingPath) + // `page` is not included here as it's added above. + const filePaths = await Promise.all( + Object.values(FILE_TYPES).map(async (file) => { + return [ + file, + await resolve(`${appDirPrefix}${parallelSegmentPath}/${file}`), + ] as const + }) + ) props[parallelKey] = `[ '${parallelSegment}', ${subtree}, { - filePath: ${JSON.stringify(resolvedLayoutPath)}, - ${ - resolvedLayoutPath - ? `layout: () => require(${JSON.stringify(resolvedLayoutPath)}),` - : '' - } - ${ - resolvedLoadingPath - ? `loading: () => require(${JSON.stringify( - resolvedLoadingPath - )}),` - : '' - } + ${filePaths + .filter(([, filePath]) => filePath !== undefined) + .map(([file, filePath]) => { + if (filePath === undefined) { + return '' + } + return `${ + file === FILE_TYPES.layout + ? `layoutOrPagePath: '${filePath}',` + : '' + }${file}: () => require(${JSON.stringify(filePath)}),` + }) + .join('\n')} } ]` } @@ -164,6 +183,7 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ export const AppRouter = require('next/dist/client/components/app-router.client.js').default export const LayoutRouter = require('next/dist/client/components/layout-router.client.js').default + export const RenderFromTemplateContext = require('next/dist/client/components/render-from-template-context.client.js').default export const HotReloader = ${ // Disable HotReloader component in production this.mode === 'development' diff --git a/packages/next/client/app-index.tsx b/packages/next/client/app-index.tsx index 4f148f9b74d49a4..a1631e60685a0a3 100644 --- a/packages/next/client/app-index.tsx +++ b/packages/next/client/app-index.tsx @@ -2,7 +2,6 @@ import '../build/polyfills/polyfill-module' // @ts-ignore react-dom/client exists when using React 18 import ReactDOMClient from 'react-dom/client' -// @ts-ignore startTransition exists when using React 18 import React from 'react' import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack' @@ -44,21 +43,6 @@ export const version = process.env.__NEXT_VERSION const appElement: HTMLElement | Document | null = document -let reactRoot: any = null - -function renderReactElement( - domEl: HTMLElement | Document, - fn: () => JSX.Element -): void { - const reactEl = fn() - if (!reactRoot) { - // Unlike with createRoot, you don't need a separate root.render() call here - reactRoot = (ReactDOMClient as any).hydrateRoot(domEl, reactEl) - } else { - reactRoot.render(reactEl) - } -} - const getCacheKey = () => { const { pathname, search } = location return pathname + search @@ -182,11 +166,19 @@ function RSCComponent(props: any) { } export function hydrate() { - renderReactElement(appElement!, () => ( + const reactEl = ( - )) + ) + + const isError = document.documentElement.id === '__next_error__' + const reactRoot = isError + ? (ReactDOMClient as any).createRoot(appElement) + : (ReactDOMClient as any).hydrateRoot(appElement, reactEl) + if (isError) { + reactRoot.render(reactEl) + } } diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index 63e7aa083c33228..197764187e727a8 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -12,6 +12,7 @@ import type { import { LayoutRouterContext, GlobalLayoutRouterContext, + TemplateContext, } from '../../shared/lib/app-router-context' import { fetchServerResponse } from './app-router.client' // import { matchSegment } from './match-segments' @@ -312,7 +313,7 @@ function LoadingBoundary({ }: { children: React.ReactNode loading?: React.ReactNode -}) { +}): JSX.Element { if (loading) { return {children} } @@ -320,6 +321,65 @@ function LoadingBoundary({ return <>{children} } +type ErrorComponent = React.ComponentType<{ error: Error; reset: () => void }> +interface ErrorBoundaryProps { + errorComponent: ErrorComponent +} + +/** + * Handles errors through `getDerivedStateFromError`. + * Renders the provided error component and provides a way to `reset` the error boundary state. + */ +class ErrorBoundaryHandler extends React.Component< + ErrorBoundaryProps, + { error: Error | null } +> { + constructor(props: ErrorBoundaryProps) { + super(props) + this.state = { error: null } + } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + if (this.state.error) { + return ( + + ) + } + + return this.props.children + } +} + +/** + * Renders error boundary with the provided "errorComponent" property as the fallback. + * If no "errorComponent" property is provided it renders the children without an error boundary. + */ +function ErrorBoundary({ + errorComponent, + children, +}: ErrorBoundaryProps & { children: React.ReactNode }): JSX.Element { + if (errorComponent) { + return ( + + {children} + + ) + } + + return <>{children} +} + /** * OuterLayoutRouter handles the current segment as well as rendering of other segments. * It can be rendered next to each other with a different `parallelRouterKey`, allowing for Parallel routes. @@ -328,12 +388,16 @@ export default function OuterLayoutRouter({ parallelRouterKey, segmentPath, childProp, + error, loading, + template, rootLayoutIncluded, }: { parallelRouterKey: string segmentPath: FlightSegmentPath childProp: ChildProp + error: ErrorComponent + template: React.ReactNode loading: React.ReactNode | undefined rootLayoutIncluded: boolean }) { @@ -371,23 +435,39 @@ export default function OuterLayoutRouter({ <> {preservedSegments.map((preservedSegment) => { return ( - // Loading boundary is render for each segment to ensure they have their own loading state. - // The loading boundary is passed to the router during rendering to ensure it can be immediately rendered when suspending on a Flight fetch. - - - + /* + - Error boundary + - Only renders error boundary if error component is provided. + - Rendered for each segment to ensure they have their own error state. + - Loading boundary + - Only renders suspense boundary if loading components is provided. + - Rendered for each segment to ensure they have their own loading state. + - Passed to the router during rendering to ensure it can be immediately rendered when suspending on a Flight fetch. + */ + + + + + + } + > + {template} + ) })} diff --git a/packages/next/client/components/render-from-template-context.client.tsx b/packages/next/client/components/render-from-template-context.client.tsx new file mode 100644 index 000000000000000..b3da594dca9988a --- /dev/null +++ b/packages/next/client/components/render-from-template-context.client.tsx @@ -0,0 +1,7 @@ +import React, { useContext } from 'react' +import { TemplateContext } from '../../shared/lib/app-router-context' + +export default function RenderFromTemplateContext(): JSX.Element { + const children = useContext(TemplateContext) + return <>{children} +} diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index b71191b4b720ac4..7fe57797fb4c157 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -26,6 +26,7 @@ import { FlightManifest, } from '../build/webpack/plugins/flight-manifest-plugin' import { FlushEffectsContext } from '../client/components/hooks-client' +import type { ComponentsType } from '../build/webpack/loaders/next-app-loader' // this needs to be required lazily so that `next-server` can set // the env before we require @@ -290,12 +291,7 @@ export type Segment = type LoaderTree = [ segment: string, parallelRoutes: { [parallelRouterKey: string]: LoaderTree }, - components: { - filePath: string - layout?: () => any - loading?: () => any - page?: () => any - } + components: ComponentsType ] /** @@ -524,6 +520,8 @@ export async function renderToHTMLOrFlight( const pageIsDynamic = isDynamicRoute(pathname) const LayoutRouter = ComponentMod.LayoutRouter as typeof import('../client/components/layout-router.client').default + const RenderFromTemplateContext = + ComponentMod.RenderFromTemplateContext as typeof import('../client/components/render-from-template-context.client').default const HotReloader = ComponentMod.HotReloader as | typeof import('../client/components/hot-reloader.client').default | null @@ -654,7 +652,11 @@ export async function renderToHTMLOrFlight( */ const createComponentTree = async ({ createSegmentPath, - loaderTree: [segment, parallelRoutes, { filePath, layout, loading, page }], + loaderTree: [ + segment, + parallelRoutes, + { layoutOrPagePath, layout, template, error, loading, page }, + ], parentParams, firstItem, rootLayoutIncluded, @@ -666,11 +668,17 @@ export async function renderToHTMLOrFlight( firstItem?: boolean }): Promise<{ Component: React.ComponentType }> => { // TODO-APP: enable stylesheet per layout/page - const stylesheets = getCssInlinedLinkTags( - serverComponentManifest, - serverCSSManifest!, - filePath - ) + const stylesheets: string[] = layoutOrPagePath + ? getCssInlinedLinkTags( + serverComponentManifest, + serverCSSManifest!, + layoutOrPagePath + ) + : [] + const Template = template + ? await interopDefault(template()) + : React.Fragment + const ErrorComponent = error ? await interopDefault(error()) : undefined const Loading = loading ? await interopDefault(loading()) : undefined const isLayout = typeof layout !== 'undefined' const isPage = typeof page !== 'undefined' @@ -746,6 +754,12 @@ export async function renderToHTMLOrFlight( parallelRouterKey={parallelRouteKey} segmentPath={createSegmentPath(currentSegmentPath)} loading={Loading ? : undefined} + error={ErrorComponent} + template={ + + } childProp={childProp} rootLayoutIncluded={rootLayoutIncludedAtThisLevelOrAbove} />, @@ -769,13 +783,21 @@ export async function renderToHTMLOrFlight( : childSegment, } + const segmentPath = createSegmentPath(currentSegmentPath) + // This is turned back into an object below. return [ parallelRouteKey, : undefined} + template={ + + } childProp={childProp} rootLayoutIncluded={rootLayoutIncludedAtThisLevelOrAbove} />, @@ -1142,29 +1164,61 @@ export async function renderToHTMLOrFlight( return flushed } - const renderStream = await renderToInitialStream({ - ReactDOMServer, - element: content, - streamOptions: { - nonce, - // Include hydration scripts in the HTML - bootstrapScripts: subresourceIntegrityManifest - ? buildManifest.rootMainFiles.map((src) => ({ - src: `${renderOpts.assetPrefix || ''}/_next/` + src, - integrity: subresourceIntegrityManifest[src], - })) - : buildManifest.rootMainFiles.map( - (src) => `${renderOpts.assetPrefix || ''}/_next/` + src - ), - }, - }) + try { + const renderStream = await renderToInitialStream({ + ReactDOMServer, + element: content, + streamOptions: { + nonce, + // Include hydration scripts in the HTML + bootstrapScripts: subresourceIntegrityManifest + ? buildManifest.rootMainFiles.map((src) => ({ + src: `${renderOpts.assetPrefix || ''}/_next/` + src, + integrity: subresourceIntegrityManifest[src], + })) + : buildManifest.rootMainFiles.map( + (src) => `${renderOpts.assetPrefix || ''}/_next/` + src + ), + }, + }) - return await continueFromInitialStream(renderStream, { - dataStream: serverComponentsInlinedTransformStream?.readable, - generateStaticHTML: generateStaticHTML, - flushEffectHandler, - flushEffectsToHead: true, - }) + return await continueFromInitialStream(renderStream, { + dataStream: serverComponentsInlinedTransformStream?.readable, + generateStaticHTML: generateStaticHTML, + flushEffectHandler, + flushEffectsToHead: true, + }) + } catch (err) { + // TODO-APP: show error overlay in development. `element` should probably be wrapped in AppRouter for this case. + const renderStream = await renderToInitialStream({ + ReactDOMServer, + element: ( + + + + + ), + streamOptions: { + nonce, + // Include hydration scripts in the HTML + bootstrapScripts: subresourceIntegrityManifest + ? buildManifest.rootMainFiles.map((src) => ({ + src: `${renderOpts.assetPrefix || ''}/_next/` + src, + integrity: subresourceIntegrityManifest[src], + })) + : buildManifest.rootMainFiles.map( + (src) => `${renderOpts.assetPrefix || ''}/_next/` + src + ), + }, + }) + + return await continueFromInitialStream(renderStream, { + dataStream: serverComponentsInlinedTransformStream?.readable, + generateStaticHTML: generateStaticHTML, + flushEffectHandler, + flushEffectsToHead: true, + }) + } } return new RenderResult(await bodyResult()) diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 74f73d1916e8fad..f9a36be37dc3f05 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -66,8 +66,11 @@ export const GlobalLayoutRouterContext = React.createContext<{ focusAndScrollRef: FocusAndScrollRef }>(null as any) +export const TemplateContext = React.createContext(null as any) + if (process.env.NODE_ENV !== 'production') { AppRouterContext.displayName = 'AppRouterContext' LayoutRouterContext.displayName = 'LayoutRouterContext' GlobalLayoutRouterContext.displayName = 'GlobalLayoutRouterContext' + TemplateContext.displayName = 'TemplateContext' } diff --git a/packages/next/shared/lib/constants.ts b/packages/next/shared/lib/constants.ts index f17d05ba0f83924..856c638eac756dd 100644 --- a/packages/next/shared/lib/constants.ts +++ b/packages/next/shared/lib/constants.ts @@ -1,4 +1,4 @@ -type ValueOf = Required[keyof T] +export type ValueOf = Required[keyof T] export const COMPILER_NAMES = { client: 'client', diff --git a/test/e2e/app-dir/app/app/error/clientcomponent/error.client.js b/test/e2e/app-dir/app/app/error/clientcomponent/error.client.js new file mode 100644 index 000000000000000..cc0c3b620bfd0ca --- /dev/null +++ b/test/e2e/app-dir/app/app/error/clientcomponent/error.client.js @@ -0,0 +1,10 @@ +export default function ErrorBoundary({ error, reset }) { + return ( + <> +

An error occurred: {error.message}

+ + + ) +} diff --git a/test/e2e/app-dir/app/app/error/clientcomponent/page.client.js b/test/e2e/app-dir/app/app/error/clientcomponent/page.client.js new file mode 100644 index 000000000000000..8c3fe1a72901c1d --- /dev/null +++ b/test/e2e/app-dir/app/app/error/clientcomponent/page.client.js @@ -0,0 +1,18 @@ +import { useState } from 'react' + +export default function Page() { + const [clicked, setClicked] = useState(false) + if (clicked) { + throw new Error('this is a test') + } + return ( + + ) +} diff --git a/test/e2e/app-dir/app/app/error/ssr-error-client-component/page.client.js b/test/e2e/app-dir/app/app/error/ssr-error-client-component/page.client.js new file mode 100644 index 000000000000000..1cca8f6810c8a0b --- /dev/null +++ b/test/e2e/app-dir/app/app/error/ssr-error-client-component/page.client.js @@ -0,0 +1,3 @@ +export default function Page() { + throw new Error('Error during SSR') +} diff --git a/test/e2e/app-dir/app/app/template/clientcomponent/other/page.server.js b/test/e2e/app-dir/app/app/template/clientcomponent/other/page.server.js new file mode 100644 index 000000000000000..24c81bb71930dfe --- /dev/null +++ b/test/e2e/app-dir/app/app/template/clientcomponent/other/page.server.js @@ -0,0 +1,11 @@ +import Link from 'next/link' +export default function Page() { + return ( + <> +

Other Page

+ + To Page + + + ) +} diff --git a/test/e2e/app-dir/app/app/template/clientcomponent/page.server.js b/test/e2e/app-dir/app/app/template/clientcomponent/page.server.js new file mode 100644 index 000000000000000..89378c7e29f176b --- /dev/null +++ b/test/e2e/app-dir/app/app/template/clientcomponent/page.server.js @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +

Page

+ + To Other + + + ) +} diff --git a/test/e2e/app-dir/app/app/template/clientcomponent/template.client.js b/test/e2e/app-dir/app/app/template/clientcomponent/template.client.js new file mode 100644 index 000000000000000..3f9960433a4e2aa --- /dev/null +++ b/test/e2e/app-dir/app/app/template/clientcomponent/template.client.js @@ -0,0 +1,12 @@ +import { useState } from 'react' + +export default function Template({ children }) { + const [count, setCount] = useState(0) + return ( + <> +

Template {count}

+ + {children} + + ) +} diff --git a/test/e2e/app-dir/app/app/template/servercomponent/other/page.server.js b/test/e2e/app-dir/app/app/template/servercomponent/other/page.server.js new file mode 100644 index 000000000000000..2ed3a0fa15cef91 --- /dev/null +++ b/test/e2e/app-dir/app/app/template/servercomponent/other/page.server.js @@ -0,0 +1,11 @@ +import Link from 'next/link' +export default function Page() { + return ( + <> +

Other Page

+ + To Page + + + ) +} diff --git a/test/e2e/app-dir/app/app/template/servercomponent/page.server.js b/test/e2e/app-dir/app/app/template/servercomponent/page.server.js new file mode 100644 index 000000000000000..27a7097ccd650ca --- /dev/null +++ b/test/e2e/app-dir/app/app/template/servercomponent/page.server.js @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +

Page

+ + To Other + + + ) +} diff --git a/test/e2e/app-dir/app/app/template/servercomponent/template.server.js b/test/e2e/app-dir/app/app/template/servercomponent/template.server.js new file mode 100644 index 000000000000000..c3dc5dadb9255fb --- /dev/null +++ b/test/e2e/app-dir/app/app/template/servercomponent/template.server.js @@ -0,0 +1,10 @@ +export default function Template({ children }) { + return ( + <> +

+ Template {performance.now()} +

+ {children} + + ) +} diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index e92332aa0c8ca25..4f98910d63c3a7f 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1317,6 +1317,106 @@ describe('app dir', () => { expect(res.status).toBe(500) }) }) + + describe('template component', () => { + it('should render the template that holds state in a client component and reset on navigation', async () => { + const browser = await webdriver(next.url, '/template/clientcomponent') + expect(await browser.elementByCss('h1').text()).toBe('Template 0') + await browser.elementByCss('button').click() + expect(await browser.elementByCss('h1').text()).toBe('Template 1') + + await browser.elementByCss('#link').click() + await browser.waitForElementByCss('#other-page') + + expect(await browser.elementByCss('h1').text()).toBe('Template 0') + await browser.elementByCss('button').click() + expect(await browser.elementByCss('h1').text()).toBe('Template 1') + + await browser.elementByCss('#link').click() + await browser.waitForElementByCss('#page') + + expect(await browser.elementByCss('h1').text()).toBe('Template 0') + }) + + it('should render the template that is a server component and rerender on navigation', async () => { + const browser = await webdriver(next.url, '/template/servercomponent') + expect(await browser.elementByCss('h1').text()).toStartWith('Template') + + const currentTime = await browser + .elementByCss('#performance-now') + .text() + + await browser.elementByCss('#link').click() + await browser.waitForElementByCss('#other-page') + + expect(await browser.elementByCss('h1').text()).toStartWith('Template') + + // template should rerender on navigation even when it's a server component + expect(await browser.elementByCss('#performance-now').text()).toBe( + currentTime + ) + + await browser.elementByCss('#link').click() + await browser.waitForElementByCss('#page') + + expect(await browser.elementByCss('#performance-now').text()).toBe( + currentTime + ) + }) + }) + + // TODO-APP: This is disabled for development as the error overlay needs to be reworked. + ;(isDev ? describe.skip : describe)('error component', () => { + it('should trigger error component when an error happens during rendering', async () => { + const browser = await webdriver(next.url, '/error/clientcomponent') + await browser + .elementByCss('#error-trigger-button') + .click() + .waitForElementByCss('#error-boundary-message') + + expect( + await browser.elementByCss('#error-boundary-message').text() + ).toBe('An error occurred: this is a test') + }) + + it('should allow resetting error boundary', async () => { + const browser = await webdriver(next.url, '/error/clientcomponent') + + // Try triggering and resetting a few times in a row + for (let i = 0; i < 5; i++) { + await browser + .elementByCss('#error-trigger-button') + .click() + .waitForElementByCss('#error-boundary-message') + + expect( + await browser.elementByCss('#error-boundary-message').text() + ).toBe('An error occurred: this is a test') + + await browser + .elementByCss('#reset') + .click() + .waitForElementByCss('#error-trigger-button') + + expect( + await browser.elementByCss('#error-trigger-button').text() + ).toBe('Trigger Error!') + } + }) + + it('should hydrate empty shell to handle server-side rendering errors', async () => { + const browser = await webdriver( + next.url, + '/error/ssr-error-client-component' + ) + const logs = await browser.log() + const errors = logs + .filter((x) => x.source === 'error') + .map((x) => x.message) + .join('\n') + expect(errors).toInclude('Error during SSR') + }) + }) } describe('without assetPrefix', () => {