Skip to content

Commit

Permalink
Update root component handling (vercel#36781)
Browse files Browse the repository at this point in the history
  • Loading branch information
ijjk authored May 10, 2022
1 parent 342331e commit 482fe25
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 50 deletions.
28 changes: 22 additions & 6 deletions packages/next/build/webpack/loaders/next-view-loader.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function DeploymentsBreakdownPage(props) {
return (
<>
<p>hello from root/dashboard/(custom)/deployments/breakdown</p>
</>
)
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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

0 comments on commit 482fe25

Please sign in to comment.