rsnext/packages/next/pages/_document.tsx
Joe Haddad 3cd37b29ea
Simplify development hiding (#8789)
We were trying to be too tricky. `visibility: hidden` is great for layout speed, but it allows child elements to make themselves visible.

Since styles are going to change anyway, this doesn't make sense. We should completely hide the body which prevents a reflow by avoiding any style computation until Global CSS is ready to go.
2019-09-18 16:15:08 -04:00

765 lines
23 KiB
TypeScript

/* eslint-disable */
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import { cleanAmpPath } from '../next-server/server/utils'
import {
DocumentContext,
DocumentInitialProps,
DocumentProps,
} from '../next-server/lib/utils'
import { htmlEscapeJsonString } from '../server/htmlescape'
import flush from 'styled-jsx/server'
import {
CLIENT_STATIC_FILES_RUNTIME_AMP,
CLIENT_STATIC_FILES_RUNTIME_WEBPACK,
} from '../next-server/lib/constants'
export { DocumentContext, DocumentInitialProps, DocumentProps }
export type OriginProps = {
nonce?: string
crossOrigin?: string
}
export type DocumentComponentContext = {
readonly _documentProps: DocumentProps
readonly _devOnlyInvalidateCacheQueryString: string
}
export async function middleware({ req, res }: DocumentContext) {}
function dedupe(bundles: any[]): any[] {
const files = new Set()
const kept = []
for (const bundle of bundles) {
if (files.has(bundle.file)) continue
files.add(bundle.file)
kept.push(bundle)
}
return kept
}
function getOptionalModernScriptVariant(path: string) {
if (process.env.__NEXT_MODERN_BUILD) {
return path.replace(/\.js$/, '.module.js')
}
return path
}
/**
* `Document` component handles the initial `document` markup and renders only on the server side.
* Commonly used for implementing server side rendering for `css-in-js` libraries.
*/
export default class Document<P = {}> extends Component<DocumentProps & P> {
static childContextTypes = {
_documentProps: PropTypes.any,
_devOnlyInvalidateCacheQueryString: PropTypes.string,
}
/**
* `getInitialProps` hook returns the context object with the addition of `renderPage`.
* `renderPage` callback executes `React` rendering logic synchronously to support server-rendering wrappers
*/
static async getInitialProps({
renderPage,
}: DocumentContext): Promise<DocumentInitialProps> {
const { html, head, dataOnly } = await renderPage()
const styles = flush()
return { html, head, styles, dataOnly }
}
context!: DocumentComponentContext
getChildContext(): DocumentComponentContext {
return {
_documentProps: this.props,
// In dev we invalidate the cache by appending a timestamp to the resource URL.
// This is a workaround to fix https://github.com/zeit/next.js/issues/5860
// TODO: remove this workaround when https://bugs.webkit.org/show_bug.cgi?id=187726 is fixed.
_devOnlyInvalidateCacheQueryString:
process.env.NODE_ENV !== 'production' ? '?ts=' + Date.now() : '',
}
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export class Html extends Component<
React.DetailedHTMLProps<
React.HtmlHTMLAttributes<HTMLHtmlElement>,
HTMLHtmlElement
>
> {
static contextTypes = {
_documentProps: PropTypes.any,
}
static propTypes = {
children: PropTypes.node.isRequired,
}
context!: DocumentComponentContext
render() {
const { inAmpMode } = this.context._documentProps
return (
<html
{...this.props}
amp={inAmpMode ? '' : undefined}
data-ampdevmode={
inAmpMode && process.env.NODE_ENV !== 'production' ? '' : undefined
}
/>
)
}
}
export class Head extends Component<
OriginProps &
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLHeadElement>,
HTMLHeadElement
>
> {
static contextTypes = {
_documentProps: PropTypes.any,
_devOnlyInvalidateCacheQueryString: PropTypes.string,
}
static propTypes = {
nonce: PropTypes.string,
crossOrigin: PropTypes.string,
}
context!: DocumentComponentContext
getCssLinks() {
const { assetPrefix, files } = this.context._documentProps
if (!files || files.length === 0) {
return null
}
return files.map((file: string) => {
// Only render .css files here
if (!/\.css$/.exec(file)) {
return null
}
return (
<link
key={file}
nonce={this.props.nonce}
rel="stylesheet"
href={`${assetPrefix}/_next/${encodeURI(file)}`}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
/>
)
})
}
getPreloadDynamicChunks() {
const { dynamicImports, assetPrefix } = this.context._documentProps
const { _devOnlyInvalidateCacheQueryString } = this.context
return (
dedupe(dynamicImports)
.map((bundle: any) => {
// `dynamicImports` will contain both `.js` and `.module.js` when the
// feature is enabled. This clause will filter down to the modern
// variants only.
if (!bundle.file.endsWith(getOptionalModernScriptVariant('.js'))) {
return null
}
return (
<link
rel="preload"
key={bundle.file}
href={`${assetPrefix}/_next/${encodeURI(
bundle.file
)}${_devOnlyInvalidateCacheQueryString}`}
as="script"
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
/>
)
})
// Filter out nulled scripts
.filter(Boolean)
)
}
getPreloadMainLinks() {
const { assetPrefix, files } = this.context._documentProps
if (!files || files.length === 0) {
return null
}
const { _devOnlyInvalidateCacheQueryString } = this.context
return files
.map((file: string) => {
// `dynamicImports` will contain both `.js` and `.module.js` when the
// feature is enabled. This clause will filter down to the modern
// variants only.
// This also filters out non-JS assets.
if (!file.endsWith(getOptionalModernScriptVariant('.js'))) {
return null
}
return (
<link
key={file}
nonce={this.props.nonce}
rel="preload"
href={`${assetPrefix}/_next/${encodeURI(
file
)}${_devOnlyInvalidateCacheQueryString}`}
as="script"
crossOrigin={this.props.crossOrigin || process.crossOrigin}
/>
)
})
.filter(Boolean)
}
render() {
const {
styles,
ampPath,
inAmpMode,
assetPrefix,
hybridAmp,
canonicalBase,
__NEXT_DATA__,
dangerousAsPath,
} = this.context._documentProps
const { _devOnlyInvalidateCacheQueryString } = this.context
const { page, buildId } = __NEXT_DATA__
let { head } = this.context._documentProps
let children = this.props.children
// show a warning if Head contains <title> (only in development)
if (process.env.NODE_ENV !== 'production') {
children = React.Children.map(children, (child: any) => {
const isReactHelmet =
child && child.props && child.props['data-react-helmet']
if (child && child.type === 'title' && !isReactHelmet) {
console.warn(
"Warning: <title> should not be used in _document.js's <Head>. https://err.sh/next.js/no-document-title"
)
}
return child
})
if (this.props.crossOrigin)
console.warn(
'Warning: `Head` attribute `crossOrigin` is deprecated. https://err.sh/next.js/doc-crossorigin-deprecated'
)
}
let hasAmphtmlRel = false
let hasCanonicalRel = false
// show warning and remove conflicting amp head tags
head = !inAmpMode
? head
: React.Children.map(head || [], child => {
if (!child) return child
const { type, props } = child
let badProp: string = ''
if (type === 'meta' && props.name === 'viewport') {
badProp = 'name="viewport"'
} else if (type === 'link' && props.rel === 'canonical') {
hasCanonicalRel = true
} else if (type === 'link' && props.rel === 'amphtml') {
hasAmphtmlRel = true
} else if (type === 'script') {
// only block if
// 1. it has a src and isn't pointing to ampproject's CDN
// 2. it is using dangerouslySetInnerHTML without a type or
// a type of text/javascript
if (
(props.src && props.src.indexOf('ampproject') < -1) ||
(props.dangerouslySetInnerHTML &&
(!props.type || props.type === 'text/javascript'))
) {
badProp = '<script'
Object.keys(props).forEach(prop => {
badProp += ` ${prop}="${props[prop]}"`
})
badProp += '/>'
}
}
if (badProp) {
console.warn(
`Found conflicting amp tag "${
child.type
}" with conflicting prop ${badProp} in ${
__NEXT_DATA__.page
}. https://err.sh/next.js/conflicting-amp-tag`
)
return null
}
return child
})
// try to parse styles from fragment for backwards compat
const curStyles: React.ReactElement[] = Array.isArray(styles)
? (styles as React.ReactElement[])
: []
if (
inAmpMode &&
styles &&
// @ts-ignore Property 'props' does not exist on type ReactElement
styles.props &&
// @ts-ignore Property 'props' does not exist on type ReactElement
Array.isArray(styles.props.children)
) {
const hasStyles = (el: React.ReactElement) =>
el &&
el.props &&
el.props.dangerouslySetInnerHTML &&
el.props.dangerouslySetInnerHTML.__html
// @ts-ignore Property 'props' does not exist on type ReactElement
styles.props.children.map((child: React.ReactElement) => {
if (Array.isArray(child)) {
child.map(el => hasStyles(el) && curStyles.push(el))
} else if (hasStyles(child)) {
curStyles.push(child)
}
})
}
return (
<head {...this.props}>
{this.context._documentProps.isDevelopment &&
this.context._documentProps.hasCssMode && (
<>
<style
data-next-hydrating
dangerouslySetInnerHTML={{
__html: `body{display:none}`,
}}
/>
<noscript>
<style
data-next-hydrating
dangerouslySetInnerHTML={{
__html: `body{display:unset}`,
}}
/>
</noscript>
</>
)}
{children}
{head}
<meta
name="next-head-count"
content={React.Children.count(head || []).toString()}
/>
{inAmpMode && (
<>
<meta
name="viewport"
content="width=device-width,minimum-scale=1,initial-scale=1"
/>
{!hasCanonicalRel && (
<link
rel="canonical"
href={canonicalBase + cleanAmpPath(dangerousAsPath)}
/>
)}
{/* https://www.ampproject.org/docs/fundamentals/optimize_amp#optimize-the-amp-runtime-loading */}
<link
rel="preload"
as="script"
href="https://cdn.ampproject.org/v0.js"
/>
{/* Add custom styles before AMP styles to prevent accidental overrides */}
{styles && (
<style
amp-custom=""
dangerouslySetInnerHTML={{
__html: curStyles
.map(style => style.props.dangerouslySetInnerHTML.__html)
.join('')
.replace(/\/\*# sourceMappingURL=.*\*\//g, '')
.replace(/\/\*@ sourceURL=.*?\*\//g, ''),
}}
/>
)}
<style
amp-boilerplate=""
dangerouslySetInnerHTML={{
__html: `body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}`,
}}
/>
<noscript>
<style
amp-boilerplate=""
dangerouslySetInnerHTML={{
__html: `body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}`,
}}
/>
</noscript>
<script async src="https://cdn.ampproject.org/v0.js" />
</>
)}
{!inAmpMode && (
<>
{!hasAmphtmlRel && hybridAmp && (
<link
rel="amphtml"
href={canonicalBase + getAmpPath(ampPath, dangerousAsPath)}
/>
)}
{page !== '/_error' && (
<link
rel="preload"
href={
assetPrefix +
getOptionalModernScriptVariant(
encodeURI(
`/_next/static/${buildId}/pages${getPageFile(page)}`
)
) +
_devOnlyInvalidateCacheQueryString
}
as="script"
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
/>
)}
<link
rel="preload"
href={
assetPrefix +
getOptionalModernScriptVariant(
encodeURI(`/_next/static/${buildId}/pages/_app.js`)
) +
_devOnlyInvalidateCacheQueryString
}
as="script"
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
/>
{this.getPreloadDynamicChunks()}
{this.getPreloadMainLinks()}
{this.getCssLinks()}
{styles || null}
</>
)}
</head>
)
}
}
export class Main extends Component {
static contextTypes = {
_documentProps: PropTypes.any,
_devOnlyInvalidateCacheQueryString: PropTypes.string,
}
context!: DocumentComponentContext
render() {
const { inAmpMode, html } = this.context._documentProps
if (inAmpMode) return '__NEXT_AMP_RENDER_TARGET__'
return <div id="__next" dangerouslySetInnerHTML={{ __html: html }} />
}
}
export class NextScript extends Component<OriginProps> {
static contextTypes = {
_documentProps: PropTypes.any,
_devOnlyInvalidateCacheQueryString: PropTypes.string,
}
static propTypes = {
nonce: PropTypes.string,
crossOrigin: PropTypes.string,
}
context!: DocumentComponentContext
// Source: https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
static safariNomoduleFix =
'!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();'
getDynamicChunks() {
const { dynamicImports, assetPrefix, files } = this.context._documentProps
const { _devOnlyInvalidateCacheQueryString } = this.context
return dedupe(dynamicImports).map((bundle: any) => {
let modernProps = {}
if (process.env.__NEXT_MODERN_BUILD) {
modernProps = /\.module\.js$/.test(bundle.file)
? { type: 'module' }
: { noModule: true }
}
if (files.includes(bundle.file)) return null
return (
<script
async
key={bundle.file}
src={`${assetPrefix}/_next/${encodeURI(
bundle.file
)}${_devOnlyInvalidateCacheQueryString}`}
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
{...modernProps}
/>
)
})
}
getScripts() {
const { assetPrefix, files } = this.context._documentProps
if (!files || files.length === 0) {
return null
}
const { _devOnlyInvalidateCacheQueryString } = this.context
return files.map((file: string) => {
// Only render .js files here
if (!/\.js$/.exec(file)) {
return null
}
let modernProps = {}
if (process.env.__NEXT_MODERN_BUILD) {
modernProps = /\.module\.js$/.test(file)
? { type: 'module' }
: { noModule: true }
}
return (
<script
key={file}
src={`${assetPrefix}/_next/${encodeURI(
file
)}${_devOnlyInvalidateCacheQueryString}`}
nonce={this.props.nonce}
async
crossOrigin={this.props.crossOrigin || process.crossOrigin}
{...modernProps}
/>
)
})
}
static getInlineScriptSource(documentProps: DocumentProps) {
const { __NEXT_DATA__ } = documentProps
try {
const data = JSON.stringify(__NEXT_DATA__)
return htmlEscapeJsonString(data)
} catch (err) {
if (err.message.indexOf('circular structure')) {
throw new Error(
`Circular structure in "getInitialProps" result of page "${
__NEXT_DATA__.page
}". https://err.sh/zeit/next.js/circular-structure`
)
}
throw err
}
}
render() {
const {
staticMarkup,
assetPrefix,
inAmpMode,
devFiles,
__NEXT_DATA__,
} = this.context._documentProps
const { _devOnlyInvalidateCacheQueryString } = this.context
if (inAmpMode) {
if (process.env.NODE_ENV === 'production') {
return null
}
const devFiles = [
CLIENT_STATIC_FILES_RUNTIME_AMP,
CLIENT_STATIC_FILES_RUNTIME_WEBPACK,
]
return (
<>
{staticMarkup ? null : (
<script
id="__NEXT_DATA__"
type="application/json"
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
dangerouslySetInnerHTML={{
__html: NextScript.getInlineScriptSource(
this.context._documentProps
),
}}
data-ampdevmode
/>
)}
{devFiles
? devFiles.map(file => (
<script
key={file}
src={`${assetPrefix}/_next/${file}${_devOnlyInvalidateCacheQueryString}`}
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
data-ampdevmode
/>
))
: null}
</>
)
}
const { page, buildId } = __NEXT_DATA__
if (process.env.NODE_ENV !== 'production') {
if (this.props.crossOrigin)
console.warn(
'Warning: `NextScript` attribute `crossOrigin` is deprecated. https://err.sh/next.js/doc-crossorigin-deprecated'
)
}
const pageScript = [
<script
async
data-next-page={page}
key={page}
src={
assetPrefix +
encodeURI(`/_next/static/${buildId}/pages${getPageFile(page)}`) +
_devOnlyInvalidateCacheQueryString
}
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
{...(process.env.__NEXT_MODERN_BUILD ? { noModule: true } : {})}
/>,
process.env.__NEXT_MODERN_BUILD && (
<script
async
data-next-page={page}
key={`${page}-modern`}
src={
assetPrefix +
getOptionalModernScriptVariant(
encodeURI(`/_next/static/${buildId}/pages${getPageFile(page)}`)
) +
_devOnlyInvalidateCacheQueryString
}
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
type="module"
/>
),
]
const appScript = [
<script
async
data-next-page="/_app"
src={
assetPrefix +
`/_next/static/${buildId}/pages/_app.js` +
_devOnlyInvalidateCacheQueryString
}
key="_app"
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
{...(process.env.__NEXT_MODERN_BUILD ? { noModule: true } : {})}
/>,
process.env.__NEXT_MODERN_BUILD && (
<script
async
data-next-page="/_app"
src={
assetPrefix +
`/_next/static/${buildId}/pages/_app.module.js` +
_devOnlyInvalidateCacheQueryString
}
key="_app-modern"
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
type="module"
/>
),
]
return (
<>
{devFiles
? devFiles.map(
(file: string) =>
!file.match(/\.js\.map/) && (
<script
key={file}
src={`${assetPrefix}/_next/${encodeURI(
file
)}${_devOnlyInvalidateCacheQueryString}`}
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
/>
)
)
: null}
{staticMarkup ? null : (
<script
id="__NEXT_DATA__"
type="application/json"
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
dangerouslySetInnerHTML={{
__html: NextScript.getInlineScriptSource(
this.context._documentProps
),
}}
/>
)}
{process.env.__NEXT_MODERN_BUILD ? (
<script
nonce={this.props.nonce}
crossOrigin={this.props.crossOrigin || process.crossOrigin}
noModule={true}
dangerouslySetInnerHTML={{
__html: NextScript.safariNomoduleFix,
}}
/>
) : null}
{page !== '/_error' && pageScript}
{appScript}
{staticMarkup ? null : this.getDynamicChunks()}
{staticMarkup ? null : this.getScripts()}
</>
)
}
}
function getAmpPath(ampPath: string, asPath: string) {
return ampPath ? ampPath : `${asPath}${asPath.includes('?') ? '&' : '?'}amp=1`
}
function getPageFile(page: string, buildId?: string) {
if (page === '/') {
return buildId ? `/index.${buildId}.js` : '/index.js'
}
return buildId ? `${page}.${buildId}.js` : `${page}.js`
}