Skip to content

Commit

Permalink
Add template and error file types (#39808)
Browse files Browse the repository at this point in the history
Co-authored-by: Jiachi Liu <inbox@huozhi.im>
  • Loading branch information
timneutkens and huozhi committed Sep 9, 2022
1 parent 9ab5c01 commit a3a20dc
Show file tree
Hide file tree
Showing 17 changed files with 448 additions and 93 deletions.
62 changes: 41 additions & 21 deletions 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<typeof FILE_TYPES>]?: ComponentModule
} & {
readonly layoutOrPagePath?: string
readonly page?: ComponentModule
}

async function createTreeCodeFromPath({
pagePath,
resolve,
Expand Down Expand Up @@ -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
Expand All @@ -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')}
}
]`
}
Expand Down Expand Up @@ -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'
Expand Down
28 changes: 10 additions & 18 deletions packages/next/client/app-index.tsx
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -182,11 +166,19 @@ function RSCComponent(props: any) {
}

export function hydrate() {
renderReactElement(appElement!, () => (
const reactEl = (
<React.StrictMode>
<Root>
<RSCComponent />
</Root>
</React.StrictMode>
))
)

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)
}
}
116 changes: 98 additions & 18 deletions packages/next/client/components/layout-router.client.tsx
Expand Up @@ -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'
Expand Down Expand Up @@ -312,14 +313,73 @@ function LoadingBoundary({
}: {
children: React.ReactNode
loading?: React.ReactNode
}) {
}): JSX.Element {
if (loading) {
return <React.Suspense fallback={loading}>{children}</React.Suspense>
}

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 (
<this.props.errorComponent
error={this.state.error}
reset={this.reset}
/>
)
}

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 (
<ErrorBoundaryHandler errorComponent={errorComponent}>
{children}
</ErrorBoundaryHandler>
)
}

return <>{children}</>
}

/**
* OuterLayoutRouter handles the current segment as well as <Offscreen> rendering of other segments.
* It can be rendered next to each other with a different `parallelRouterKey`, allowing for Parallel routes.
Expand All @@ -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
}) {
Expand Down Expand Up @@ -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.
<LoadingBoundary loading={loading} key={preservedSegment}>
<InnerLayoutRouter
parallelRouterKey={parallelRouterKey}
url={url}
tree={tree}
childNodes={childNodesForParallelRouter!}
childProp={
childPropSegment === preservedSegment ? childProp : null
}
segmentPath={segmentPath}
path={preservedSegment}
isActive={currentChildSegment === preservedSegment}
rootLayoutIncluded={rootLayoutIncluded}
/>
</LoadingBoundary>
/*
- 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.
*/
<TemplateContext.Provider
key={preservedSegment}
value={
<ErrorBoundary errorComponent={error}>
<LoadingBoundary loading={loading}>
<InnerLayoutRouter
parallelRouterKey={parallelRouterKey}
url={url}
tree={tree}
childNodes={childNodesForParallelRouter!}
childProp={
childPropSegment === preservedSegment ? childProp : null
}
segmentPath={segmentPath}
path={preservedSegment}
isActive={currentChildSegment === preservedSegment}
rootLayoutIncluded={rootLayoutIncluded}
/>
</LoadingBoundary>
</ErrorBoundary>
}
>
{template}
</TemplateContext.Provider>
)
})}
</>
Expand Down
@@ -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}</>
}

0 comments on commit a3a20dc

Please sign in to comment.