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

Move root layout validation #41338

Merged
merged 18 commits into from Oct 14, 2022
Merged
Show file tree
Hide file tree
Changes from 6 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
42 changes: 32 additions & 10 deletions packages/next/server/app-render.tsx
Expand Up @@ -21,7 +21,7 @@ import {
streamToString,
} from './node-web-streams-helper'
import { ESCAPE_REGEX, htmlEscapeJsonString } from './htmlescape'
import { shouldUseReactRoot } from './utils'
import { validateRootLayout, shouldUseReactRoot } from './utils'
import { matchSegment } from '../client/components/match-segments'
import {
FlightCSSManifest,
Expand Down Expand Up @@ -1175,13 +1175,25 @@ export async function renderToHTMLOrFlight(
renderResult: RenderResult
): Promise<string> => {
const renderChunks: Buffer[] = []
const writable = new Writable({
write(chunk, _encoding, callback) {
renderChunks.push(chunk)
callback()
},
})
await renderResult.pipe(writable)
if (process.env.NEXT_RUNTIME === 'edge') {
const writableStream = new WritableStream({
write(chunk) {
return new Promise((resolve) => {
renderChunks.push(chunk)
resolve()
})
},
})
await renderResult.pipeToWritableStream(writableStream)
} else {
const writable = new Writable({
write(chunk, _encoding, callback) {
renderChunks.push(chunk)
callback()
},
})
await renderResult.pipe(writable)
}
return Buffer.concat(renderChunks).toString()
}

Expand Down Expand Up @@ -1486,7 +1498,6 @@ export async function renderToHTMLOrFlight(
generateStaticHTML: isStaticGeneration,
getServerInsertedHTML,
serverInsertedHTMLToHead: true,
dev,
})
} catch (err: any) {
// TODO-APP: show error overlay in development. `element` should probably be wrapped in AppRouter for this case.
Expand Down Expand Up @@ -1517,14 +1528,24 @@ export async function renderToHTMLOrFlight(
generateStaticHTML: isStaticGeneration,
getServerInsertedHTML,
serverInsertedHTMLToHead: true,
dev,
})
}
}
const renderResult = new RenderResult(await bodyResult())

if (dev && !isStaticGeneration) {
// Check for required root layout tags in dev
const htmlResult = await streamToBufferedResult(renderResult)
validateRootLayout(htmlResult)
return new RenderResult(htmlResult)
}

if (isStaticGeneration) {
const htmlResult = await streamToBufferedResult(renderResult)
if (dev) {
// Check for required root layout tags in dev
validateRootLayout(htmlResult)
}
// if we encountered any unexpected errors during build
// we fail the prerendering phase and the build
if (capturedErrors.length > 0) {
Expand All @@ -1548,6 +1569,7 @@ export async function renderToHTMLOrFlight(

return new RenderResult(htmlResult)
}

return renderResult
}

Expand Down
43 changes: 0 additions & 43 deletions packages/next/server/node-web-streams-helper.ts
Expand Up @@ -257,57 +257,15 @@ export function createSuffixStream(
})
}

export function createRootLayoutValidatorStream(): TransformStream<
Uint8Array,
Uint8Array
> {
let foundHtml = false
let foundHead = false
let foundBody = false

return new TransformStream({
async transform(chunk, controller) {
if (!foundHtml || !foundHead || !foundBody) {
const content = decodeText(chunk)
if (!foundHtml && content.includes('<html')) {
foundHtml = true
}
if (!foundHead && content.includes('<head')) {
foundHead = true
}
if (!foundBody && content.includes('<body')) {
foundBody = true
}
}
controller.enqueue(chunk)
},
flush(controller) {
const missingTags = [
foundHtml ? null : 'html',
foundHead ? null : 'head',
foundBody ? null : 'body',
].filter(nonNullable)

if (missingTags.length > 0) {
controller.error(
'Missing required root layout tags: ' + missingTags.join(', ')
)
}
},
})
}

export async function continueFromInitialStream(
renderStream: ReactReadableStream,
{
dev,
suffix,
dataStream,
generateStaticHTML,
getServerInsertedHTML,
serverInsertedHTMLToHead,
}: {
dev?: boolean
suffix?: string
dataStream?: ReadableStream<Uint8Array>
generateStaticHTML: boolean
Expand Down Expand Up @@ -339,7 +297,6 @@ export async function continueFromInitialStream(
: ''
return serverInsertedHTML
}),
dev ? createRootLayoutValidatorStream() : null,
].filter(nonNullable)

