Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update root component handling #36781

Merged
merged 5 commits into from May 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
28 changes: 22 additions & 6 deletions packages/next/build/webpack/loaders/next-view-loader.ts
Expand Up @@ -22,18 +22,34 @@ async function resolveLayoutPathsByPage({
}) {
const layoutPaths = new Map<string, string | undefined>()
const parts = pagePath.split('/')
const isNewRootLayout =
parts[1]?.length > 2 && parts[1]?.startsWith('(') && parts[1]?.endsWith(')')

for (let i = 1; i < parts.length; i++) {
for (let i = parts.length; i >= 0; i--) {
const pathWithoutSlashLayout = parts.slice(0, i).join('/')
const layoutPath = `${pathWithoutSlashLayout}/layout`

const resolvedLayoutPath = await resolve(layoutPath)

if (!pathWithoutSlashLayout) {
continue
}
const layoutPath = `${pathWithoutSlashLayout}/layout`
let resolvedLayoutPath = await resolve(layoutPath)
let urlPath = pathToUrlPath(pathWithoutSlashLayout)

// if we are in a new root views/(root) and a custom root layout was
// not provided or a root layout views/layout is not present, we use
// a default root layout to provide the html/body tags
const isCustomRootLayout = isNewRootLayout && i === 2

if ((isCustomRootLayout || i === 1) && !resolvedLayoutPath) {
resolvedLayoutPath = await resolve('next/dist/lib/views-layout')
}
layoutPaths.set(urlPath, resolvedLayoutPath)
}

// if we're in a new root layout don't add the top-level view/layout
if (isCustomRootLayout) {
break
}
}
return layoutPaths
}

