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

Create root layout #41523

Merged
merged 14 commits into from Oct 23, 2022
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>
)
}