return transforms.reduce(
Expand Down
30 changes: 30 additions & 0 deletions packages/next/server/render-result.ts
Expand Up @@ -66,6 +66,36 @@ export default class RenderResult {
})()
}

pipeToWritableStream(stream: WritableStream<Uint8Array>): Promise<void> {
if (typeof this._result === 'string') {
throw new Error(
'invariant: static responses cannot be piped. This is a bug in Next.js'
)
}
const response = this._result

return (async () => {
const reader = response.getReader()
const writer = stream.getWriter()

try {
while (true) {
const { done, value } = await reader.read()

if (done) {
await writer.close()
return
}

await writer.write(value)
}
} catch (err) {
await writer.abort(err)
throw err
}
})()
}

isDynamic(): boolean {
return typeof this._result !== 'string'
}
Expand Down
14 changes: 14 additions & 0 deletions packages/next/server/utils.ts
Expand Up @@ -24,3 +24,17 @@ export function isTargetLikeServerless(target: string) {

// When react version is >= 18 opt-in using reactRoot
export const shouldUseReactRoot = parseInt(React.version) >= 18

export function validateRootLayout(htmlResult: string) {
const missingTags = [
htmlResult.includes('<html') ? null : 'html',
htmlResult.includes('<head') ? null : 'head',
htmlResult.includes('<body') ? null : 'body',
].filter(Boolean)

if (missingTags.length > 0) {
throw new Error(
'Missing required root layout tags: ' + missingTags.join(', ')
ijjk marked this conversation as resolved.
Show resolved Hide resolved
)
}
}
32 changes: 26 additions & 6 deletions test/e2e/app-dir/root-layout.test.ts
Expand Up @@ -2,7 +2,7 @@ import path from 'path'
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import webdriver from 'next-webdriver'
import { check, renderViaHTTP } from 'next-test-utils'
import { getRedboxDescription, hasRedbox } from 'next-test-utils'

describe('app-dir root layout', () => {
const isDev = (global as any).isNextDev
Expand Down Expand Up @@ -38,9 +38,13 @@ describe('app-dir root layout', () => {
describe('Missing required tags', () => {
it('should error on page load', async () => {
const outputIndex = next.cliOutput.length
renderViaHTTP(next.url, '/missing-tags').catch(() => {})
await check(
() => next.cliOutput.slice(outputIndex),
const browser = await webdriver(next.url, '/missing-tags')

expect(await hasRedbox(browser, true)).toBe(true)
expect(await getRedboxDescription(browser)).toInclude(
'Missing required root layout tags: html, head, body'
)
expect(next.cliOutput.slice(outputIndex)).toMatch(
/Missing required root layout tags: html, head, body/
)
})
Expand All @@ -50,8 +54,24 @@ describe('app-dir root layout', () => {
const browser = await webdriver(next.url, '/has-tags')
await browser.elementByCss('a').click()

await check(
() => next.cliOutput.slice(outputIndex),
expect(await hasRedbox(browser, true)).toBe(true)
expect(await getRedboxDescription(browser)).toInclude(
'Missing required root layout tags: html, head, body'
)
expect(next.cliOutput.slice(outputIndex)).toMatch(
/Missing required root layout tags: html, head, body/
)
})

it('should error on page load on static generation', async () => {
const outputIndex = next.cliOutput.length
const browser = await webdriver(next.url, '/static-missing-tags/slug')

expect(await hasRedbox(browser, true)).toBe(true)
expect(await getRedboxDescription(browser)).toInclude(
'Missing required root layout tags: html, head, body'
)
expect(next.cliOutput.slice(outputIndex)).toMatch(
/Missing required root layout tags: html, head, body/
)
})
Expand Down
@@ -0,0 +1,7 @@
export default function Layout({ children }) {
return children
}

export function generateStaticParams() {
return [{ slug: 'slug' }]
}
@@ -0,0 +1,7 @@
export const config = {
dynamicParams: false,
}

export default function Page({ params }) {
return <p>Static page</p>
}