Skip to content

Commit

Permalink
Add error when document component isn't rendered (vercel#16459)
Browse files Browse the repository at this point in the history
If a custom `_document` is added but not all of the expected document components are rendered it can cause unintended errors as noticed in vercel#10219 so this adds detecting when one of the expected document components isn't rendered and shows an error. 

Closes: vercel#10219
  • Loading branch information
ijjk authored Aug 24, 2020
1 parent 333a9ea commit a7550bf
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 2 deletions.
13 changes: 13 additions & 0 deletions errors/missing-document-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Missing Document Components

#### Why This Error Occurred

In your custom `pages/_document` an expected sub-component was not rendered.

#### Possible Ways to Fix It

Make sure to import and render all of the expected `Document` components.

### Useful Links

- [Custom Document Docs](https://nextjs.org/docs/advanced-features/custom-document)
6 changes: 6 additions & 0 deletions packages/next/next-server/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ export type DocumentInitialProps = RenderPageResult & {
export type DocumentProps = DocumentInitialProps & {
__NEXT_DATA__: NEXT_DATA
dangerousAsPath: string
docComponentsRendered: {
Html?: boolean
Main?: boolean
Head?: boolean
NextScript?: boolean
}
buildManifest: BuildManifest
ampPath: string
inAmpMode: boolean
Expand Down
30 changes: 30 additions & 0 deletions packages/next/next-server/server/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
loadGetInitialProps,
NextComponentType,
RenderPage,
DocumentProps,
} from '../lib/utils'
import { tryGetPreviewData, __ApiPreviewProps } from './api-utils'
import { denormalizePagePath } from './denormalize-page-path'
Expand Down Expand Up @@ -158,6 +159,7 @@ function renderDocument(
Document: DocumentType,
{
buildManifest,
docComponentsRendered,
props,
docProps,
pathname,
Expand Down Expand Up @@ -188,6 +190,7 @@ function renderDocument(
devOnlyCacheBusterQueryString,
}: RenderOpts & {
props: any
docComponentsRendered: DocumentProps['docComponentsRendered']
docProps: DocumentInitialProps
pathname: string
query: ParsedUrlQuery
Expand Down Expand Up @@ -233,6 +236,7 @@ function renderDocument(
appGip, // whether the _app has getInitialProps
},
buildManifest,
docComponentsRendered,
dangerousAsPath,
canonicalBase,
ampPath,
Expand Down Expand Up @@ -759,8 +763,11 @@ export async function renderToHTML(
renderOpts.inAmpMode = inAmpMode
renderOpts.hybridAmp = hybridAmp

const docComponentsRendered: DocumentProps['docComponentsRendered'] = {}

let html = renderDocument(Document, {
...renderOpts,
docComponentsRendered,
buildManifest: filteredBuildManifest,
// Only enabled in production as development mode has features relying on HMR (style injection for example)
unstable_runtimeJS:
Expand All @@ -787,6 +794,29 @@ export async function renderToHTML(
devOnlyCacheBusterQueryString,
})

if (process.env.NODE_ENV !== 'production') {
const nonRenderedComponents = []
const expectedDocComponents = ['Main', 'Head', 'NextScript', 'Html']

for (const comp of expectedDocComponents) {
if (!(docComponentsRendered as any)[comp]) {
nonRenderedComponents.push(comp)
}
}
const plural = nonRenderedComponents.length !== 1 ? 's' : ''

if (nonRenderedComponents.length) {
console.warn(
`Expected Document Component${plural} ${nonRenderedComponents.join(
', '
)} ${
plural ? 'were' : 'was'
} not rendered. Make sure you render them in your custom \`_document\`\n` +
`See more info here https://err.sh/next.js/missing-document-component`
)
}
}

if (inAmpMode && html) {
// inject HTML to AMP_RENDER_TARGET to allow rendering
// directly to body in AMP mode
Expand Down
19 changes: 17 additions & 2 deletions packages/next/pages/_document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,12 @@ export function Html(
HTMLHtmlElement
>
) {
const { inAmpMode } = useContext(DocumentComponentContext)
const { inAmpMode, docComponentsRendered } = useContext(
DocumentComponentContext
)

docComponentsRendered.Html = true

return (
<html
{...props}
Expand Down Expand Up @@ -288,6 +293,8 @@ export class Head extends Component<
} = this.context
const disableRuntimeJS = unstable_runtimeJS === false

this.context.docComponentsRendered.Head = true

let { head } = this.context
let children = this.props.children
// show a warning if Head contains <title> (only in development)
Expand Down Expand Up @@ -501,7 +508,12 @@ export class Head extends Component<
}

export function Main() {
const { inAmpMode, html } = useContext(DocumentComponentContext)
const { inAmpMode, html, docComponentsRendered } = useContext(
DocumentComponentContext
)

docComponentsRendered.Main = true

if (inAmpMode) return <>{AMP_RENDER_TARGET}</>
return <div id="__next" dangerouslySetInnerHTML={{ __html: html }} />
}
Expand Down Expand Up @@ -642,10 +654,13 @@ export class NextScript extends Component<OriginProps> {
inAmpMode,
buildManifest,
unstable_runtimeJS,
docComponentsRendered,
devOnlyCacheBusterQueryString,
} = this.context
const disableRuntimeJS = unstable_runtimeJS === false

docComponentsRendered.NextScript = true

if (inAmpMode) {
if (process.env.NODE_ENV === 'production') {
return null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Index() {
return <p>Index page</p>
}
159 changes: 159 additions & 0 deletions test/integration/missing-document-component-error/test/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/* eslint-env jest */

import fs from 'fs-extra'
import { join } from 'path'
import {
findPort,
killApp,
launchApp,
check,
renderViaHTTP,
} from 'next-test-utils'

jest.setTimeout(1000 * 60 * 2)

const appDir = join(__dirname, '..')
const docPath = join(appDir, 'pages/_document.js')
let appPort
let app

const checkMissing = async (missing = [], docContent) => {
await fs.writeFile(docPath, docContent)
let stderr = ''

appPort = await findPort()
app = await launchApp(appDir, appPort, {
onStderr(msg) {
stderr += msg || ''
},
})

await renderViaHTTP(appPort, '/')

await check(() => stderr, new RegExp(`missing-document-component`))
await check(() => stderr, new RegExp(`${missing.join(', ')}`))

await killApp(app)
await fs.remove(docPath)
}

describe('Missing _document components error', () => {
it('should detect missing Html component', async () => {
await checkMissing(
['Html'],
`
import Document, { Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
render() {
return (
<html>
<Head />
<body>
<Main />
<NextScript />
</body>
</html>
)
}
}
export default MyDocument
`
)
})

it('should detect missing Head component', async () => {
await checkMissing(
['Head'],
`
import Document, { Html, Main, NextScript } from 'next/document'
class MyDocument extends Document {
render() {
return (
<Html>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
`
)
})

it('should detect missing Main component', async () => {
await checkMissing(
['Main'],
`
import Document, { Html, Head, NextScript } from 'next/document'
class MyDocument extends Document {
render() {
return (
<Html>
<Head />
<body>
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
`
)
})

it('should detect missing NextScript component', async () => {
await checkMissing(
['NextScript'],
`
import Document, { Html, Head, Main } from 'next/document'
class MyDocument extends Document {
render() {
return (
<Html>
<body>
<Main />
</body>
</Html>
)
}
}
export default MyDocument
`
)
})

it('should detect multiple missing document components', async () => {
await checkMissing(
['Head', 'NextScript'],
`
import Document, { Html, Main } from 'next/document'
class MyDocument extends Document {
render() {
return (
<Html>
<body>
<Main />
</body>
</Html>
)
}
}
export default MyDocument
`
)
})
})

0 comments on commit a7550bf

Please sign in to comment.