Expand Down Expand Up @@ -84,7 +100,7 @@ const nextViewLoader: webpack.LoaderDefinitionFunction<{
// Add page itself to the list of components
componentsCode.push(
`'${pathToUrlPath(pagePath).replace(
new RegExp(`/page\\.+(${extensions.join('|')})$`),
new RegExp(`/page+(${extensions.join('|')})$`),
''
// use require so that we can bust the require cache
)}': () => require('${pagePath}')`
Expand Down
80 changes: 43 additions & 37 deletions packages/next/server/view-render.tsx
Expand Up @@ -19,7 +19,6 @@ import {
import { FlushEffectsContext } from '../shared/lib/flush-effects'
import { isDynamicRoute } from '../shared/lib/router/utils'
import { tryGetPreviewData } from './api-utils/node'
import DefaultRootLayout from '../lib/views-layout'

const ReactDOMServer = process.env.__NEXT_REACT_ROOT
? require('react-dom/server.browser')
Expand Down Expand Up @@ -218,10 +217,13 @@ export async function renderToHTML(

const isFlight = query.__flight__ !== undefined
const flightRouterPath = isFlight ? query.__flight_router_path__ : undefined
delete query.__flight__
delete query.__flight_router_path__

const hasConcurrentFeatures = !!runtime
const pageIsDynamic = isDynamicRoute(pathname)
const components = Object.keys(ComponentMod.components)
const componentPaths = Object.keys(ComponentMod.components)
const components = componentPaths
.filter((path) => {
// Rendering part of the page is only allowed for flight data
if (flightRouterPath) {
Expand All @@ -238,6 +240,8 @@ export async function renderToHTML(
return mod
})

const isSubtreeRender = components.length < componentPaths.length

// Reads of this are cached on the `req` object, so this should resolve
// instantly. There's no need to pass this data down from a previous
// invoke, where we'd have to consider server & serverless.
Expand All @@ -247,22 +251,12 @@ export async function renderToHTML(
(renderOpts as any).previewProps
)
const isPreview = previewData !== false

let WrappedComponent: any
let RootLayout: any

const dataCache = new Map<string, Record>()
let WrappedComponent: any

for (let i = components.length - 1; i >= 0; i--) {
const dataCacheKey = i.toString()
const layout = components[i]

if (i === 0) {
// top-most layout is the root layout that renders
// the html/body tags
RootLayout = layout.Component
continue
}
let fetcher: any

// TODO: pass a shared cache from previous getStaticProps/
Expand Down Expand Up @@ -313,8 +307,7 @@ export async function renderToHTML(

// eslint-disable-next-line no-loop-func
const lastComponent = WrappedComponent
WrappedComponent = () => {
let props: any
WrappedComponent = (props: any) => {
if (fetcher) {
// The data fetching was kicked off before rendering (see above)
// if the data was not resolved yet the layout rendering will be suspended
Expand All @@ -325,7 +318,25 @@ export async function renderToHTML(
)
// Result of calling getStaticProps or getServerSideProps. If promise is not resolve yet it will suspend.
const recordValue = readRecordValue(record)
props = recordValue.props

if (props) {
props = Object.assign({}, props, recordValue.props)
} else {
props = recordValue.props
}
}

// if this is the root layout pass children as bodyChildren prop
if (!isSubtreeRender && i === 0) {
return React.createElement(layout.Component, {
...props,
headChildren: props.headChildren,
bodyChildren: React.createElement(
lastComponent || React.Fragment,
{},
null
),
})
}

return React.createElement(
Expand All @@ -345,14 +356,11 @@ export async function renderToHTML(
// }
}

// Fall back to default root layout that renders <html> / <head> / <body>
if (!RootLayout) {
RootLayout = DefaultRootLayout
}

const headChildren = buildManifest.rootMainFiles.map((src) => (
<script src={'/_next/' + src} async key={src} />
))
const headChildren = !isSubtreeRender
? buildManifest.rootMainFiles.map((src) => (
<script src={'/_next/' + src} async key={src} />
))
: undefined

let serverComponentsInlinedTransformStream: TransformStream<
Uint8Array,
Expand All @@ -362,11 +370,15 @@ export async function renderToHTML(
serverComponentsInlinedTransformStream = new TransformStream()
const search = stringifyQuery(query)

const Component = createServerComponentRenderer(RootLayout, ComponentMod, {
cachePrefix: pathname + (search ? `?${search}` : ''),
transformStream: serverComponentsInlinedTransformStream,
serverComponentManifest,
})
const Component = createServerComponentRenderer(
WrappedComponent,
ComponentMod,
{
cachePrefix: pathname + (search ? `?${search}` : ''),
transformStream: serverComponentsInlinedTransformStream,
serverComponentManifest,
}
)

// const serverComponentProps = query.__props__
// ? JSON.parse(query.__props__ as string)
Expand Down Expand Up @@ -417,10 +429,7 @@ export async function renderToHTML(
if (renderServerComponentData) {
return new RenderResult(
renderToReadableStream(
<RootLayout
headChildren={headChildren}
bodyChildren={<WrappedComponent />}
/>,
<WrappedComponent headChildren={headChildren} />,
serverComponentManifest
).pipeThrough(createBufferedTransformStream())
)
Expand All @@ -443,10 +452,7 @@ export async function renderToHTML(
const bodyResult = async () => {
const content = (
<AppContainer>
<Component
headChildren={headChildren}
bodyChildren={<WrappedComponent />}
/>
<Component headChildren={headChildren} />
</AppContainer>
)

Expand Down
@@ -0,0 +1,7 @@
export default function AnotherPage(props) {
return (
<>
<p>hello from newroot/dashboard/another</p>
</>
)
}
19 changes: 19 additions & 0 deletions test/e2e/views-dir/app/views/(newroot)/layout.server.js
@@ -0,0 +1,19 @@
export async function getServerSideProps() {
return {
props: {
world: 'world',
},
}
}

export default function Root({ headChildren, bodyChildren, world }) {
return (
<html className="this-is-another-document-html">
<head>
{headChildren}
<title>{`hello ${world}`}</title>
</head>
<body className="this-is-another-document-body">{bodyChildren}</body>
</html>
)
}
@@ -0,0 +1,7 @@
export default function DeploymentsBreakdownPage(props) {
return (
<>
<p>hello from root/dashboard/(custom)/deployments/breakdown</p>
</>
)
}
@@ -0,0 +1,8 @@
export default function CustomDashboardRootLayout({ children }) {
return (
<>
<h2>Custom dashboard</h2>
{children}
</>
)
}
12 changes: 10 additions & 2 deletions test/e2e/views-dir/app/views/layout.server.js
@@ -1,9 +1,17 @@
export default function Root({ headChildren, bodyChildren }) {
export async function getServerSideProps() {
return {
props: {
world: 'world',
},
}
}

export default function Root({ headChildren, bodyChildren, world }) {
return (
<html className="this-is-the-document-html">
<head>
{headChildren}
<title>Test</title>
<title>{`hello ${world}`}</title>
</head>
<body className="this-is-the-document-body">{bodyChildren}</body>
</html>
Expand Down
55 changes: 50 additions & 5 deletions test/e2e/views-dir/index.test.ts
Expand Up @@ -26,6 +26,12 @@ describe('views dir', () => {
})
afterAll(() => next.destroy())

it('should pass props from getServerSideProps in root layout', async () => {
const html = await renderViaHTTP(next.url, '/dashboard')
const $ = cheerio.load(html)
expect($('title').text()).toBe('hello world')
})

it('should serve from pages', async () => {
const html = await renderViaHTTP(next.url, '/')
expect(html).toContain('hello from pages/index')
Expand Down Expand Up @@ -55,14 +61,16 @@ describe('views dir', () => {
expect($('p').text()).toBe('hello from root/dashboard/integrations')
})

// TODO: why is this routable but /should-not-serve-server.server.js
it('should not include parent when not in parent directory with route in directory', async () => {
const html = await renderViaHTTP(next.url, '/dashboard/hello')
const $ = cheerio.load(html)

// Should be nested in /root.js
expect($('html').hasClass('this-is-the-document-html')).toBeTruthy()
expect($('body').hasClass('this-is-the-document-body')).toBeTruthy()
// new root has to provide it's own custom root layout or the default
// is used instead
expect(html).toContain('<html>')
expect(html).toContain('<body>')
expect($('html').hasClass('this-is-the-document-html')).toBeFalsy()
expect($('body').hasClass('this-is-the-document-body')).toBeFalsy()

// Should not be nested in dashboard
expect($('h1').text()).toBeFalsy()
Expand All @@ -71,11 +79,48 @@ describe('views dir', () => {
expect($('p').text()).toBe('hello from root/dashboard/rootonly/hello')
})

it('should use new root layout when provided', async () => {
const html = await renderViaHTTP(next.url, '/dashboard/another')
const $ = cheerio.load(html)

// new root has to provide it's own custom root layout or the default
// is used instead
expect($('html').hasClass('this-is-another-document-html')).toBeTruthy()
expect($('body').hasClass('this-is-another-document-body')).toBeTruthy()

// Should not be nested in dashboard
expect($('h1').text()).toBeFalsy()

// Should render the page text
expect($('p').text()).toBe('hello from newroot/dashboard/another')
})

it('should not create new root layout when nested (optional)', async () => {
const html = await renderViaHTTP(
next.url,
'/dashboard/deployments/breakdown'
)
const $ = cheerio.load(html)

// new root has to provide it's own custom root layout or the default
// is used instead
expect($('html').hasClass('this-is-the-document-html')).toBeTruthy()
expect($('body').hasClass('this-is-the-document-body')).toBeTruthy()

// Should be nested in dashboard
expect($('h1').text()).toBe('Dashboard')
expect($('h2').text()).toBe('Custom dashboard')

// Should render the page text
expect($('p').text()).toBe(
'hello from root/dashboard/(custom)/deployments/breakdown'
)
})

it('should include parent document when no direct parent layout', async () => {
const html = await renderViaHTTP(next.url, '/dashboard/integrations')
const $ = cheerio.load(html)

// Root has to provide it's own document
expect($('html').hasClass('this-is-the-document-html')).toBeTruthy()
expect($('body').hasClass('this-is-the-document-body')).toBeTruthy()
})
Expand Down