Add initial support for unstable_getServerProps (#10077)

* Add support for unstable_getServerProps

* Apply suggestions from review

* Add no-cache header and update types

* Revert sharing of load-components type

* Add catchall test and update routes-manifest field

* Update header check

* Update to pass query for getServerProps data requests

* Update to not cache getServerProps requests

* Rename server side props identifier

* Update to nest props for getServerProps

* Add no-cache header in serverless-loader also

* Update to throw error for mixed SSG/serverProps earlier

* Add comment explaining params chosing in serverless-loader

* Update invalidKeysMsg to return a string and inline throwing

* Inline throwing mixed SSG/serverProps error

* Update setting cache header in serverless-loader

* Add separate getServerData method in router

* Update checkIsSSG -> isDataIdentifier

* Refactor router getData back to ternary

* Apply suggestions to build/index.ts

* drop return

* De-dupe extra escape regex

* Add param test
This commit is contained in:
JJ Kasper 2020-01-27 16:50:59 -06:00 committed by Joe Haddad
parent abd69ec4be
commit c24daa2172
26 changed files with 1070 additions and 77 deletions

View file

@ -1,20 +1,25 @@
import { NodePath, PluginObj } from '@babel/core'
import * as BabelTypes from '@babel/types'
import { SERVER_PROPS_SSG_CONFLICT } from '../../../lib/constants'
const pageComponentVar = '__NEXT_COMP'
const prerenderId = '__N_SSG'
const serverPropsId = '__N_SSP'
export const EXPORT_NAME_GET_STATIC_PROPS = 'unstable_getStaticProps'
export const EXPORT_NAME_GET_STATIC_PATHS = 'unstable_getStaticPaths'
export const EXPORT_NAME_GET_SERVER_PROPS = 'unstable_getServerProps'
const ssgExports = new Set([
EXPORT_NAME_GET_STATIC_PROPS,
EXPORT_NAME_GET_STATIC_PATHS,
EXPORT_NAME_GET_SERVER_PROPS,
])
type PluginState = {
refs: Set<NodePath<BabelTypes.Identifier>>
isPrerender: boolean
isServerProps: boolean
done: boolean
}
@ -44,7 +49,7 @@ function decorateSsgExport(
'=',
t.memberExpression(
t.identifier(pageComponentVar),
t.identifier(prerenderId)
t.identifier(state.isPrerender ? prerenderId : serverPropsId)
),
t.booleanLiteral(true)
),
@ -55,6 +60,24 @@ function decorateSsgExport(
})
}
const isDataIdentifier = (name: string, state: PluginState): boolean => {
if (ssgExports.has(name)) {
if (name === EXPORT_NAME_GET_SERVER_PROPS) {
if (state.isPrerender) {
throw new Error(SERVER_PROPS_SSG_CONFLICT)
}
state.isServerProps = true
} else {
if (state.isServerProps) {
throw new Error(SERVER_PROPS_SSG_CONFLICT)
}
state.isPrerender = true
}
return true
}
return false
}
export default function nextTransformSsg({
types: t,
}: {
@ -134,10 +157,11 @@ export default function nextTransformSsg({
enter(_, state) {
state.refs = new Set<NodePath<BabelTypes.Identifier>>()
state.isPrerender = false
state.isServerProps = false
state.done = false
},
exit(path, state) {
if (!state.isPrerender) {
if (!state.isPrerender && !state.isServerProps) {
return
}
@ -239,8 +263,7 @@ export default function nextTransformSsg({
const specifiers = path.get('specifiers')
if (specifiers.length) {
specifiers.forEach(s => {
if (ssgExports.has(s.node.exported.name)) {
state.isPrerender = true
if (isDataIdentifier(s.node.exported.name, state)) {
s.remove()
}
})
@ -259,8 +282,7 @@ export default function nextTransformSsg({
switch (decl.node.type) {
case 'FunctionDeclaration': {
const name = decl.node.id!.name
if (ssgExports.has(name)) {
state.isPrerender = true
if (isDataIdentifier(name, state)) {
path.remove()
}
break
@ -274,8 +296,7 @@ export default function nextTransformSsg({
return
}
const name = d.node.id.name
if (ssgExports.has(name)) {
state.isPrerender = true
if (isDataIdentifier(name, state)) {
d.remove()
}
})

View file

@ -63,6 +63,7 @@ import {
} from './utils'
import getBaseWebpackConfig from './webpack-config'
import { writeBuildId } from './write-build-id'
import escapeStringRegexp from 'escape-string-regexp'
const fsAccess = promisify(fs.access)
const fsUnlink = promisify(fs.unlink)
@ -258,22 +259,23 @@ export default async function build(dir: string, conf = null): Promise<void> {
}
}
const routesManifestPath = path.join(distDir, ROUTES_MANIFEST)
const routesManifest: any = {
version: 1,
basePath: config.experimental.basePath,
redirects: redirects.map(r => buildCustomRoute(r, 'redirect')),
rewrites: rewrites.map(r => buildCustomRoute(r, 'rewrite')),
headers: headers.map(r => buildCustomRoute(r, 'header')),
dynamicRoutes: getSortedRoutes(dynamicRoutes).map(page => ({
page,
regex: getRouteRegex(page).re.source,
})),
}
await mkdirp(distDir)
await fsWriteFile(
path.join(distDir, ROUTES_MANIFEST),
JSON.stringify({
version: 1,
basePath: config.experimental.basePath,
redirects: redirects.map(r => buildCustomRoute(r, 'redirect')),
rewrites: rewrites.map(r => buildCustomRoute(r, 'rewrite')),
headers: headers.map(r => buildCustomRoute(r, 'header')),
dynamicRoutes: getSortedRoutes(dynamicRoutes).map(page => ({
page,
regex: getRouteRegex(page).re.source,
})),
}),
'utf8'
)
// We need to write the manifest with rewrites before build
// so serverless can import the manifest
await fsWriteFile(routesManifestPath, JSON.stringify(routesManifest), 'utf8')
const configs = await Promise.all([
getBaseWebpackConfig(dir, {
@ -405,6 +407,7 @@ export default async function build(dir: string, conf = null): Promise<void> {
const staticPages = new Set<string>()
const invalidPages = new Set<string>()
const hybridAmpPages = new Set<string>()
const serverPropsPages = new Set<string>()
const additionalSsgPaths = new Map<string, Array<string>>()
const pageInfos = new Map<string, PageInfo>()
const pagesManifest = JSON.parse(await fsReadFile(manifestPath, 'utf8'))
@ -502,6 +505,8 @@ export default async function build(dir: string, conf = null): Promise<void> {
additionalSsgPaths.set(page, result.prerenderRoutes)
ssgPageRoutes = result.prerenderRoutes
}
} else if (result.hasServerProps) {
serverPropsPages.add(page)
} else if (result.isStatic && customAppGetInitialProps === false) {
staticPages.add(page)
isStatic = true
@ -525,6 +530,41 @@ export default async function build(dir: string, conf = null): Promise<void> {
)
staticCheckWorkers.end()
if (serverPropsPages.size > 0) {
// We update the routes manifest after the build with the
// serverProps routes since we can't determine this until after build
routesManifest.serverPropsRoutes = {}
for (const page of serverPropsPages) {
const dataRoute = path.posix.join(
'/_next/data',
buildId,
`${page === '/' ? '/index' : page}.json`
)
routesManifest.serverPropsRoutes[page] = {
page,
dataRouteRegex: isDynamicRoute(page)
? getRouteRegex(dataRoute.replace(/\.json$/, '')).re.source.replace(
/\(\?:\\\/\)\?\$$/,
'\\.json$'
)
: new RegExp(
`^${path.posix.join(
'/_next/data',
escapeStringRegexp(buildId),
`${page === '/' ? '/index' : page}.json`
)}$`
).source,
}
}
await fsWriteFile(
routesManifestPath,
JSON.stringify(routesManifest),
'utf8'
)
}
// Since custom _app.js can wrap the 404 page we have to opt-out of static optimization if it has getInitialProps
// Only export the static 404 when there is no /_error present
const useStatic404 =

View file

@ -9,7 +9,11 @@ import {
Rewrite,
getRedirectStatus,
} from '../lib/check-custom-routes'
import { SSG_GET_INITIAL_PROPS_CONFLICT } from '../lib/constants'
import {
SSG_GET_INITIAL_PROPS_CONFLICT,
SERVER_PROPS_GET_INIT_PROPS_CONFLICT,
SERVER_PROPS_SSG_CONFLICT,
} from '../lib/constants'
import prettyBytes from '../lib/pretty-bytes'
import { recursiveReadDir } from '../lib/recursive-readdir'
import { getRouteMatcher, getRouteRegex } from '../next-server/lib/router/utils'
@ -481,6 +485,7 @@ export async function isPageStatic(
): Promise<{
isStatic?: boolean
isHybridAmp?: boolean
hasServerProps?: boolean
hasStaticProps?: boolean
prerenderRoutes?: string[] | undefined
}> {
@ -496,6 +501,7 @@ export async function isPageStatic(
const hasGetInitialProps = !!(Comp as any).getInitialProps
const hasStaticProps = !!mod.unstable_getStaticProps
const hasStaticPaths = !!mod.unstable_getStaticPaths
const hasServerProps = !!mod.unstable_getServerProps
const hasLegacyStaticParams = !!mod.unstable_getStaticParams
if (hasLegacyStaticParams) {
@ -510,6 +516,14 @@ export async function isPageStatic(
throw new Error(SSG_GET_INITIAL_PROPS_CONFLICT)
}
if (hasGetInitialProps && hasServerProps) {
throw new Error(SERVER_PROPS_GET_INIT_PROPS_CONFLICT)
}
if (hasStaticProps && hasServerProps) {
throw new Error(SERVER_PROPS_SSG_CONFLICT)
}
// A page cannot have static parameters if it is not a dynamic page.
if (hasStaticProps && hasStaticPaths && !isDynamicRoute(page)) {
throw new Error(
@ -593,10 +607,11 @@ export async function isPageStatic(
const config = mod.config || {}
return {
isStatic: !hasStaticProps && !hasGetInitialProps,
isStatic: !hasStaticProps && !hasGetInitialProps && !hasServerProps,
isHybridAmp: config.amp === 'hybrid',
prerenderRoutes: prerenderPaths,
hasStaticProps,
hasServerProps,
}
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') return {}

View file

@ -186,6 +186,7 @@ const nextServerlessLoader: loader.Loader = function() {
export const unstable_getStaticProps = ComponentInfo['unstable_getStaticProp' + 's']
export const unstable_getStaticParams = ComponentInfo['unstable_getStaticParam' + 's']
export const unstable_getStaticPaths = ComponentInfo['unstable_getStaticPath' + 's']
export const unstable_getServerProps = ComponentInfo['unstable_getServerProp' + 's']
${dynamicRouteMatcher}
${handleRewrites}
@ -207,6 +208,7 @@ const nextServerlessLoader: loader.Loader = function() {
Document,
buildManifest,
unstable_getStaticProps,
unstable_getServerProps,
unstable_getStaticPaths,
reactLoadableManifest,
canonicalBase: "${canonicalBase}",
@ -237,7 +239,7 @@ const nextServerlessLoader: loader.Loader = function() {
${page === '/_error' ? `res.statusCode = 404` : ''}
${
pageIsDynamicRoute
? `const params = fromExport && !unstable_getStaticProps ? {} : dynamicRouteMatcher(parsedUrl.pathname) || {};`
? `const params = fromExport && !unstable_getStaticProps && !unstable_getServerProps ? {} : dynamicRouteMatcher(parsedUrl.pathname) || {};`
: `const params = {};`
}
${
@ -273,15 +275,22 @@ const nextServerlessLoader: loader.Loader = function() {
`
: `const nowParams = null;`
}
// make sure to set renderOpts to the correct params e.g. _params
// if provided from worker or params if we're parsing them here
renderOpts.params = _params || params
let result = await renderToHTML(req, res, "${page}", Object.assign({}, unstable_getStaticProps ? {} : parsedUrl.query, nowParams ? nowParams : params, _params), renderOpts)
if (_nextData && !fromExport) {
const payload = JSON.stringify(renderOpts.pageData)
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Length', Buffer.byteLength(payload))
res.setHeader(
'Cache-Control',
\`s-maxage=\${renderOpts.revalidate}, stale-while-revalidate\`
unstable_getServerProps
? \`no-cache, no-store, must-revalidate\`
: \`s-maxage=\${renderOpts.revalidate}, stale-while-revalidate\`
)
res.end(payload)
return null
@ -295,6 +304,7 @@ const nextServerlessLoader: loader.Loader = function() {
const result = await renderToHTML(req, res, "/_error", parsedUrl.query, Object.assign({}, options, {
unstable_getStaticProps: undefined,
unstable_getStaticPaths: undefined,
unstable_getServerProps: undefined,
Component: Error
}))
return result
@ -304,6 +314,7 @@ const nextServerlessLoader: loader.Loader = function() {
const result = await renderToHTML(req, res, "/_error", parsedUrl.query, Object.assign({}, options, {
unstable_getStaticProps: undefined,
unstable_getStaticPaths: undefined,
unstable_getServerProps: undefined,
Component: Error,
err
}))

View file

@ -191,7 +191,7 @@ export default async function({
html = components.Component
queryWithAutoExportWarn()
} else {
curRenderOpts = { ...components, ...renderOpts, ampPath }
curRenderOpts = { ...components, ...renderOpts, ampPath, params }
html = await renderMethod(req, res, page, query, curRenderOpts)
}
}

View file

@ -25,3 +25,7 @@ export const DOT_NEXT_ALIAS = 'private-dot-next'
export const PUBLIC_DIR_MIDDLEWARE_CONFLICT = `You can not have a '_next' folder inside of your public folder. This conflicts with the internal '/_next' route. https://err.sh/zeit/next.js/public-next-folder-conflict`
export const SSG_GET_INITIAL_PROPS_CONFLICT = `You can not use getInitialProps with unstable_getStaticProps. To use SSG, please remove your getInitialProps`
export const SERVER_PROPS_GET_INIT_PROPS_CONFLICT = `You can not use getInitialProps with unstable_getServerProps. Please remove one or the other`
export const SERVER_PROPS_SSG_CONFLICT = `You can not use unstable_getStaticProps with unstable_getServerProps. To use SSG, please remove your unstable_getServerProps`

View file

@ -27,6 +27,9 @@ function toRoute(path: string): string {
return path.replace(/\/$/, '') || '/'
}
const prepareRoute = (path: string) =>
toRoute(!path || path === '/' ? '/index' : path)
type Url = UrlObject | string
export type BaseRouter = {
@ -61,6 +64,33 @@ type BeforePopStateCallback = (state: any) => boolean
type ComponentLoadCancel = (() => void) | null
const fetchNextData = (
pathname: string,
query: ParsedUrlQuery | null,
cb?: (...args: any) => any
) => {
return fetch(
formatWithValidation({
// @ts-ignore __NEXT_DATA__
pathname: `/_next/data/${__NEXT_DATA__.buildId}${pathname}.json`,
query,
})
)
.then(res => {
if (!res.ok) {
throw new Error(`Failed to load static props`)
}
return res.json()
})
.then(data => {
return cb ? cb(data) : data
})
.catch((err: Error) => {
;(err as any).code = 'PAGE_LOAD_ERROR'
throw err
})
}
export default class Router implements BaseRouter {
route: string
pathname: string
@ -464,6 +494,8 @@ export default class Router implements BaseRouter {
return this._getData<RouteInfo>(() =>
(Component as any).__N_SSG
? this._getStaticData(as)
: (Component as any).__N_SSP
? this._getServerData(as)
: this.getInitialProps(
Component,
// we provide AppTree later so this needs to be `any`
@ -676,31 +708,18 @@ export default class Router implements BaseRouter {
})
}
_getStaticData = (asPath: string, _cachedData?: object): Promise<object> => {
let pathname = parse(asPath).pathname
pathname = toRoute(!pathname || pathname === '/' ? '/index' : pathname)
_getStaticData = (asPath: string): Promise<object> => {
const pathname = prepareRoute(parse(asPath).pathname!)
return process.env.NODE_ENV === 'production' &&
(_cachedData = this.sdc[pathname])
? Promise.resolve(_cachedData)
: fetch(
// @ts-ignore __NEXT_DATA__
`/_next/data/${__NEXT_DATA__.buildId}${pathname}.json`
)
.then(res => {
if (!res.ok) {
throw new Error(`Failed to load static props`)
}
return res.json()
})
.then(data => {
this.sdc[pathname!] = data
return data
})
.catch((err: Error) => {
;(err as any).code = 'PAGE_LOAD_ERROR'
throw err
})
return process.env.NODE_ENV === 'production' && this.sdc[pathname]
? Promise.resolve(this.sdc[pathname])
: fetchNextData(pathname, null, data => (this.sdc[pathname] = data))
}
_getServerData = (asPath: string): Promise<object> => {
let { pathname, query } = parse(asPath, true)
pathname = prepareRoute(pathname!)
return fetchNextData(pathname, query)
}
getInitialProps(

View file

@ -1,3 +1,5 @@
import { IncomingMessage, ServerResponse } from 'http'
import { ParsedUrlQuery } from 'querystring'
import {
BUILD_MANIFEST,
CLIENT_STATIC_FILES_PATH,
@ -6,7 +8,6 @@ import {
} from '../lib/constants'
import { join } from 'path'
import { requirePage } from './require'
import { ParsedUrlQuery } from 'querystring'
import { BuildManifest } from './get-page-files'
import { AppType, DocumentType } from '../lib/utils'
import { PageConfig, NextPageContext } from 'next/types'
@ -33,6 +34,13 @@ type Unstable_getStaticProps = (params: {
type Unstable_getStaticPaths = () => Promise<Array<string | ParsedUrlQuery>>
type Unstable_getServerProps = (context: {
params: ParsedUrlQuery | undefined
req: IncomingMessage
res: ServerResponse
query: ParsedUrlQuery
}) => Promise<{ [key: string]: any }>
export type LoadComponentsReturnType = {
Component: React.ComponentType
pageConfig?: PageConfig
@ -43,6 +51,7 @@ export type LoadComponentsReturnType = {
App: AppType
unstable_getStaticProps?: Unstable_getStaticProps
unstable_getStaticPaths?: Unstable_getStaticPaths
unstable_getServerProps?: Unstable_getServerProps
}
export async function loadComponents(
@ -106,6 +115,7 @@ export async function loadComponents(
DocumentMiddleware,
reactLoadableManifest,
pageConfig: ComponentMod.config || {},
unstable_getServerProps: ComponentMod.unstable_getServerProps,
unstable_getStaticProps: ComponentMod.unstable_getStaticProps,
unstable_getStaticPaths: ComponentMod.unstable_getStaticPaths,
}

View file

@ -382,7 +382,7 @@ export default class Server {
req,
res,
pathname,
{ _nextDataReq: '1' },
{ ..._parsedUrl.query, _nextDataReq: '1' },
parsedUrl
)
return {
@ -823,7 +823,9 @@ export default class Server {
if (revalidate) {
res.setHeader(
'Cache-Control',
`s-maxage=${revalidate}, stale-while-revalidate`
revalidate < 0
? `no-cache, no-store, must-revalidate`
: `s-maxage=${revalidate}, stale-while-revalidate`
)
} else if (revalidate === false) {
res.setHeader(
@ -865,25 +867,62 @@ export default class Server {
typeof result.Component === 'object' &&
typeof (result.Component as any).renderReqToHTML === 'function'
const isSSG = !!result.unstable_getStaticProps
const isServerProps = !!result.unstable_getServerProps
// Toggle whether or not this is a Data request
const isDataReq = query._nextDataReq
delete query._nextDataReq
// Serverless requests need its URL transformed back into the original
// request path (to emulate lambda behavior in production)
if (isLikeServerless && isDataReq) {
let { pathname } = parseUrl(req.url || '', true)
pathname = !pathname || pathname === '/' ? '/index' : pathname
req.url = formatUrl({
pathname: `/_next/data/${this.buildId}${pathname}.json`,
query,
})
}
// non-spr requests should render like normal
if (!isSSG) {
// handle serverless
if (isLikeServerless) {
if (isDataReq) {
const renderResult = await (result.Component as any).renderReqToHTML(
req,
res,
true
)
this.__sendPayload(
res,
JSON.stringify(renderResult?.renderOpts?.pageData),
'application/json',
-1
)
return null
}
this.prepareServerlessUrl(req, query)
return (result.Component as any).renderReqToHTML(req, res)
}
if (isDataReq && isServerProps) {
const props = await renderToHTML(req, res, pathname, query, {
...result,
...opts,
isDataReq,
})
this.__sendPayload(res, JSON.stringify(props), 'application/json', -1)
return null
}
return renderToHTML(req, res, pathname, query, {
...result,
...opts,
})
}
// Toggle whether or not this is an SPR Data request
const isDataReq = query._nextDataReq
delete query._nextDataReq
// Compute the SPR cache key
const ssgCacheKey = parseUrl(req.url || '').pathname!
@ -909,14 +948,6 @@ export default class Server {
// If we're here, that means data is missing or it's stale.
// Serverless requests need its URL transformed back into the original
// request path (to emulate lambda behavior in production)
if (isLikeServerless && isDataReq) {
let { pathname } = parseUrl(req.url || '', true)
pathname = !pathname || pathname === '/' ? '/index' : pathname
req.url = `/_next/data/${this.buildId}${pathname}.json`
}
const doRender = withCoalescedInvoke(async function(): Promise<{
html: string | null
pageData: any
@ -1033,6 +1064,7 @@ export default class Server {
result,
{
...this.renderOpts,
params,
amphtml,
hasAmp,
}

View file

@ -24,7 +24,11 @@ import { AmpStateContext } from '../lib/amp-context'
import optimizeAmp from './optimize-amp'
import { isInAmpMode } from '../lib/amp'
import { isDynamicRoute } from '../lib/router/utils/is-dynamic'
import { SSG_GET_INITIAL_PROPS_CONFLICT } from '../../lib/constants'
import {
SSG_GET_INITIAL_PROPS_CONFLICT,
SERVER_PROPS_GET_INIT_PROPS_CONFLICT,
SERVER_PROPS_SSG_CONFLICT,
} from '../../lib/constants'
import { AMP_RENDER_TARGET } from '../lib/constants'
import { LoadComponentsReturnType, ManifestItem } from './load-components'
@ -129,6 +133,8 @@ type RenderOpts = LoadComponentsReturnType & {
ErrorDebug?: React.ComponentType<{ error: Error }>
ampValidator?: (html: string, pathname: string) => Promise<void>
documentMiddlewareEnabled?: boolean
isDataReq?: boolean
params?: ParsedUrlQuery
}
function renderDocument(
@ -222,6 +228,14 @@ function renderDocument(
)
}
const invalidKeysMsg = (methodName: string, invalidKeys: string[]) => {
return (
`Additional keys were returned from \`${methodName}\`. Properties intended for your component must be nested under the \`props\` key, e.g.:` +
`\n\n\treturn { props: { title: 'My Title', content: '...' } }` +
`\n\nKeys that need to be moved: ${invalidKeys.join(', ')}.`
)
}
export async function renderToHTML(
req: IncomingMessage,
res: ServerResponse,
@ -246,6 +260,9 @@ export async function renderToHTML(
ErrorDebug,
unstable_getStaticProps,
unstable_getStaticPaths,
unstable_getServerProps,
isDataReq,
params,
} = renderOpts
const callMiddleware = async (method: string, args: any[], props = false) => {
@ -281,7 +298,10 @@ export async function renderToHTML(
const hasPageGetInitialProps = !!(Component as any).getInitialProps
const isAutoExport =
!hasPageGetInitialProps && defaultAppGetInitialProps && !isSpr
!hasPageGetInitialProps &&
defaultAppGetInitialProps &&
!isSpr &&
!unstable_getServerProps
if (
process.env.NODE_ENV !== 'production' &&
@ -301,6 +321,14 @@ export async function renderToHTML(
throw new Error(SSG_GET_INITIAL_PROPS_CONFLICT + ` ${pathname}`)
}
if (hasPageGetInitialProps && unstable_getServerProps) {
throw new Error(SERVER_PROPS_GET_INIT_PROPS_CONFLICT + ` ${pathname}`)
}
if (unstable_getServerProps && isSpr) {
throw new Error(SERVER_PROPS_SSG_CONFLICT + ` ${pathname}`)
}
if (!!unstable_getStaticPaths && !isSpr) {
throw new Error(
`unstable_getStaticPaths was added without a unstable_getStaticProps in ${pathname}. Without unstable_getStaticProps, unstable_getStaticPaths does nothing`
@ -395,7 +423,7 @@ export async function renderToHTML(
if (isSpr) {
const data = await unstable_getStaticProps!({
params: isDynamicRoute(pathname) ? query : undefined,
params: isDynamicRoute(pathname) ? (query as any) : undefined,
})
const invalidKeys = Object.keys(data).filter(
@ -403,11 +431,7 @@ export async function renderToHTML(
)
if (invalidKeys.length) {
throw new Error(
`Additional keys were returned from \`getStaticProps\`. Properties intended for your component must be nested under the \`props\` key, e.g.:` +
`\n\n\treturn { props: { title: 'My Title', content: '...' } }` +
`\n\nKeys that need to be moved: ${invalidKeys.join(', ')}.`
)
throw new Error(invalidKeysMsg('getStaticProps', invalidKeys))
}
if (typeof data.revalidate === 'number') {
@ -452,6 +476,27 @@ export async function renderToHTML(
renderOpts.err = err
}
if (unstable_getServerProps) {
const data = await unstable_getServerProps({
params,
query,
req,
res,
})
const invalidKeys = Object.keys(data).filter(key => key !== 'props')
if (invalidKeys.length) {
throw new Error(invalidKeysMsg('getServerProps', invalidKeys))
}
props.pageProps = data.props
;(renderOpts as any).pageData = props
}
// We only need to do this if we want to support calling
// _app's getInitialProps for getServerProps if not this can be removed
if (isDataReq) return props
// the response might be finished on the getInitialProps call
if (isResSent(res) && !isSpr) return null
@ -504,7 +549,10 @@ export async function renderToHTML(
)
}
const documentCtx = { ...ctx, renderPage }
const docProps = await loadGetInitialProps(Document, documentCtx)
const docProps: DocumentInitialProps = await loadGetInitialProps(
Document,
documentCtx
)
// the response might be finished on the getInitialProps call
if (isResSent(res) && !isSpr) return null
@ -519,7 +567,7 @@ export async function renderToHTML(
const dynamicImports: ManifestItem[] = []
for (const mod of reactLoadableModules) {
const manifestItem = reactLoadableManifest[mod]
const manifestItem: ManifestItem[] = reactLoadableManifest[mod]
if (manifestItem) {
manifestItem.forEach(item => {

View file

@ -0,0 +1,38 @@
import Link from 'next/link'
import fs from 'fs'
import findUp from 'find-up'
// eslint-disable-next-line camelcase
export async function unstable_getServerProps() {
const text = fs
.readFileSync(
findUp.sync('world.txt', {
// prevent webpack from intercepting
// eslint-disable-next-line no-eval
cwd: eval(`__dirname`),
}),
'utf8'
)
.trim()
return {
props: {
world: text,
time: new Date().getTime(),
},
}
}
export default ({ world, time }) => (
<>
<p>hello {world}</p>
<span id="anotherTime">time: {time}</span>
<Link href="/">
<a id="home">to home</a>
</Link>
<br />
<Link href="/something">
<a id="something">to something</a>
</Link>
</>
)

View file

@ -0,0 +1,26 @@
import React from 'react'
import Link from 'next/link'
// eslint-disable-next-line camelcase
export async function unstable_getServerProps({ query }) {
return {
props: {
post: query.post,
comment: query.comment,
time: new Date().getTime(),
},
}
}
export default ({ post, comment, time }) => {
return (
<>
<p>Post: {post}</p>
<p>Comment: {comment}</p>
<span>time: {time}</span>
<Link href="/">
<a id="home">to home</a>
</Link>
</>
)
}

View file

@ -0,0 +1,38 @@
import React from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
// eslint-disable-next-line camelcase
export async function unstable_getServerProps({ params }) {
if (params.post === 'post-10') {
await new Promise(resolve => {
setTimeout(() => resolve(), 1000)
})
}
if (params.post === 'post-100') {
throw new Error('such broken..')
}
return {
props: {
params,
post: params.post,
time: (await import('perf_hooks')).performance.now(),
},
}
}
export default ({ post, time, params }) => {
return (
<>
<p>Post: {post}</p>
<span>time: {time}</span>
<div id="params">{JSON.stringify(params)}</div>
<div id="query">{JSON.stringify(useRouter().query)}</div>
<Link href="/">
<a id="home">to home</a>
</Link>
</>
)
}

View file

@ -0,0 +1,24 @@
import React from 'react'
import Link from 'next/link'
// eslint-disable-next-line camelcase
export async function unstable_getServerProps() {
return {
props: {
slugs: ['post-1', 'post-2'],
time: (await import('perf_hooks')).performance.now(),
},
}
}
export default ({ slugs, time }) => {
return (
<>
<p>Posts: {JSON.stringify(slugs)}</p>
<span>time: {time}</span>
<Link href="/">
<a id="home">to home</a>
</Link>
</>
)
}

View file

@ -0,0 +1,34 @@
import React from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
// eslint-disable-next-line camelcase
export async function unstable_getServerProps({ params }) {
return {
props: {
world: 'world',
params: params || {},
time: new Date().getTime(),
random: Math.random(),
},
}
}
export default ({ world, time, params, random }) => {
return (
<>
<p>hello: {world}</p>
<span>time: {time}</span>
<div id="random">{random}</div>
<div id="params">{JSON.stringify(params)}</div>
<div id="query">{JSON.stringify(useRouter().query)}</div>
<Link href="/">
<a id="home">to home</a>
</Link>
<br />
<Link href="/another">
<a id="another">to another</a>
</Link>
</>
)
}

View file

@ -0,0 +1,25 @@
import Link from 'next/link'
// eslint-disable-next-line camelcase
export async function unstable_getServerProps() {
return {
props: {
world: 'world',
time: new Date().getTime(),
},
}
}
export default ({ world, time }) => (
<>
<p>hello {world}</p>
<span>time: {time}</span>
<Link href="/">
<a id="home">to home</a>
</Link>
<br />
<Link href="/something">
<a id="something">to something</a>
</Link>
</>
)

View file

@ -0,0 +1,47 @@
import Link from 'next/link'
// eslint-disable-next-line camelcase
export async function unstable_getServerProps() {
return {
props: {
world: 'world',
time: new Date().getTime(),
},
}
}
const Page = ({ world, time }) => {
return (
<>
<p>hello {world}</p>
<span>time: {time}</span>
<Link href="/another">
<a id="another">to another</a>
</Link>
<br />
<Link href="/something">
<a id="something">to something</a>
</Link>
<br />
<Link href="/normal">
<a id="normal">to normal</a>
</Link>
<br />
<Link href="/blog/[post]" as="/blog/post-1">
<a id="post-1">to dynamic</a>
</Link>
<Link href="/blog/[post]" as="/blog/post-100">
<a id="broken-post">to broken</a>
</Link>
<br />
<Link href="/blog/[post]/[comment]" as="/blog/post-1/comment-1">
<a id="comment-1">to another dynamic</a>
</Link>
<Link href="/something?another=thing">
<a id="something-query">to something?another=thing</a>
</Link>
</>
)
}
export default Page

View file

@ -0,0 +1,34 @@
import React from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
// eslint-disable-next-line camelcase
export async function unstable_getServerProps({ params, query }) {
return {
world: 'world',
query: query || {},
params: params || {},
time: new Date().getTime(),
random: Math.random(),
}
}
export default ({ world, time, params, random, query }) => {
return (
<>
<p>hello: {world}</p>
<span>time: {time}</span>
<div id="random">{random}</div>
<div id="params">{JSON.stringify(params)}</div>
<div id="initial-query">{JSON.stringify(query)}</div>
<div id="query">{JSON.stringify(useRouter().query)}</div>
<Link href="/">
<a id="home">to home</a>
</Link>
<br />
<Link href="/another">
<a id="another">to another</a>
</Link>
</>
)
}

View file

@ -0,0 +1 @@
export default () => <p id="normal-text">a normal page</p>

View file

@ -0,0 +1,36 @@
import React from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
// eslint-disable-next-line camelcase
export async function unstable_getServerProps({ params, query }) {
return {
props: {
world: 'world',
query: query || {},
params: params || {},
time: new Date().getTime(),
random: Math.random(),
},
}
}
export default ({ world, time, params, random, query }) => {
return (
<>
<p>hello: {world}</p>
<span>time: {time}</span>
<div id="random">{random}</div>
<div id="params">{JSON.stringify(params)}</div>
<div id="initial-query">{JSON.stringify(query)}</div>
<div id="query">{JSON.stringify(useRouter().query)}</div>
<Link href="/">
<a id="home">to home</a>
</Link>
<br />
<Link href="/another">
<a id="another">to another</a>
</Link>
</>
)
}

View file

@ -0,0 +1,24 @@
import React from 'react'
import Link from 'next/link'
// eslint-disable-next-line camelcase
export async function unstable_getServerProps({ query }) {
return {
props: {
user: query.user,
time: (await import('perf_hooks')).performance.now(),
},
}
}
export default ({ user, time }) => {
return (
<>
<p>User: {user}</p>
<span>time: {time}</span>
<Link href="/">
<a id="home">to home</a>
</Link>
</>
)
}

View file

@ -0,0 +1,407 @@
/* eslint-env jest */
/* global jasmine */
import fs from 'fs-extra'
import { join } from 'path'
import webdriver from 'next-webdriver'
import cheerio from 'cheerio'
import escapeRegex from 'escape-string-regexp'
import {
renderViaHTTP,
fetchViaHTTP,
findPort,
launchApp,
killApp,
waitFor,
nextBuild,
nextStart,
normalizeRegEx,
} from 'next-test-utils'
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2
const appDir = join(__dirname, '..')
const nextConfig = join(appDir, 'next.config.js')
let app
let appPort
let buildId
const expectedManifestRoutes = () => ({
'/something': {
page: '/something',
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(buildId)}\\/something.json$`
),
},
'/blog/[post]': {
page: '/blog/[post]',
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(buildId)}\\/blog\\/([^/]+?)\\.json$`
),
},
'/': {
page: '/',
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(buildId)}\\/index.json$`
),
},
'/default-revalidate': {
page: '/default-revalidate',
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(buildId)}\\/default-revalidate.json$`
),
},
'/catchall/[...path]': {
page: '/catchall/[...path]',
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(buildId)}\\/catchall\\/(.+?)\\.json$`
),
},
'/blog': {
page: '/blog',
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(buildId)}\\/blog.json$`
),
},
'/blog/[post]/[comment]': {
page: '/blog/[post]/[comment]',
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(
buildId
)}\\/blog\\/([^/]+?)\\/([^/]+?)\\.json$`
),
},
'/user/[user]/profile': {
page: '/user/[user]/profile',
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(
buildId
)}\\/user\\/([^/]+?)\\/profile\\.json$`
),
},
'/another': {
page: '/another',
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(buildId)}\\/another.json$`
),
},
'/invalid-keys': {
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(buildId)}\\/invalid-keys.json$`
),
page: '/invalid-keys',
},
})
const navigateTest = (dev = false) => {
it('should navigate between pages successfully', async () => {
const toBuild = [
'/',
'/another',
'/something',
'/normal',
'/blog/post-1',
'/blog/post-1/comment-1',
]
await Promise.all(toBuild.map(pg => renderViaHTTP(appPort, pg)))
const browser = await webdriver(appPort, '/')
let text = await browser.elementByCss('p').text()
expect(text).toMatch(/hello.*?world/)
// hydration
await waitFor(2500)
// go to /another
async function goFromHomeToAnother() {
await browser.elementByCss('#another').click()
await browser.waitForElementByCss('#home')
text = await browser.elementByCss('p').text()
expect(text).toMatch(/hello.*?world/)
}
await goFromHomeToAnother()
// go to /
async function goFromAnotherToHome() {
await browser.eval('window.didTransition = 1')
await browser.elementByCss('#home').click()
await browser.waitForElementByCss('#another')
text = await browser.elementByCss('p').text()
expect(text).toMatch(/hello.*?world/)
expect(await browser.eval('window.didTransition')).toBe(1)
}
await goFromAnotherToHome()
await goFromHomeToAnother()
const snapTime = await browser.elementByCss('#anotherTime').text()
// Re-visit page
await goFromAnotherToHome()
await goFromHomeToAnother()
const nextTime = await browser.elementByCss('#anotherTime').text()
expect(snapTime).not.toMatch(nextTime)
// Reset to Home for next test
await goFromAnotherToHome()
// go to /something
await browser.elementByCss('#something').click()
await browser.waitForElementByCss('#home')
text = await browser.elementByCss('p').text()
expect(text).toMatch(/hello.*?world/)
expect(await browser.eval('window.didTransition')).toBe(1)
// go to /
await browser.elementByCss('#home').click()
await browser.waitForElementByCss('#post-1')
// go to /blog/post-1
await browser.elementByCss('#post-1').click()
await browser.waitForElementByCss('#home')
text = await browser.elementByCss('p').text()
expect(text).toMatch(/Post:.*?post-1/)
expect(await browser.eval('window.didTransition')).toBe(1)
// go to /
await browser.elementByCss('#home').click()
await browser.waitForElementByCss('#comment-1')
// go to /blog/post-1/comment-1
await browser.elementByCss('#comment-1').click()
await browser.waitForElementByCss('#home')
text = await browser.elementByCss('p:nth-child(2)').text()
expect(text).toMatch(/Comment:.*?comment-1/)
expect(await browser.eval('window.didTransition')).toBe(1)
await browser.close()
})
}
const runTests = (dev = false) => {
navigateTest(dev)
it('should SSR normal page correctly', async () => {
const html = await renderViaHTTP(appPort, '/')
expect(html).toMatch(/hello.*?world/)
})
it('should SSR getServerProps page correctly', async () => {
const html = await renderViaHTTP(appPort, '/blog/post-1')
expect(html).toMatch(/Post:.*?post-1/)
})
it('should supply query values SSR', async () => {
const html = await renderViaHTTP(appPort, '/blog/post-1?hello=world')
const $ = cheerio.load(html)
const params = $('#params').text()
expect(JSON.parse(params)).toEqual({ post: 'post-1' })
const query = $('#query').text()
expect(JSON.parse(query)).toEqual({ hello: 'world', post: 'post-1' })
})
it('should supply params values for catchall correctly', async () => {
const html = await renderViaHTTP(appPort, '/catchall/first')
const $ = cheerio.load(html)
const params = $('#params').text()
expect(JSON.parse(params)).toEqual({ path: ['first'] })
const query = $('#query').text()
expect(JSON.parse(query)).toEqual({ path: ['first'] })
const data = JSON.parse(
await renderViaHTTP(appPort, `/_next/data/${buildId}/catchall/first.json`)
)
expect(data.pageProps.params).toEqual({ path: ['first'] })
})
it('should return data correctly', async () => {
const data = JSON.parse(
await renderViaHTTP(appPort, `/_next/data/${buildId}/something.json`)
)
expect(data.pageProps.world).toBe('world')
})
it('should pass query for data request', async () => {
const data = JSON.parse(
await renderViaHTTP(
appPort,
`/_next/data/${buildId}/something.json?another=thing`
)
)
expect(data.pageProps.query.another).toBe('thing')
})
it('should return data correctly for dynamic page', async () => {
const data = JSON.parse(
await renderViaHTTP(appPort, `/_next/data/${buildId}/blog/post-1.json`)
)
expect(data.pageProps.post).toBe('post-1')
})
it('should navigate to a normal page and back', async () => {
const browser = await webdriver(appPort, '/')
let text = await browser.elementByCss('p').text()
expect(text).toMatch(/hello.*?world/)
await browser.elementByCss('#normal').click()
await browser.waitForElementByCss('#normal-text')
text = await browser.elementByCss('#normal-text').text()
expect(text).toMatch(/a normal page/)
})
it('should provide correct query value for dynamic page', async () => {
const html = await renderViaHTTP(
appPort,
'/blog/post-1?post=something-else'
)
const $ = cheerio.load(html)
const query = JSON.parse($('#query').text())
expect(query.post).toBe('post-1')
})
it('should parse query values on mount correctly', async () => {
const browser = await webdriver(appPort, '/blog/post-1?another=value')
await waitFor(2000)
const text = await browser.elementByCss('#query').text()
expect(text).toMatch(/another.*?value/)
expect(text).toMatch(/post.*?post-1/)
})
it('should pass query for data request on navigation', async () => {
const browser = await webdriver(appPort, '/')
await browser.eval('window.beforeNav = true')
await browser.elementByCss('#something-query').click()
await browser.waitForElementByCss('#initial-query')
const query = JSON.parse(
await browser.elementByCss('#initial-query').text()
)
expect(await browser.eval('window.beforeNav')).toBe(true)
expect(query.another).toBe('thing')
})
it('should reload page on failed data request', async () => {
const browser = await webdriver(appPort, '/')
await waitFor(500)
await browser.eval('window.beforeClick = true')
await browser.elementByCss('#broken-post').click()
await waitFor(1000)
expect(await browser.eval('window.beforeClick')).not.toBe('true')
})
it('should always call getServerProps without caching', async () => {
const initialRes = await fetchViaHTTP(appPort, '/something')
const initialHtml = await initialRes.text()
expect(initialHtml).toMatch(/hello.*?world/)
const newRes = await fetchViaHTTP(appPort, '/something')
const newHtml = await newRes.text()
expect(newHtml).toMatch(/hello.*?world/)
expect(initialHtml !== newHtml).toBe(true)
const newerRes = await fetchViaHTTP(appPort, '/something')
const newerHtml = await newerRes.text()
expect(newerHtml).toMatch(/hello.*?world/)
expect(newHtml !== newerHtml).toBe(true)
})
it('should not re-call getServerProps when updating query', async () => {
const browser = await webdriver(appPort, '/something?hello=world')
await waitFor(2000)
const query = await browser.elementByCss('#query').text()
expect(JSON.parse(query)).toEqual({ hello: 'world' })
const {
props: {
pageProps: { random: initialRandom },
},
} = await browser.eval('window.__NEXT_DATA__')
const curRandom = await browser.elementByCss('#random').text()
expect(curRandom).toBe(initialRandom + '')
})
if (dev) {
it('should show error for extra keys returned from getServerProps', async () => {
const html = await renderViaHTTP(appPort, '/invalid-keys')
expect(html).toContain(
`Additional keys were returned from \`getServerProps\`. Properties intended for your component must be nested under the \`props\` key, e.g.:`
)
expect(html).toContain(
`Keys that need to be moved: world, query, params, time, random`
)
})
} else {
it('should not fetch data on mount', async () => {
const browser = await webdriver(appPort, '/blog/post-100')
await browser.eval('window.thisShouldStay = true')
await waitFor(2 * 1000)
const val = await browser.eval('window.thisShouldStay')
expect(val).toBe(true)
})
it('should output routes-manifest correctly', async () => {
const { serverPropsRoutes } = await fs.readJSON(
join(appDir, '.next/routes-manifest.json')
)
for (const key of Object.keys(serverPropsRoutes)) {
const val = serverPropsRoutes[key].dataRouteRegex
serverPropsRoutes[key].dataRouteRegex = normalizeRegEx(val)
}
expect(serverPropsRoutes).toEqual(expectedManifestRoutes())
})
it('should set no-cache, no-store, must-revalidate header', async () => {
const res = await fetchViaHTTP(
appPort,
`/_next/data/${escapeRegex(buildId)}/something.json`
)
expect(res.headers.get('cache-control')).toContain('no-cache')
})
}
}
describe('unstable_getServerProps', () => {
describe('dev mode', () => {
beforeAll(async () => {
appPort = await findPort()
app = await launchApp(appDir, appPort)
buildId = 'development'
})
afterAll(() => killApp(app))
runTests(true)
})
describe('serverless mode', () => {
beforeAll(async () => {
await fs.writeFile(
nextConfig,
`module.exports = { target: 'serverless' }`,
'utf8'
)
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8')
})
afterAll(() => killApp(app))
runTests()
})
describe('production mode', () => {
beforeAll(async () => {
await fs.remove(nextConfig)
await nextBuild(appDir, [], { stdout: true })
appPort = await findPort()
app = await nextStart(appDir, appPort)
buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8')
})
afterAll(() => killApp(app))
runTests()
})
})

View file

@ -0,0 +1 @@
world

View file

@ -0,0 +1,13 @@
export const unstable_getStaticProps = async () => {
return {
props: { world: 'world' },
}
}
export const unstable_getServerProps = async () => {
return {
props: { world: 'world' },
}
}
export default ({ world }) => <p>Hello {world}</p>

View file

@ -0,0 +1,13 @@
export const unstable_getStaticPaths = async () => {
return {
props: { world: 'world' }
}
}
export const unstable_getServerProps = async () => {
return {
props: { world: 'world' }
}
}
export default ({ world }) => <p>Hello {world}</p>

View file

@ -0,0 +1,32 @@
/* eslint-env jest */
/* global jasmine */
import fs from 'fs-extra'
import { join } from 'path'
import { nextBuild } from 'next-test-utils'
import { SERVER_PROPS_SSG_CONFLICT } from 'next/dist/lib/constants'
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 1
const appDir = join(__dirname, '..')
const indexPage = join(appDir, 'pages/index.js')
const indexPageAlt = `${indexPage}.alt`
const indexPageBak = `${indexPage}.bak`
describe('Mixed getStaticProps and getServerProps error', () => {
it('should error when exporting both getStaticProps and getServerProps', async () => {
const { stderr } = await nextBuild(appDir, [], { stderr: true })
expect(stderr).toContain(SERVER_PROPS_SSG_CONFLICT)
})
it('should error when exporting both getStaticPaths and getServerProps', async () => {
await fs.move(indexPage, indexPageBak)
await fs.move(indexPageAlt, indexPage)
const { stderr, code } = await nextBuild(appDir, [], { stderr: true })
await fs.move(indexPage, indexPageAlt)
await fs.move(indexPageBak, indexPage)
expect(code).toBe(1)
expect(stderr).toContain(SERVER_PROPS_SSG_CONFLICT)
})
})