From 206798cda0f28a29d4a36960d0610a0b9d74504e Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 22 Aug 2022 12:05:06 +0200 Subject: [PATCH 01/23] Add template and error file types --- .../build/webpack/loaders/next-app-loader.ts | 57 +++++++++++++------ packages/next/server/app-render.tsx | 48 +++++++++------- packages/next/shared/lib/constants.ts | 2 +- 3 files changed, 69 insertions(+), 38 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index bf7a405e191336f..142782c04250235 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 { 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, @@ -32,13 +49,15 @@ async function createTreeCodeFromPath({ continue } - // For segmentPath === '' avoid double `/` - const layoutPath = `${appDirPrefix}${segmentPath}/layout` - // For segmentPath === '' avoid double `/` - const loadingPath = `${appDirPrefix}${segmentPath}/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}${segmentPath}/${file}`), + ] as const + }) + ) // Existing tree are the children of the current segment const children = tree @@ -49,17 +68,17 @@ async function createTreeCodeFromPath({ children ? `children: ${children},` : '' } }, { - filePath: '${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')} }]` } @@ -123,6 +142,8 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ removeExt: (p) => removeExtensions(extensions, p), }) + console.log(treeCode) + const result = ` export ${treeCode} diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 2c221e3e75e7d94..bd364ed50d15c40 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -27,6 +27,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 @@ -284,12 +285,7 @@ export type Segment = type LoaderTree = [ segment: string, parallelRoutes: { [parallelRouterKey: string]: LoaderTree }, - components: { - filePath: string - layout?: () => any - loading?: () => any - page?: () => any - } + components: ComponentsType ] /** @@ -589,7 +585,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, @@ -601,11 +601,15 @@ 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()) : undefined + const ErrorComponent = error ? await interopDefault(error()) : undefined const Loading = loading ? await interopDefault(loading()) : undefined const isLayout = typeof layout !== 'undefined' const isPage = typeof page !== 'undefined' @@ -695,16 +699,22 @@ export async function renderToHTMLOrFlight( : childSegment, } + const segmentPath = createSegmentPath(currentSegmentPath) + // This is turned back into an object below. return [ parallelRouteKey, - : undefined} - childProp={childProp} - rootLayoutIncluded={rootLayoutIncludedAtThisLevelOrAbove} - />, + , ] } ) diff --git a/packages/next/shared/lib/constants.ts b/packages/next/shared/lib/constants.ts index c314f8517b5b653..f39c3f47d276edf 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', From f68d056c92cabcbdd1566548398cec5613344442 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 22 Aug 2022 12:07:42 +0200 Subject: [PATCH 02/23] Add fallback for template --- packages/next/server/app-render.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index bd364ed50d15c40..558323ebcd4511a 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -608,8 +608,10 @@ export async function renderToHTMLOrFlight( layoutOrPagePath ) : [] - const Template = template ? await interopDefault(template()) : undefined - const ErrorComponent = error ? await interopDefault(error()) : undefined + 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' From 44fed5b000256f12d050e30b74c933154e0876fc Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 22 Aug 2022 12:07:59 +0200 Subject: [PATCH 03/23] Remove log --- packages/next/build/webpack/loaders/next-app-loader.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index 142782c04250235..da9896948599aed 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -142,8 +142,6 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ removeExt: (p) => removeExtensions(extensions, p), }) - console.log(treeCode) - const result = ` export ${treeCode} From 85d0db246d18f211a0018c45341ca19878ff73de Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 22 Aug 2022 13:38:45 +0200 Subject: [PATCH 04/23] Handle error boundary --- .../components/layout-router.client.tsx | 98 +++++++++++++++---- packages/next/server/app-render.tsx | 5 +- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index bffb974c706e8d0..1a5d14db03a28f8 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -303,7 +303,7 @@ function LoadingBoundary({ }: { children: React.ReactNode loading?: React.ReactNode -}) { +}): JSX.Element { if (loading) { return {children} } @@ -311,6 +311,57 @@ function LoadingBoundary({ return <>{children} } +type ErrorComponent = React.ComponentType<{ error: Error; reset: () => void }> + +interface ErrorBoundaryProps { + errorComponent: ErrorComponent +} +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 + } +} + +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. @@ -319,12 +370,14 @@ export default function OuterLayoutRouter({ parallelRouterKey, segmentPath, childProp, + error, loading, rootLayoutIncluded, }: { parallelRouterKey: string segmentPath: FlightSegmentPath childProp: ChildProp + error: ErrorComponent loading: React.ReactNode | undefined rootLayoutIncluded: boolean }) { @@ -362,23 +415,32 @@ 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. + */ + + + + + ) })} diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 558323ebcd4511a..cb304e290eb65de 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -611,7 +611,7 @@ export async function renderToHTMLOrFlight( const Template = template ? await interopDefault(template()) : React.Fragment - // const ErrorComponent = error ? await interopDefault(error()) : undefined + const ErrorComponent = error ? await interopDefault(error()) : undefined const Loading = loading ? await interopDefault(loading()) : undefined const isLayout = typeof layout !== 'undefined' const isPage = typeof page !== 'undefined' @@ -707,15 +707,14 @@ export async function renderToHTMLOrFlight( return [ parallelRouteKey, , ] } From cdfb6883541b78b2ea5067b521f9430b74de4993 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 22 Aug 2022 13:41:51 +0200 Subject: [PATCH 05/23] Add comments to error components --- .../next/client/components/layout-router.client.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index 1a5d14db03a28f8..33820e16e64127a 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -312,10 +312,14 @@ function LoadingBoundary({ } 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 } @@ -347,6 +351,10 @@ class ErrorBoundaryHandler extends React.Component< } } +/** + * 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, From 2c859cf332f14b33340ab970e4dc75f9d5cc0b34 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 22 Aug 2022 17:19:13 +0200 Subject: [PATCH 06/23] Move error thrown in rendering to client-side rendering to match Suspense behavior --- packages/next/server/app-render.tsx | 56 ++++++++++++++----- .../app/app/dashboard/client-comp.client.jsx | 1 + 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index cb304e290eb65de..6dbe5782794c518 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -1069,23 +1069,49 @@ export async function renderToHTMLOrFlight( return flushed } - const renderStream = await renderToInitialStream({ - ReactDOMServer, - element: content, - streamOptions: { - // Include hydration scripts in the HTML - bootstrapScripts: buildManifest.rootMainFiles.map( - (src) => `${renderOpts.assetPrefix || ''}/_next/` + src + try { + const renderStream = await renderToInitialStream({ + ReactDOMServer, + element: content, + streamOptions: { + // Include hydration scripts in the HTML + bootstrapScripts: buildManifest.rootMainFiles.map( + (src) => `${renderOpts.assetPrefix || ''}/_next/` + src + ), + }, + }) + + 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: { + // Include hydration scripts in the HTML + bootstrapScripts: 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, + }) + } } return new RenderResult(await bodyResult()) diff --git a/test/e2e/app-dir/app/app/dashboard/client-comp.client.jsx b/test/e2e/app-dir/app/app/dashboard/client-comp.client.jsx index cabed435eada1ee..e5c79bb5dcdc7fe 100644 --- a/test/e2e/app-dir/app/app/dashboard/client-comp.client.jsx +++ b/test/e2e/app-dir/app/app/dashboard/client-comp.client.jsx @@ -3,6 +3,7 @@ import styles from './client-comp.module.css' import { useEffect, useState } from 'react' export default function ClientComp() { + throw new Error('test') const [state, setState] = useState({}) useEffect(() => { setState({ test: 'HELLOOOO' }) From 7403eb13845fd2469fc131b6577acddffa912861 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 22 Aug 2022 17:32:22 +0200 Subject: [PATCH 07/23] Render error document instead of hydrating to avoid hydration errors. --- packages/next/client/app-index.tsx | 27 ++++++++++----------------- packages/next/server/app-render.tsx | 2 +- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/next/client/app-index.tsx b/packages/next/client/app-index.tsx index 9d0ec8accb6fac3..297aef0fc07e050 100644 --- a/packages/next/client/app-index.tsx +++ b/packages/next/client/app-index.tsx @@ -56,21 +56,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 @@ -194,11 +179,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/server/app-render.tsx b/packages/next/server/app-render.tsx index 6dbe5782794c518..04874d97952babf 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -1092,7 +1092,7 @@ export async function renderToHTMLOrFlight( const renderStream = await renderToInitialStream({ ReactDOMServer, element: ( - + From 842793636cf31c9dceca5ca5f5a060c5a409bba4 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 22 Aug 2022 17:34:11 +0200 Subject: [PATCH 08/23] Remove throw --- test/e2e/app-dir/app/app/dashboard/client-comp.client.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/test/e2e/app-dir/app/app/dashboard/client-comp.client.jsx b/test/e2e/app-dir/app/app/dashboard/client-comp.client.jsx index e5c79bb5dcdc7fe..cabed435eada1ee 100644 --- a/test/e2e/app-dir/app/app/dashboard/client-comp.client.jsx +++ b/test/e2e/app-dir/app/app/dashboard/client-comp.client.jsx @@ -3,7 +3,6 @@ import styles from './client-comp.module.css' import { useEffect, useState } from 'react' export default function ClientComp() { - throw new Error('test') const [state, setState] = useState({}) useEffect(() => { setState({ test: 'HELLOOOO' }) From bddbc441ce60548d87b2dce2f500894cd03e5e81 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 23 Aug 2022 09:45:22 +0200 Subject: [PATCH 09/23] Remove ts-ignore for startTransition as it's no longer used here. --- packages/next/client/app-index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/next/client/app-index.tsx b/packages/next/client/app-index.tsx index 297aef0fc07e050..cb1ea5d8aff1fda 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' From ce6f67cd6320e323f962d74f1c790d96e3e594bf Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 23 Aug 2022 11:45:25 +0200 Subject: [PATCH 10/23] Invert handling of template.js --- .../build/webpack/loaders/next-app-loader.ts | 1 + .../components/layout-router.client.tsx | 39 ++++++++++++------- .../render-from-template-context.client.tsx | 7 ++++ packages/next/server/app-render.tsx | 25 +++++++----- .../next/shared/lib/app-router-context.ts | 3 ++ 5 files changed, 50 insertions(+), 25 deletions(-) create mode 100644 packages/next/client/components/render-from-template-context.client.tsx diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index da9896948599aed..81236ad655f3b77 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -147,6 +147,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/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index 33820e16e64127a..ef7fa780a399887 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -9,6 +9,7 @@ import type { import { LayoutRouterContext, GlobalLayoutRouterContext, + TemplateContext, } from '../../shared/lib/app-router-context' import { fetchServerResponse } from './app-router.client' import { matchSegment } from './match-segments' @@ -380,12 +381,14 @@ export default function OuterLayoutRouter({ childProp, error, loading, + template, rootLayoutIncluded, }: { parallelRouterKey: string segmentPath: FlightSegmentPath childProp: ChildProp error: ErrorComponent + template: React.ReactNode loading: React.ReactNode | undefined rootLayoutIncluded: boolean }) { @@ -433,21 +436,27 @@ export default function OuterLayoutRouter({ - 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..1a49f1d16478b90 --- /dev/null +++ b/packages/next/client/components/render-from-template-context.client.tsx @@ -0,0 +1,7 @@ +import { useContext } from 'react' +import { TemplateContext } from '../../shared/lib/app-router-context' + +export default function RenderFromTemplateContext() { + const children = useContext(TemplateContext) + return children +} diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 04874d97952babf..a9054869b567e78 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -455,6 +455,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 @@ -706,16 +708,19 @@ export async function renderToHTMLOrFlight( // This is turned back into an object below. return [ parallelRouteKey, - , + : undefined} + template={ + + } + childProp={childProp} + rootLayoutIncluded={rootLayoutIncludedAtThisLevelOrAbove} + />, ] } ) diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 06c78f84e797f70..3bd20c9036aa2b6 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -72,8 +72,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' } From 1a1a7ba1ccdcb8808bf5de04f86aea555fe39a81 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 23 Aug 2022 12:09:27 +0200 Subject: [PATCH 11/23] Fix type --- .../components/render-from-template-context.client.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/next/client/components/render-from-template-context.client.tsx b/packages/next/client/components/render-from-template-context.client.tsx index 1a49f1d16478b90..b3da594dca9988a 100644 --- a/packages/next/client/components/render-from-template-context.client.tsx +++ b/packages/next/client/components/render-from-template-context.client.tsx @@ -1,7 +1,7 @@ -import { useContext } from 'react' +import React, { useContext } from 'react' import { TemplateContext } from '../../shared/lib/app-router-context' -export default function RenderFromTemplateContext() { +export default function RenderFromTemplateContext(): JSX.Element { const children = useContext(TemplateContext) - return children + return <>{children} } From 74443d6a57af209cca3c4e8ada9ecaef37cad2cd Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 7 Sep 2022 14:23:09 +0200 Subject: [PATCH 12/23] Apply changes again --- .../build/webpack/loaders/next-app-loader.ts | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index 6c6321770280bbc..cab673713a21859 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -67,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}${segmentPath}/${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')} } ]` } From ca7a1117e858b0fbb0b78782a15ad05bdfaf27b9 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 7 Sep 2022 15:11:48 +0200 Subject: [PATCH 13/23] Fix type error --- packages/next/server/app-render.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 2fe21e517829a93..681a3ba83939a39 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -696,6 +696,12 @@ export async function renderToHTMLOrFlight( parallelRouterKey={parallelRouteKey} segmentPath={createSegmentPath(currentSegmentPath)} loading={Loading ? : undefined} + error={ErrorComponent} + template={ + + } childProp={childProp} rootLayoutIncluded={rootLayoutIncludedAtThisLevelOrAbove} />, From 3492a2a31bac11f7a59578a61d8c762ac93efb5f Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 7 Sep 2022 15:43:10 +0200 Subject: [PATCH 14/23] Use new segment path --- packages/next/build/webpack/loaders/next-app-loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index cab673713a21859..86218ec5fda7226 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -72,7 +72,7 @@ async function createTreeCodeFromPath({ Object.values(FILE_TYPES).map(async (file) => { return [ file, - await resolve(`${appDirPrefix}${segmentPath}/${file}`), + await resolve(`${appDirPrefix}${parallelSegmentPath}/${file}`), ] as const }) ) From 430b5bc092692c9da29dc19b456ea487322130cf Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 7 Sep 2022 20:34:05 +0200 Subject: [PATCH 15/23] Add test for template.client.js --- .../clientcomponent/other/page.server.js | 11 ++++++++++ .../template/clientcomponent/page.server.js | 12 +++++++++++ .../clientcomponent/template.client.js | 12 +++++++++++ test/e2e/app-dir/index.test.ts | 21 +++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 test/e2e/app-dir/app/app/template/clientcomponent/other/page.server.js create mode 100644 test/e2e/app-dir/app/app/template/clientcomponent/page.server.js create mode 100644 test/e2e/app-dir/app/app/template/clientcomponent/template.client.js 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/index.test.ts b/test/e2e/app-dir/index.test.ts index 6ff89b27588809b..d4195757ab171ec 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1194,6 +1194,27 @@ describe('app dir', () => { }) }) }) + + 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') + }) + }) } describe('without assetPrefix', () => { From eeba0f371fb6aefca9e9250a71ffbf031e13fc46 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 8 Sep 2022 12:02:24 +0200 Subject: [PATCH 16/23] Add test for server component template.js --- .../components/layout-router.client.tsx | 17 ++++++------ .../servercomponent/other/page.server.js | 11 ++++++++ .../template/servercomponent/page.server.js | 12 +++++++++ .../servercomponent/template.server.js | 10 +++++++ test/e2e/app-dir/index.test.ts | 26 +++++++++++++++++++ 5 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 test/e2e/app-dir/app/app/template/servercomponent/other/page.server.js create mode 100644 test/e2e/app-dir/app/app/template/servercomponent/page.server.js create mode 100644 test/e2e/app-dir/app/app/template/servercomponent/template.server.js diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index 883287b6b5e7b5f..197764187e727a8 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -444,9 +444,10 @@ export default function OuterLayoutRouter({ - 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} - - + + } + > + {template} + ) })} 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 d4195757ab171ec..967f44a1f613ec0 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1214,6 +1214,32 @@ describe('app dir', () => { 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 + ) + }) }) } From 65c117629ad4d181ac170a3bcd20d5c4fa3a6221 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 8 Sep 2022 13:36:24 +0200 Subject: [PATCH 17/23] Add test for error.js --- .../app/error/clientcomponent/error.client.js | 10 +++++ .../app/error/clientcomponent/page.client.js | 18 +++++++++ test/e2e/app-dir/index.test.ts | 39 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 test/e2e/app-dir/app/app/error/clientcomponent/error.client.js create mode 100644 test/e2e/app-dir/app/app/error/clientcomponent/page.client.js 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/index.test.ts b/test/e2e/app-dir/index.test.ts index 967f44a1f613ec0..7f328ecad182722 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1241,6 +1241,45 @@ describe('app dir', () => { ) }) }) + + 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!') + } + }) + }) } describe('without assetPrefix', () => { From c8cbe9165a61a42a80f411db8b7d6c282b6410fd Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 8 Sep 2022 13:43:34 +0200 Subject: [PATCH 18/23] Fix broken path --- packages/next/build/webpack/loaders/next-app-loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index 86218ec5fda7226..f99b96b4bb400cb 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -56,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 From 20a6b5b45457f38782ab551ed48841673bdf13d7 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 8 Sep 2022 14:12:18 +0200 Subject: [PATCH 19/23] Skip error component tests in development --- test/e2e/app-dir/index.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index 7f328ecad182722..6399ab20cf88c17 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1242,7 +1242,8 @@ describe('app dir', () => { }) }) - describe('error component', () => { + // 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 From aacb1df138dd08495107cc9a2f5e6cbea1d06c2d Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 8 Sep 2022 14:21:56 +0200 Subject: [PATCH 20/23] Add test for hydrating ssr error --- .../error/ssr-error-client-component/page.client.js | 3 +++ test/e2e/app-dir/index.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 test/e2e/app-dir/app/app/error/ssr-error-client-component/page.client.js 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/index.test.ts b/test/e2e/app-dir/index.test.ts index 6399ab20cf88c17..1413f3770128401 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1280,6 +1280,19 @@ describe('app dir', () => { ).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') + }) }) } From a9be299d591c7be8403096bd7b14b21d6af0a3a5 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 8 Sep 2022 17:16:35 +0200 Subject: [PATCH 21/23] Update packages/next/build/webpack/loaders/next-app-loader.ts Co-authored-by: Jiachi Liu --- packages/next/build/webpack/loaders/next-app-loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index f99b96b4bb400cb..3153b835c7db558 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -1,5 +1,5 @@ import type webpack from 'webpack' -import { ValueOf } from '../../../shared/lib/constants' +import type { ValueOf } from '../../../shared/lib/constants' import { NODE_RESOLVE_OPTIONS } from '../../webpack-config' import { getModuleBuildInfo } from './get-module-build-info' From 68bb2c8a3638138b35a07baa2a2484b0480d554e Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Fri, 9 Sep 2022 09:50:33 +0200 Subject: [PATCH 22/23] Add sri handling for error fallback page --- packages/next/server/app-render.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index de6f0b3c230fc92..2451093ed227856 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -1200,9 +1200,14 @@ export async function renderToHTMLOrFlight( ), streamOptions: { // Include hydration scripts in the HTML - bootstrapScripts: buildManifest.rootMainFiles.map( - (src) => `${renderOpts.assetPrefix || ''}/_next/` + src - ), + bootstrapScripts: subresourceIntegrityManifest + ? buildManifest.rootMainFiles.map((src) => ({ + src: `${renderOpts.assetPrefix || ''}/_next/` + src, + integrity: subresourceIntegrityManifest[src], + })) + : buildManifest.rootMainFiles.map( + (src) => `${renderOpts.assetPrefix || ''}/_next/` + src + ), }, }) From 033bcb69202405ac0150aaef420c3d0452474f5c Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Fri, 9 Sep 2022 10:05:40 +0200 Subject: [PATCH 23/23] Add nonce to error fallback --- packages/next/server/app-render.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 2451093ed227856..7fe57797fb4c157 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -1199,6 +1199,7 @@ export async function renderToHTMLOrFlight( ), streamOptions: { + nonce, // Include hydration scripts in the HTML bootstrapScripts: subresourceIntegrityManifest ? buildManifest.rootMainFiles.map((src) => ({