Skip to content

Commit

Permalink
Create root layout (#41523)
Browse files Browse the repository at this point in the history
Creates a root layout if it is missing in DEV when building the page in
`next-app-loader`. If the page is in a route group, the layout will be
created there. Otherwise it will create a layout directly under `/app`.

Breaks the build if a page is found that's missing a root layout.

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the
feature request has been accepted for implementation before opening a
PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing
doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
  • Loading branch information
Hannes Bornö committed Oct 23, 2022
1 parent 0a97d07 commit a105515
Show file tree
Hide file tree
Showing 12 changed files with 392 additions and 8 deletions.
3 changes: 3 additions & 0 deletions packages/next/build/entries.ts
Expand Up @@ -217,6 +217,9 @@ export function getAppEntry(opts: {
appDir: string
appPaths: string[] | null
pageExtensions: string[]
isDev?: boolean
rootDir?: string
tsconfigPath?: string
}) {
return {
import: `next-app-loader?${stringify(opts)}!`,
Expand Down
54 changes: 50 additions & 4 deletions packages/next/build/webpack/loaders/next-app-loader.ts
@@ -1,8 +1,12 @@
import type webpack from 'webpack'
import chalk from 'next/dist/compiled/chalk'
import type { ValueOf } from '../../../shared/lib/constants'
import { NODE_RESOLVE_OPTIONS } from '../../webpack-config'
import { getModuleBuildInfo } from './get-module-build-info'
import { sep } from 'path'
import { verifyRootLayout } from '../../../lib/verifyRootLayout'
import * as Log from '../../../build/output/log'
import { APP_DIR_ALIAS } from '../../../lib/constants'

export const FILE_TYPES = {
layout: 'layout',
Expand Down Expand Up @@ -35,6 +39,7 @@ async function createTreeCodeFromPath({
const splittedPath = pagePath.split(/[\\/]/)
const appDirPrefix = splittedPath[0]
const pages: string[] = []
let rootLayout: string | undefined

async function createSubtreePropsFromSegmentPath(
segments: string[]
Expand Down Expand Up @@ -82,6 +87,12 @@ async function createTreeCodeFromPath({
})
)

if (!rootLayout) {
rootLayout = filePaths.find(
([type, path]) => type === 'layout' && !!path
)?.[1]
}

props[parallelKey] = `[
'${parallelSegment}',
${subtree},
Expand Down Expand Up @@ -111,7 +122,7 @@ async function createTreeCodeFromPath({
}

const tree = await createSubtreePropsFromSegmentPath([])
return [`const tree = ${tree}.children;`, pages]
return [`const tree = ${tree}.children;`, pages, rootLayout]
}

function createAbsolutePath(appDir: string, pathToTurnAbsolute: string) {
Expand All @@ -129,9 +140,20 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{
appDir: string
appPaths: string[] | null
pageExtensions: string[]
rootDir?: string
tsconfigPath?: string
isDev?: boolean
}> = async function nextAppLoader() {
const { name, appDir, appPaths, pagePath, pageExtensions } =
this.getOptions() || {}
const {
name,
appDir,
appPaths,
pagePath,
pageExtensions,
rootDir,
tsconfigPath,
isDev,
} = this.getOptions() || {}

const buildInfo = getModuleBuildInfo((this as any)._module)
buildInfo.route = {
Expand Down Expand Up @@ -182,12 +204,36 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{
}
}

const [treeCode, pages] = await createTreeCodeFromPath({
const [treeCode, pages, rootLayout] = await createTreeCodeFromPath({
pagePath,
resolve: resolver,
resolveParallelSegments,
})

if (!rootLayout) {
const errorMessage = `${chalk.bold(
pagePath.replace(`${APP_DIR_ALIAS}/`, '')
)} doesn't have a root layout. To fix this error, make sure every page has a root layout.`

if (!isDev) {
// If we're building and missing a root layout, exit the build
Log.error(errorMessage)
process.exit(1)
} else {
// In dev we'll try to create a root layout
const createdRootLayout = await verifyRootLayout({
appDir: appDir,
dir: rootDir!,
tsconfigPath: tsconfigPath!,
pagePath,
pageExtensions,
})
if (!createdRootLayout) {
throw new Error(errorMessage)
}
}
}

const result = `
export ${treeCode}
export const pages = ${JSON.stringify(pages)}
Expand Down
@@ -1,4 +1,4 @@
import * as React from 'react'
import React from 'react'
import {
Dialog,
DialogBody,
Expand Down Expand Up @@ -32,16 +32,16 @@ export const RootLayoutError: React.FC<RootLayoutErrorProps> =
<DialogContent>
<DialogHeader className="nextjs-container-root-layout-error-header">
<h4 id="nextjs__container_root_layout_error_label">
Root layout error
Missing required tags
</h4>
</DialogHeader>
<DialogBody className="nextjs-container-root-layout-error-body">
<Terminal content={message} />
<footer>
<p id="nextjs__container_root_layout_error_desc">
<small>
This error occurred during the build process and can only be
dismissed by fixing the error.
This error and can only be dismissed by providing all
required tags.
</small>
</p>
</footer>
Expand Down
108 changes: 108 additions & 0 deletions packages/next/lib/verifyRootLayout.ts
@@ -0,0 +1,108 @@
import path from 'path'
import { promises as fs } from 'fs'
import chalk from 'next/dist/compiled/chalk'
import * as Log from '../build/output/log'
import { APP_DIR_ALIAS } from './constants'

const globOrig =
require('next/dist/compiled/glob') as typeof import('next/dist/compiled/glob')
const glob = (cwd: string, pattern: string): Promise<string[]> => {
return new Promise((resolve, reject) => {
globOrig(pattern, { cwd }, (err, files) => {
if (err) {
return reject(err)
}
resolve(files)
})
})
}

function getRootLayout(isTs: boolean) {
if (isTs) {
return `export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}
`
}

return `export default function RootLayout({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}
`
}

export async function verifyRootLayout({
dir,
appDir,
tsconfigPath,
pagePath,
pageExtensions,
}: {
dir: string
appDir: string
tsconfigPath: string
pagePath: string
pageExtensions: string[]
}) {
try {
const layoutFiles = await glob(
appDir,
`**/layout.{${pageExtensions.join(',')}}`
)
const hasLayout = layoutFiles.length !== 0

const normalizedPagePath = pagePath.replace(`${APP_DIR_ALIAS}/`, '')
const firstSegmentValue = normalizedPagePath.split('/')[0]
const pageRouteGroup = firstSegmentValue.startsWith('(')
? firstSegmentValue
: undefined

if (pageRouteGroup || !hasLayout) {
const resolvedTsConfigPath = path.join(dir, tsconfigPath)
const hasTsConfig = await fs.access(resolvedTsConfigPath).then(
() => true,
() => false
)

const rootLayoutPath = path.join(
appDir,
// If the page is within a route group directly under app (e.g. app/(routegroup)/page.js)
// prefer creating the root layout in that route group. Otherwise create the root layout in the app root.
pageRouteGroup ? pageRouteGroup : '',
`layout.${hasTsConfig ? 'tsx' : 'js'}`
)
await fs.writeFile(rootLayoutPath, getRootLayout(hasTsConfig))
console.log(
chalk.green(
`\nYour page ${chalk.bold(
`app/${normalizedPagePath}`
)} did not have a root layout, we created ${chalk.bold(
`app${rootLayoutPath.replace(appDir, '')}`
)} for you.`
) + '\n'
)

// Created root layout
return true
}
} catch (error) {
Log.error('Failed to create root layout', error)
}

// Didn't create root layout
return false
}
6 changes: 6 additions & 0 deletions packages/next/server/dev/hot-reloader.ts
Expand Up @@ -624,6 +624,9 @@ export default class HotReloader {
),
appDir: this.appDir!,
pageExtensions: this.config.pageExtensions,
rootDir: this.dir,
isDev: true,
tsconfigPath: this.config.typescript.tsconfigPath,
}).import
: undefined

Expand Down Expand Up @@ -702,6 +705,9 @@ export default class HotReloader {
),
appDir: this.appDir!,
pageExtensions: this.config.pageExtensions,
rootDir: this.dir,
isDev: true,
tsconfigPath: this.config.typescript.tsconfigPath,
})
: relativeRequest,
hasAppDir,
Expand Down
7 changes: 7 additions & 0 deletions test/.stats-app/app/layout.js
@@ -0,0 +1,7 @@
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}

0 comments on commit a105515

Please sign in to comment.