Add prerender PageConfig option (#7699)

* Add prerender PageConfig option

* Update PageConfig type

* Add inlining of data when pre-render is set and add tests

* Update types import

* Add check for props

* Rename prerender to experimentalPrerender for now
This commit is contained in:
JJ Kasper 2019-07-01 14:13:52 -07:00 committed by GitHub
parent 0faf693ed2
commit 0ca8087565
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 406 additions and 59 deletions

View file

@ -377,6 +377,10 @@ export default class Router implements BaseRouter {
return new Promise((resolve, reject) => {
const ctx = { pathname, query, asPath: as }
this.getInitialProps(Component, ctx).then(props => {
// if data is inlined during pre-render it is a string
if (props && typeof props.pageProps === 'string') {
props.pageProps = JSON.parse(props.pageProps)
}
routeInfo.props = props
this.components[route] = routeInfo
resolve(routeInfo)

View file

@ -5,23 +5,16 @@ import {
SERVER_DIRECTORY,
} from '../lib/constants'
import { join } from 'path'
import { PageConfig } from 'next-server/types'
import { requirePage } from './require'
export function interopDefault(mod: any) {
return mod.default || mod
}
export interface IPageConfig {
amp?: boolean | 'hybrid'
api?: {
bodyParser?: boolean
}
}
export type LoadComponentsReturnType = {
Component: any
PageConfig: IPageConfig
pageConfig: PageConfig
buildManifest?: any
reactLoadableManifest?: any
Document?: any
@ -37,7 +30,7 @@ export async function loadComponents(
): Promise<LoadComponentsReturnType> {
if (serverless) {
const Component = await requirePage(pathname, distDir, serverless)
return { Component, PageConfig: Component.config || {} }
return { Component, pageConfig: Component.config || {} }
}
const documentPath = join(
distDir,
@ -82,6 +75,6 @@ export async function loadComponents(
buildManifest,
DocumentMiddleware,
reactLoadableManifest,
PageConfig: ComponentMod.config || {},
pageConfig: ComponentMod.config || {},
}
}

View file

@ -40,7 +40,6 @@ import {
interopDefault,
loadComponents,
LoadComponentsReturnType,
IPageConfig,
} from './load-components'
import { renderToHTML } from './render'
import { getPagePath } from './require'
@ -48,6 +47,7 @@ import Router, { route, Route, RouteMatch, Params } from './router'
import { sendHTML } from './send-html'
import { serveStatic } from './serve-static'
import { isBlockedPage, isInternalUrl } from './utils'
import { PageConfig } from 'next-server/types'
type NextConfig = any
@ -317,7 +317,7 @@ export default class Server {
const resolverModule = require(resolverFunction)
if (resolverModule.config) {
const config: IPageConfig = resolverModule.config
const config: PageConfig = resolverModule.config
if (config.api && config.api.bodyParser === false) {
bodyParser = false
}
@ -516,7 +516,6 @@ export default class Server {
return renderToHTML(req, res, pathname, query, {
...result,
...opts,
PageConfig: result.PageConfig,
})
}

View file

@ -28,7 +28,7 @@ import { getPageFiles, BuildManifest } from './get-page-files'
import { AmpStateContext } from '../lib/amp-context'
import optimizeAmp from './optimize-amp'
import { isInAmpMode } from '../lib/amp'
import { IPageConfig } from './load-components'
import { PageConfig } from 'next-server/types'
export type ManifestItem = {
id: number | string
@ -145,13 +145,15 @@ type RenderOpts = {
hybridAmp?: boolean
buildManifest: BuildManifest
reactLoadableManifest: ReactLoadableManifest
PageConfig: IPageConfig
pageConfig: PageConfig
Component: React.ComponentType
Document: DocumentType
DocumentMiddleware: (ctx: NextPageContext) => void
App: AppType
ErrorDebug?: React.ComponentType<{ error: Error }>
ampValidator?: (html: string, pathname: string) => Promise<void>
isPrerender?: boolean
pageData?: any
}
function renderDocument(
@ -250,7 +252,7 @@ export async function renderToHTML(
ampPath = '',
App,
Document,
PageConfig,
pageConfig,
DocumentMiddleware,
Component,
buildManifest,
@ -259,7 +261,7 @@ export async function renderToHTML(
} = renderOpts
await Loadable.preloadAll() // Make sure all dynamic imports are loaded
let isStaticPage = false
let isStaticPage = Boolean(pageConfig.experimentalPrerender)
if (dev) {
const { isValidElementType } = require('react-is')
@ -336,9 +338,9 @@ export async function renderToHTML(
}
const ampState = {
ampFirst: PageConfig.amp === true,
ampFirst: pageConfig.amp === true,
hasQuery: Boolean(query.amp),
hybrid: PageConfig.amp === 'hybrid',
hybrid: pageConfig.amp === 'hybrid',
}
const reactLoadableModules: string[] = []
@ -490,9 +492,13 @@ export async function renderToHTML(
const inAmpMode = isInAmpMode(ampState)
const hybridAmp = ampState.hybrid
// update renderOpts so export knows it's AMP state
// update renderOpts so export knows current state
renderOpts.inAmpMode = inAmpMode
renderOpts.hybridAmp = hybridAmp
renderOpts.pageData = props && props.pageProps
renderOpts.isPrerender =
pageConfig.experimentalPrerender === true ||
pageConfig.experimentalPrerender === 'inline'
let html = renderDocument(Document, {
...renderOpts,

View file

@ -1 +1,10 @@
declare module 'react-ssr-prepass'
/**
* `Config` type, use it for export const config
*/
export type PageConfig = {
amp?: boolean | 'hybrid'
api?: {
bodyParser?: boolean
}
experimentalPrerender?: boolean | 'inline' | 'legacy'
}

View file

@ -1,12 +1,14 @@
import { PluginObj } from '@babel/core'
import { NodePath } from '@babel/traverse'
import * as BabelTypes from '@babel/types'
import { PageConfig } from 'next-server/types'
interface PageConfig {
amp?: boolean | 'hybrid'
}
const configKeys = new Set(['amp', 'experimentalPrerender'])
export const inlineGipIdentifier = '__NEXT_GIP_INLINE__'
export const dropBundleIdentifier = '__NEXT_DROP_CLIENT_FILE__'
function replacePath(path: any, t: typeof BabelTypes) {
// replace progam path with just a variable with the drop identifier
function replaceBundle(path: any, t: typeof BabelTypes) {
path.parentPath.replaceWith(
t.program(
[
@ -15,8 +17,8 @@ function replacePath(path: any, t: typeof BabelTypes) {
t.identifier('config'),
t.assignmentExpression(
'=',
t.identifier('no'), // this can't be empty but is required
t.stringLiteral(`__NEXT_DROP_CLIENT_FILE__ ${Date.now()}`)
t.identifier(dropBundleIdentifier),
t.stringLiteral(`${dropBundleIdentifier} ${Date.now()}`)
)
),
]),
@ -26,18 +28,20 @@ function replacePath(path: any, t: typeof BabelTypes) {
)
}
interface ConfigState {
setupInlining?: boolean
bundleDropped?: boolean
}
export default function nextPageConfig({
types: t,
}: {
types: typeof BabelTypes
}): PluginObj<{
insertedDrop?: boolean
hasAmp?: boolean
}> {
}): PluginObj {
return {
visitor: {
Program: {
enter(path, state: any) {
enter(path, state: ConfigState) {
path.traverse(
{
ExportNamedDeclaration(
@ -45,7 +49,7 @@ export default function nextPageConfig({
state: any
) {
if (
state.replaced ||
state.bundleDropped ||
!path.node.declaration ||
!(path.node.declaration as any).declarations
)
@ -58,13 +62,24 @@ export default function nextPageConfig({
for (const prop of declaration.init.properties) {
const { name } = prop.key
if (name === 'amp') config.amp = prop.value.value
if (configKeys.has(name)) {
// @ts-ignore
config[name] = prop.value.value
}
}
}
if (config.amp === true) {
replacePath(path, t)
state.replaced = true
replaceBundle(path, t)
state.bundleDropped = true
return
}
if (
config.experimentalPrerender === true ||
config.experimentalPrerender === 'inline'
) {
state.setupInlining = true
}
},
},
@ -72,6 +87,29 @@ export default function nextPageConfig({
)
},
},
// handles Page.getInitialProps = () => {}
AssignmentExpression(path, state: ConfigState) {
if (!state.setupInlining) return
const { property } = (path.node.left || {}) as any
const { name } = property
if (name !== 'getInitialProps') return
// replace the getInitialProps function with an identifier for replacing
path.node.right = t.functionExpression(
null,
[],
t.blockStatement([
t.returnStatement(t.stringLiteral(inlineGipIdentifier)),
])
)
},
// handles class { static async getInitialProps() {} }
FunctionDeclaration(path, state: ConfigState) {
if (!state.setupInlining) return
if ((path.node.id && path.node.id.name) !== 'getInitialProps') return
path.node.body = t.blockStatement([
t.returnStatement(t.stringLiteral(inlineGipIdentifier)),
])
},
},
}
}

View file

@ -334,7 +334,7 @@ export default async function build(dir: string, conf = null): Promise<void> {
}
}
if (customAppGetInitialProps === false && nonReservedPage) {
if (nonReservedPage) {
try {
await staticCheckSema.acquire()
const result: any = await new Promise((resolve, reject) => {
@ -348,7 +348,10 @@ export default async function build(dir: string, conf = null): Promise<void> {
})
staticCheckSema.release()
if (result.isStatic) {
if (
(result.static && customAppGetInitialProps === false) ||
result.prerender
) {
staticPages.add(page)
isStatic = true
}

View file

@ -6,7 +6,7 @@ export default function worker(
) {
try {
const { serverBundle, runtimeEnvConfig } = options || ({} as any)
const isStatic = isPageStatic(serverBundle, runtimeEnvConfig)
const result = isPageStatic(serverBundle, runtimeEnvConfig)
// clear require.cache to prevent running out of memory
// since the cache is persisted by default
@ -19,7 +19,7 @@ export default function worker(
}
})
callback(null, { isStatic })
callback(null, result)
} catch (error) {
callback(error)
}

View file

@ -266,17 +266,22 @@ export async function getPageSizeInKb(
export function isPageStatic(
serverBundle: string,
runtimeEnvConfig: any
): boolean {
): { static?: boolean; prerender?: boolean } {
try {
nextEnvConfig.setConfig(runtimeEnvConfig)
const Comp = require(serverBundle).default
const mod = require(serverBundle)
const Comp = mod.default
if (!Comp || !isValidElementType(Comp) || typeof Comp === 'string') {
throw new Error('INVALID_DEFAULT_EXPORT')
}
return typeof (Comp as any).getInitialProps !== 'function'
return {
static: typeof (Comp as any).getInitialProps !== 'function',
prerender: mod.config && mod.config.experimentalPrerender,
}
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') return false
if (err.code === 'MODULE_NOT_FOUND') return {}
throw err
}
}

View file

@ -77,7 +77,7 @@ module.exports = babelLoader.custom(babel => {
options.presets = [...options.presets, presetItem]
}
if (!isServer && filename.indexOf('pages') !== -1) {
if (!isServer) {
const pageConfigPlugin = babel.createConfigItem(
[require('../../babel/plugins/next-page-config')],
{ type: 'plugin' }

View file

@ -55,7 +55,7 @@ const nextServerlessLoader: loader.Loader = function() {
import * as ComponentInfo from '${absolutePagePath}';
const Component = ComponentInfo.default
export default Component
export const PageConfig = ComponentInfo['confi' + 'g'] || {}
export const config = ComponentInfo['confi' + 'g'] || {}
export const _app = App
export async function renderReqToHTML(req, res, fromExport) {
const options = {
@ -74,7 +74,7 @@ const nextServerlessLoader: loader.Loader = function() {
const renderOpts = Object.assign(
{
Component,
PageConfig,
pageConfig: config,
dataOnly: req.headers && (req.headers.accept || '').indexOf('application/amp.bind+json') !== -1,
nextExport: fromExport
},

View file

@ -2,14 +2,16 @@ import mkdirpModule from 'mkdirp'
import { promisify } from 'util'
import { extname, join, dirname, sep } from 'path'
import { renderToHTML } from 'next-server/dist/server/render'
import { writeFile, access } from 'fs'
import { writeFile, access, readFile } from 'fs'
import { Sema } from 'async-sema'
import AmpHtmlValidator from 'amphtml-validator'
import { loadComponents } from 'next-server/dist/server/load-components'
import { inlineGipIdentifier } from '../build/babel/plugins/next-page-config'
const envConfig = require('next-server/config')
const mkdirp = promisify(mkdirpModule)
const writeFileP = promisify(writeFile)
const readFileP = promisify(readFile)
const accessP = promisify(access)
global.__NEXT_DATA__ = {
@ -106,6 +108,27 @@ process.on(
}
}
// inline pageData for getInitialProps
if (curRenderOpts.isPrerender && curRenderOpts.pageData) {
const dataStr = JSON.stringify(curRenderOpts.pageData)
.replace(/"/g, '\\"')
.replace(/'/g, "\\'")
const bundlePath = join(
distDir,
'static',
buildId,
'pages',
(path === '/' ? 'index' : path) + '.js'
)
const bundleContent = await readFileP(bundlePath, 'utf8')
await writeFileP(
bundlePath,
bundleContent.replace(inlineGipIdentifier, dataStr)
)
}
const validateAmp = async (html, page) => {
const validator = await AmpHtmlValidator.getInstance()
const result = validator.validateString(html)

View file

@ -11,6 +11,8 @@ import {
NextApiRequest,
} from 'next-server/dist/lib/utils'
import { PageConfig } from 'next-server/types'
// Extend the React types with missing properties
declare module 'react' {
// <html amp=""> support
@ -43,14 +45,10 @@ export type NextPage<P = {}, IP = P> = {
getInitialProps?(ctx: NextPageContext): Promise<IP>
}
/**
* `Config` type, use it for export const config
*/
export type PageConfig = {
amp?: boolean | 'hybrid'
api?: {
bodyParser?: boolean
}
export {
NextPageContext,
NextComponentType,
NextApiResponse,
NextApiRequest,
PageConfig,
}
export { NextPageContext, NextComponentType, NextApiResponse, NextApiRequest }

View file

@ -0,0 +1,5 @@
module.exports = {
experimental: {
autoExport: true
}
}

View file

@ -0,0 +1,7 @@
const Page = ({ name }) => <p>Pre-render page {name}</p>
Page.getInitialProps = async () => ({ name: 'John Deux' })
export const config = { experimentalPrerender: true }
export default Page

View file

@ -0,0 +1 @@
export default () => <p>An autoExported page</p>

View file

@ -0,0 +1,7 @@
export const config = { experimentalPrerender: true }
const Page = ({ title }) => <p>{title}</p>
Page.getInitialProps = async () => ({ title: 'something' })
export default Page

View file

@ -0,0 +1,5 @@
const Page = () => <p>I'm just an old SSR page</p>
Page.getInitialProps = () => ({})
export default Page

View file

@ -0,0 +1,5 @@
const Page = () => <p>I'm just an old SSR page</p>
Page.getInitialProps = () => ({})
export default Page

View file

@ -0,0 +1,85 @@
/* eslint-env jest */
/* global jasmine */
import fs from 'fs-extra'
import path from 'path'
import {
nextBuild,
nextStart,
findPort,
killApp,
renderViaHTTP
} from 'next-test-utils'
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 3
const appDir = path.join(__dirname, '../')
let buildId
let appPort
let app
describe('Pre-rendering pages', () => {
beforeAll(async () => {
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
buildId = (
(await fs.readFile(path.join(appDir, '.next/BUILD_ID'), 'utf8')) || ''
).trim()
})
afterAll(() => killApp(app))
it('should render the correct files', async () => {
let files = ['nested/hello', 'another', 'index']
for (const file of files) {
expect(
await fs.exists(
path.join(
appDir,
'.next/server/static',
buildId,
'pages',
file + '.html'
)
)
).toBe(true)
}
files = ['nested/old-school', 'old-school']
for (const file of files) {
expect(
await fs.exists(
path.join(
appDir,
'.next/server/static',
buildId,
'pages',
file + '.js'
)
)
).toBe(true)
}
})
it('should have called getInitialProps during pre-render', async () => {
const hello = await renderViaHTTP(appPort, '/nested/hello')
expect(hello).toMatch(/something/)
const another = await renderViaHTTP(appPort, '/another')
expect(another).toMatch(/John Deux/)
})
it('should call getInitialProps for SSR pages', async () => {
const oldSchool1 = await renderViaHTTP(appPort, '/old-school')
expect(oldSchool1).toMatch(/I.*?m just an old SSR page/)
const oldSchool2 = await renderViaHTTP(appPort, '/nested/old-school')
expect(oldSchool2).toMatch(/I.*?m just an old SSR page/)
})
it('should autoExport correctly', async () => {
const index = await renderViaHTTP(appPort, '/')
expect(index).toMatch(/An autoExported page/)
})
})

View file

@ -0,0 +1,27 @@
import Link from 'next/link'
export const config = {
experimentalPrerender: 'inline'
}
const Page = ({ data }) => {
return (
<>
<h3>{data}</h3>
<Link href='/to-something'>
<a id='to-something'>Click to to-something</a>
</Link>
</>
)
}
Page.getInitialProps = async () => {
if (typeof window !== 'undefined') {
throw new Error(`this shouldn't be called`)
}
return {
data: 'this is some data to be inlined!!!'
}
}
export default Page

View file

@ -0,0 +1,30 @@
import React from 'react'
import Link from 'next/link'
export const config = {
experimentalPrerender: true
}
class Page extends React.Component {
static async getInitialProps () {
if (typeof window !== 'undefined') {
throw new Error(`this shouldn't be called`)
}
return {
title: 'some interesting title'
}
}
render () {
return (
<>
<h3>{this.props.title}</h3>
<Link href='/something'>
<a id='something'>Click to something</a>
</Link>
</>
)
}
}
export default Page

View file

@ -468,6 +468,26 @@ describe('Production Usage', () => {
}
})
it('should pre-render pages with data correctly', async () => {
const toSomething = await renderViaHTTP(appPort, '/to-something')
expect(toSomething).toMatch(/some interesting title/)
const something = await renderViaHTTP(appPort, '/something')
expect(something).toMatch(/this is some data to be inlined/)
})
it('should have inlined the data correctly in pre-render', async () => {
const browser = await webdriver(appPort, '/to-something')
await browser.elementByCss('#something').click()
let text = await browser.elementByCss('h3').text()
expect(text).toMatch(/this is some data to be inlined/)
await browser.elementByCss('#to-something').click()
text = await browser.elementByCss('h3').text()
expect(text).toMatch(/some interesting title/)
})
dynamicImportTests(context, (p, q) => renderViaHTTP(context.appPort, p, q))
processEnv(context)

View file

@ -0,0 +1,27 @@
import Link from 'next/link'
export const config = {
experimentalPrerender: 'inline'
}
const Page = ({ data }) => {
return (
<>
<h3>{data}</h3>
<Link href='/to-something'>
<a id='to-something'>Click to to-something</a>
</Link>
</>
)
}
Page.getInitialProps = async () => {
if (typeof window !== 'undefined') {
throw new Error(`this shouldn't be called`)
}
return {
data: 'this is some data to be inlined!!!'
}
}
export default Page

View file

@ -0,0 +1,30 @@
import React from 'react'
import Link from 'next/link'
export const config = {
experimentalPrerender: true
}
class Page extends React.Component {
static async getInitialProps () {
if (typeof window !== 'undefined') {
throw new Error(`this shouldn't be called`)
}
return {
title: 'some interesting title'
}
}
render () {
return (
<>
<h3>{this.props.title}</h3>
<Link href='/something'>
<a id='something'>Click to something</a>
</Link>
</>
)
}
}
export default Page

View file

@ -113,6 +113,26 @@ describe('Serverless', () => {
}
})
it('should pre-render pages with data correctly', async () => {
const toSomething = await renderViaHTTP(appPort, '/to-something')
expect(toSomething).toMatch(/some interesting title/)
const something = await renderViaHTTP(appPort, '/something')
expect(something).toMatch(/this is some data to be inlined/)
})
it('should have inlined the data correctly in pre-render', async () => {
const browser = await webdriver(appPort, '/to-something')
await browser.elementByCss('#something').click()
let text = await browser.elementByCss('h3').text()
expect(text).toMatch(/this is some data to be inlined/)
await browser.elementByCss('#to-something').click()
text = await browser.elementByCss('h3').text()
expect(text).toMatch(/some interesting title/)
})
describe('With basic usage', () => {
it('should allow etag header support', async () => {
const url = `http://localhost:${appPort}/`