amp-bind data injection (#6840)

This commit is contained in:
Tim Neutkens 2019-04-02 16:09:34 +02:00 committed by GitHub
parent 84fbd4b594
commit b1fdffec75
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 385 additions and 31 deletions

View file

@ -62,6 +62,7 @@
"@babel/preset-flow": "7.0.0",
"@babel/preset-react": "7.0.0",
"@mdx-js/loader": "0.18.0",
"@types/string-hash": "1.1.1",
"@zeit/next-css": "1.0.2-canary.2",
"@zeit/next-sass": "1.0.2-canary.2",
"@zeit/next-typescript": "1.1.2-canary.0",

View file

@ -0,0 +1,3 @@
import * as React from 'react'
export const DataManagerContext: React.Context<any> = React.createContext(null)

View file

@ -0,0 +1,21 @@
export class DataManager {
data: Map<string, any>
constructor(data?: any) {
this.data = new Map(data)
}
getData() {
return this.data
}
get(key: string) {
return this.data.get(key)
}
set(key: string, value: any) {
this.data.set(key, value)
}
overwrite(data: any) {
this.data = new Map(data)
}
}

View file

@ -0,0 +1,3 @@
import * as React from 'react'
export const RouterContext: React.Context<any> = React.createContext(null)

View file

@ -27,6 +27,7 @@ const defaultConfig = {
(Number(process.env.CIRCLE_NODE_TOTAL) ||
(os.cpus() || { length: 1 }).length) - 1
),
ampBindInitData: false,
exportTrailingSlash: true,
profiling: false,
sharedRuntime: false

View file

@ -28,6 +28,8 @@ type ServerConstructor = {
conf?: NextConfig,
}
const ENDING_IN_JSON_REGEX = /\.json$/
export default class Server {
dir: string
quiet: boolean
@ -36,6 +38,7 @@ export default class Server {
buildId: string
renderOpts: {
ampEnabled: boolean
ampBindInitData: boolean
staticMarkup: boolean
buildId: string
generateEtags: boolean
@ -74,6 +77,7 @@ export default class Server {
this.buildId = this.readBuildId()
this.renderOpts = {
ampEnabled: this.nextConfig.experimental.amp,
ampBindInitData: this.nextConfig.experimental.ampBindInitData,
staticMarkup,
buildId: this.buildId,
generateEtags,
@ -260,12 +264,19 @@ export default class Server {
return this.handleRequest(req, res, parsedUrl)
}
const isDataRequest = ENDING_IN_JSON_REGEX.test(pathname)
if (isDataRequest) {
pathname = pathname.replace(ENDING_IN_JSON_REGEX, '')
}
if (isBlockedPage(pathname)) {
return this.render404(req, res, parsedUrl)
}
const html = await this.renderToHTML(req, res, pathname, query, {
amphtml: query.amp && this.nextConfig.experimental.amp,
dataOnly: isDataRequest,
})
// Request was ended by the user
if (html === null) {
@ -291,9 +302,10 @@ export default class Server {
res: ServerResponse,
pathname: string,
query: ParsedUrlQuery = {},
{ amphtml, hasAmp }: {
{ amphtml, dataOnly, hasAmp }: {
amphtml?: boolean,
hasAmp?: boolean,
dataOnly?: boolean,
} = {},
): Promise<string | null> {
try {
@ -303,7 +315,7 @@ export default class Server {
res,
pathname,
query,
{ ...this.renderOpts, amphtml, hasAmp },
{ ...this.renderOpts, amphtml, hasAmp, dataOnly },
)
return html
} catch (err) {

View file

@ -7,7 +7,11 @@ import mitt, {MittEmitter} from '../lib/mitt';
import { loadGetInitialProps, isResSent } from '../lib/utils'
import Head, { defaultHead } from '../lib/head'
import Loadable from '../lib/loadable'
import { DataManagerContext } from '../lib/data-manager-context'
import {LoadableContext} from '../lib/loadable-context'
import { RouterContext } from '../lib/router-context'
import { DataManager } from '..//lib/data-manager'
import {
getDynamicImportBundles,
Manifest as ReactLoadableManifest,
@ -107,6 +111,7 @@ function render(
type RenderOpts = {
ampEnabled: boolean
ampBindInitData: boolean
staticMarkup: boolean
buildId: string
runtimeConfig?: { [key: string]: any }
@ -116,7 +121,8 @@ type RenderOpts = {
dev?: boolean
ampPath?: string
amphtml?: boolean
hasAmp?: boolean
hasAmp?: boolean,
dataOnly?: boolean,
buildManifest: BuildManifest
reactLoadableManifest: ReactLoadableManifest
Component: React.ComponentType
@ -128,6 +134,7 @@ type RenderOpts = {
function renderDocument(
Document: React.ComponentType,
{
dataManagerData,
ampEnabled = false,
props,
docProps,
@ -148,6 +155,7 @@ function renderDocument(
files,
dynamicImports,
}: RenderOpts & {
dataManagerData: any,
props: any
docProps: any
pathname: string
@ -167,6 +175,7 @@ function renderDocument(
<IsAmpContext.Provider value={amphtml}>
<Document
__NEXT_DATA__={{
dataManager: dataManagerData,
props, // The result of getInitialProps
page: pathname, // The rendered page
query, // querystring parsed / passed by the user
@ -175,7 +184,7 @@ function renderDocument(
runtimeConfig, // runtimeConfig if provided, otherwise don't sent in the resulting HTML
nextExport, // If this is a page exported by `next export`
dynamicIds:
dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds,
dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds,
err: err ? serializeError(dev, err) : undefined, // Error if one happened, otherwise don't sent in the resulting HTML
}}
ampEnabled={ampEnabled}
@ -205,6 +214,7 @@ export async function renderToHTML(
const {
err,
dev = false,
ampBindInitData = false,
staticMarkup = false,
amphtml = false,
hasAmp = false,
@ -264,15 +274,79 @@ export async function renderToHTML(
...getPageFiles(buildManifest, '/_app'),
]),
]
let dataManager: DataManager | undefined
if (ampBindInitData) {
dataManager = new DataManager()
}
const reactLoadableModules: string[] = []
const renderPage = (
const renderElementToString = staticMarkup
? renderToStaticMarkup
: renderToString
let renderPage: (options: ComponentsEnhancer) => { html: string, head: any } | Promise<{ html: string; head: any }>
if (ampBindInitData) {
renderPage = async (
options: ComponentsEnhancer = {},
): Promise<{ html: string; head: any }> => {
if (ctx.err && ErrorDebug) {
return render(renderElementToString, <ErrorDebug error={ctx.err} />)
}
if (dev && (props.router || props.Component)) {
throw new Error(
`'router' and 'Component' can not be returned in getInitialProps from _app.js https://err.sh/zeit/next.js/cant-override-next-props.md`,
)
}
const {
App: EnhancedApp,
Component: EnhancedComponent,
} = enhanceComponents(options, App, Component)
let recursionCount = 0
const renderTree = async (): Promise<any> => {
recursionCount++
// This is temporary, we can remove it once the API is finished.
if (recursionCount > 100) {
throw new Error('Did 100 promise recursions, bailing out to avoid infinite loop.')
}
try {
return await render(
renderElementToString,
<RouterContext.Provider value={router}>
<DataManagerContext.Provider value={dataManager}>
<IsAmpContext.Provider value={amphtml}>
<LoadableContext.Provider
value={(moduleName) => reactLoadableModules.push(moduleName)}
>
<EnhancedApp
Component={EnhancedComponent}
router={router}
{...props}
/>
</LoadableContext.Provider>
</IsAmpContext.Provider>
</DataManagerContext.Provider>
</RouterContext.Provider>,
)
} catch (err) {
if (typeof err.then !== 'undefined') {
await err
return await renderTree()
}
throw err
}
}
const res = await renderTree()
return res
}
} else {
renderPage = (
options: ComponentsEnhancer = {},
): { html: string; head: any } => {
const renderElementToString = staticMarkup
? renderToStaticMarkup
: renderToString
if (ctx.err && ErrorDebug) {
return render(renderElementToString, <ErrorDebug error={ctx.err} />)
}
@ -290,19 +364,22 @@ export async function renderToHTML(
return render(
renderElementToString,
<IsAmpContext.Provider value={amphtml}>
<LoadableContext.Provider
value={(moduleName) => reactLoadableModules.push(moduleName)}
>
<EnhancedApp
Component={EnhancedComponent}
router={router}
{...props}
/>
</LoadableContext.Provider>
</IsAmpContext.Provider>,
<RouterContext.Provider value={router}>
<IsAmpContext.Provider value={amphtml}>
<LoadableContext.Provider
value={(moduleName) => reactLoadableModules.push(moduleName)}
>
<EnhancedApp
Component={EnhancedComponent}
router={router}
{...props}
/>
</LoadableContext.Provider>
</IsAmpContext.Provider>
</RouterContext.Provider>,
)
}
}
const docProps = await loadGetInitialProps(Document, { ...ctx, renderPage })
// the response might be finished on the getInitialProps call
@ -313,8 +390,18 @@ export async function renderToHTML(
]
const dynamicImportsIds: any = dynamicImports.map((bundle) => bundle.id)
let dataManagerData = '[]'
if (dataManager) {
dataManagerData = JSON.stringify([...dataManager.getData()])
}
if (renderOpts.dataOnly) {
return dataManagerData
}
return renderDocument(Document, {
...renderOpts,
dataManagerData,
props,
docProps,
pathname,

View file

@ -0,0 +1,51 @@
import {PluginObj} from '@babel/core'
import {NodePath} from '@babel/traverse'
import * as BabelTypes from '@babel/types'
export default function ({ types: t }: {types: typeof BabelTypes}): PluginObj {
return {
visitor: {
ImportDeclaration (path: NodePath<BabelTypes.ImportDeclaration>, state) {
const source = path.node.source.value
if (source !== 'next/data') return
const createHookSpecifier = path.get('specifiers').find(specifier => {
return specifier.isImportSpecifier() && specifier.node.imported.name === 'createHook'
})
if (!createHookSpecifier) return
const bindingName = createHookSpecifier.node.local.name
const binding = path.scope.getBinding(bindingName)
if (!binding) {
return
}
binding.referencePaths.forEach(refPath => {
let callExpression = refPath.parentPath
if (!callExpression.isCallExpression()) return
let args: any = callExpression.get('arguments')
if (!args[0]) {
throw callExpression.buildCodeFrameError('first argument to createHook should be a function')
}
if (!args[1]) {
callExpression.node.arguments.push(t.objectExpression([]))
}
args = callExpression.get('arguments')
args[1].node.properties.push(t.objectProperty(
t.identifier('key'),
t.stringLiteral(state.opts.key)
))
})
}
}
}
}

View file

@ -225,8 +225,14 @@ export default function getBaseWebpackConfig (dir: string, {dev = false, isServe
...nodePathList // Support for NODE_PATH environment variable
]
},
// @ts-ignore this is filtered
module: {
rules: [
config.experimental.ampBindInitData && !isServer && {
test: /\.(js|mjs|jsx)$/,
include: [path.join(dir, 'data')],
use: 'next-data-loader'
},
{
test: /\.(js|mjs|jsx)$/,
include: [dir, /next-server[\\/]dist[\\/]lib/],

View file

@ -1,4 +1,6 @@
import babelLoader from 'babel-loader'
import hash from 'string-hash'
import { basename } from 'path'
module.exports = babelLoader.custom(babel => {
const presetItem = babel.createConfigItem(require('../../babel/preset'), { type: 'preset' })
@ -21,6 +23,7 @@ module.exports = babelLoader.custom(babel => {
return { loader, custom }
},
config (cfg, { source, customOptions: { isServer } }) {
const filename = this.resourcePath
const options = Object.assign({}, cfg.options)
if (cfg.hasFilesystemConfig()) {
for (const file of [cfg.babelrc, cfg.config]) {
@ -36,6 +39,12 @@ module.exports = babelLoader.custom(babel => {
options.presets = [...options.presets, presetItem]
}
if (isServer && source.indexOf('next/data') !== -1) {
const nextDataPlugin = babel.createConfigItem([require('../../babel/plugins/next-data'), { key: basename(filename) + '-' + hash(filename) }], { type: 'plugin' })
options.plugins = options.plugins || []
options.plugins.push(nextDataPlugin)
}
// If the file has `module.exports` we have to transpile commonjs because Babel adds `import` statements
// That break webpack, since webpack doesn't support combining commonjs and esmodules
if (source.indexOf('module.exports') !== -1) {

View file

@ -0,0 +1,13 @@
import {loader} from 'webpack'
import hash from 'string-hash'
import {basename} from 'path'
const nextDataLoader: loader.Loader = function (source) {
const filename = this.resourcePath
return `
import {createHook} from 'next/data'
export default createHook(undefined, {key: ${JSON.stringify(basename(filename) + '-' + hash(filename))}})
`
}
export default nextDataLoader

View file

@ -1,7 +1,7 @@
import React from 'react'
import React, { Suspense } from 'react'
import ReactDOM from 'react-dom'
import HeadManager from './head-manager'
import { createRouter } from 'next/router'
import { createRouter, makePublicRouterInstance } from 'next/router'
import mitt from 'next-server/dist/lib/mitt'
import { loadGetInitialProps, getURL } from 'next-server/dist/lib/utils'
import PageLoader from './page-loader'
@ -9,6 +9,9 @@ import * as envConfig from 'next-server/config'
import { ErrorBoundary } from './error-boundary'
import Loadable from 'next-server/dist/lib/loadable'
import { HeadManagerContext } from 'next-server/dist/lib/head-manager-context'
import { DataManagerContext } from 'next-server/dist/lib/data-manager-context'
import { RouterContext } from 'next-server/dist/lib/router-context'
import { DataManager } from 'next-server/dist/lib/data-manager'
// Polyfill Promise globally
// This is needed because Webpack's dynamic loading(common chunks) code
@ -33,6 +36,9 @@ const {
dynamicIds
} = data
const d = JSON.parse(window.__NEXT_DATA__.dataManager)
export const dataManager = new DataManager(d)
const prefix = assetPrefix || ''
// With dynamic assetPrefix it's no longer possible to set assetPrefix at the build time
@ -183,17 +189,25 @@ async function doRender ({ App, Component, props, err }) {
// In development runtime errors are caught by react-error-overlay.
if (process.env.NODE_ENV === 'development') {
renderReactElement((
<HeadManagerContext.Provider value={headManager.updateHead}>
<App {...appProps} />
</HeadManagerContext.Provider>
<Suspense fallback={<div>Loading...</div>}>
<RouterContext.Provider value={makePublicRouterInstance(router)}>
<DataManagerContext.Provider value={dataManager}>
<HeadManagerContext.Provider value={headManager.updateHead}>
<App {...appProps} />
</HeadManagerContext.Provider>
</DataManagerContext.Provider>
</RouterContext.Provider>
</Suspense>
), appContainer)
} else {
// In production we catch runtime errors using componentDidCatch which will trigger renderError.
renderReactElement((
<ErrorBoundary fn={(error) => renderError({ App, err: error }).catch(err => console.error('Error rendering page: ', err))}>
<HeadManagerContext.Provider value={headManager.updateHead}>
<App {...appProps} />
</HeadManagerContext.Provider>
<Suspense fallback={<div>Loading...</div>}>
<HeadManagerContext.Provider value={headManager.updateHead}>
<App {...appProps} />
</HeadManagerContext.Provider>
</Suspense>
</ErrorBoundary>
), appContainer)
}

1
packages/next/data.js Normal file
View file

@ -0,0 +1 @@
module.exports = require('./dist/lib/data')

34
packages/next/lib/data.ts Normal file
View file

@ -0,0 +1,34 @@
import {useContext} from 'react'
import { DataManagerContext } from 'next-server/dist/lib/data-manager-context'
import { RouterContext } from 'next-server/dist/lib/router-context'
import fetch from 'unfetch'
export function createHook(fetcher: () => Promise<any>, options: {key: string}) {
if (!options.key) {
throw new Error('key not provided to createHook options.')
}
return function useData() {
const router: import('next-server/lib/router/router').default = useContext(RouterContext)
const dataManager: import('next-server/lib/data-manager').DataManager = useContext(DataManagerContext)
const existing = dataManager.get(options.key)
if (existing && existing.status === 'resolved') {
return existing.result
}
// @ts-ignore webpack optimization
if (process.browser) {
const res = fetch(router.route === '/' ? 'index.json' : router.route + '.json').then((res: any) => res.json()).then((result: any) => {
dataManager.overwrite(result)
})
throw res
} else {
const res = fetcher().then((result) => {
dataManager.set(options.key, {
status: 'resolved',
result,
})
})
throw res
}
}
}

View file

@ -74,6 +74,7 @@
"recursive-copy": "2.0.6",
"serialize-javascript": "1.6.1",
"source-map": "0.6.1",
"string-hash": "1.1.3",
"strip-ansi": "3.0.1",
"styled-jsx": "3.2.1",
"terser": "3.16.1",

View file

@ -15,8 +15,8 @@ export default class Document extends Component {
_devOnlyInvalidateCacheQueryString: PropTypes.string,
}
static getInitialProps({ renderPage }) {
const { html, head } = renderPage()
static async getInitialProps({ renderPage }) {
const { html, head } = await renderPage()
const styles = flush()
return { html, head, styles }
}

View file

@ -2,6 +2,17 @@ declare module '@babel/plugin-transform-modules-commonjs';
declare module 'next-server/next-config';
declare module 'next-server/constants';
declare module 'webpack/lib/GraphHelpers';
declare module 'unfetch'
declare module 'next-server/dist/lib/data-manager-context' {
import * as all from 'next-server/lib/data-manager-context'
export = all
}
declare module 'next-server/dist/lib/router-context' {
import * as all from 'next-server/lib/router-context'
export = all
}
declare module 'next/dist/compiled/nanoid/index.js' {

View file

@ -0,0 +1,6 @@
import { createHook } from 'next/data'
import os from 'os'
export default createHook(async () => {
return os.uptime()
})

View file

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

View file

@ -0,0 +1,7 @@
import Link from 'next/link'
export default () => {
return <Link href='/'>
<a>home</a>
</Link>
}

View file

@ -0,0 +1,6 @@
import getUptime from '../data/get-uptime'
export default function Index () {
const uptime = getUptime()
return <h1>The uptime of the server is {uptime}</h1>
}

View file

@ -0,0 +1,52 @@
/* eslint-env jest */
/* global jasmine */
import { join } from 'path'
import webdriver from 'next-webdriver'
import {
killApp,
findPort,
launchApp,
renderViaHTTP
} from 'next-test-utils'
const appDir = join(__dirname, '../')
let appPort
let server
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2
describe('AMP Bind Initial Data', () => {
beforeAll(async () => {
appPort = await findPort()
server = await launchApp(appDir, appPort)
})
afterAll(() => killApp(server))
it('responds with json with .json extension on page', async () => {
const data = await renderViaHTTP(appPort, '/index.json')
let isJSON = false
try {
JSON.parse(data)
isJSON = true
} catch (_) {}
expect(isJSON).toBe(true)
})
it('renders the data during SSR', async () => {
const html = await renderViaHTTP(appPort, '/')
expect(html).toMatch(/The uptime of the server is.*?\d.*?\d/)
})
it('renders a page without data', async () => {
const html = await renderViaHTTP(appPort, '/about')
expect(html).toMatch(/<a.*?home/)
})
it('navigates to a page with data correctly', async () => {
const browser = await webdriver(appPort, '/about')
await browser.elementByCss('a').click()
await browser.waitForElementByCss('h1')
const h1Text = await browser.elementByCss('h1').text()
expect(h1Text).toMatch(/The uptime of the server is.*?\d.*?\d/)
await browser.close()
})
})

View file

@ -1679,6 +1679,11 @@
resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9"
integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==
"@types/string-hash@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/string-hash/-/string-hash-1.1.1.tgz#4c336e61d1e13ce2d3efaaa5910005fd080e106b"
integrity sha512-ijt3zdHi2DmZxQpQTmozXszzDo78V4R3EdvX0jFMfnMH2ZzQSmCbaWOMPGXFUYSzSIdStv78HDjg32m5dxc+tA==
"@types/strip-ansi@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/strip-ansi/-/strip-ansi-3.0.0.tgz#9b63d453a6b54aa849182207711a08be8eea48ae"
@ -8560,7 +8565,12 @@ mute-stream@~0.0.4:
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
nan@^2.10.0, nan@^2.9.2:
nan@^2.10.0:
version "2.13.2"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7"
integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==
nan@^2.9.2:
version "2.12.1"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.12.1.tgz#7b1aa193e9aa86057e3c7bbd0ac448e770925552"
integrity sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw==