From c24daa21722fadfce2d1d561ce65ecc0efa6a7ea Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 27 Jan 2020 16:50:59 -0600 Subject: [PATCH] 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 --- .../build/babel/plugins/next-ssg-transform.ts | 37 +- packages/next/build/index.ts | 70 ++- packages/next/build/utils.ts | 19 +- .../webpack/loaders/next-serverless-loader.ts | 15 +- packages/next/export/worker.js | 2 +- packages/next/lib/constants.ts | 4 + .../next/next-server/lib/router/router.ts | 67 +-- .../next-server/server/load-components.ts | 12 +- .../next/next-server/server/next-server.ts | 60 ++- packages/next/next-server/server/render.tsx | 68 ++- .../getserverprops/pages/another/index.js | 38 ++ .../pages/blog/[post]/[comment].js | 26 ++ .../getserverprops/pages/blog/[post]/index.js | 38 ++ .../getserverprops/pages/blog/index.js | 24 ++ .../pages/catchall/[...path].js | 34 ++ .../pages/default-revalidate.js | 25 ++ .../integration/getserverprops/pages/index.js | 47 ++ .../getserverprops/pages/invalid-keys.js | 34 ++ .../getserverprops/pages/normal.js | 1 + .../getserverprops/pages/something.js | 36 ++ .../pages/user/[user]/profile.js | 24 ++ .../getserverprops/test/index.test.js | 407 ++++++++++++++++++ test/integration/getserverprops/world.txt | 1 + .../pages/index.js | 13 + .../pages/index.js.alt | 13 + .../test/index.test.js | 32 ++ 26 files changed, 1070 insertions(+), 77 deletions(-) create mode 100644 test/integration/getserverprops/pages/another/index.js create mode 100644 test/integration/getserverprops/pages/blog/[post]/[comment].js create mode 100644 test/integration/getserverprops/pages/blog/[post]/index.js create mode 100644 test/integration/getserverprops/pages/blog/index.js create mode 100644 test/integration/getserverprops/pages/catchall/[...path].js create mode 100644 test/integration/getserverprops/pages/default-revalidate.js create mode 100644 test/integration/getserverprops/pages/index.js create mode 100644 test/integration/getserverprops/pages/invalid-keys.js create mode 100644 test/integration/getserverprops/pages/normal.js create mode 100644 test/integration/getserverprops/pages/something.js create mode 100644 test/integration/getserverprops/pages/user/[user]/profile.js create mode 100644 test/integration/getserverprops/test/index.test.js create mode 100644 test/integration/getserverprops/world.txt create mode 100644 test/integration/mixed-ssg-serverprops-error/pages/index.js create mode 100644 test/integration/mixed-ssg-serverprops-error/pages/index.js.alt create mode 100644 test/integration/mixed-ssg-serverprops-error/test/index.test.js diff --git a/packages/next/build/babel/plugins/next-ssg-transform.ts b/packages/next/build/babel/plugins/next-ssg-transform.ts index 4e9731338df5039..8fc50b255a7a9b3 100644 --- a/packages/next/build/babel/plugins/next-ssg-transform.ts +++ b/packages/next/build/babel/plugins/next-ssg-transform.ts @@ -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> 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>() 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() } }) diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 964082a2b8e1d1b..67cbdc381bf684e 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -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 { } } + 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 { const staticPages = new Set() const invalidPages = new Set() const hybridAmpPages = new Set() + const serverPropsPages = new Set() const additionalSsgPaths = new Map>() const pageInfos = new Map() const pagesManifest = JSON.parse(await fsReadFile(manifestPath, 'utf8')) @@ -502,6 +505,8 @@ export default async function build(dir: string, conf = null): Promise { 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 { ) 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 = diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index ac126d9c7749445..2bb641ed5c7e964 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -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 {} diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 4948c38d3735cc5..9db7d8c93dbf6ea 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -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 })) diff --git a/packages/next/export/worker.js b/packages/next/export/worker.js index 30e7db5d67275fb..68a1307b519dd7b 100644 --- a/packages/next/export/worker.js +++ b/packages/next/export/worker.js @@ -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) } } diff --git a/packages/next/lib/constants.ts b/packages/next/lib/constants.ts index 7692ffb747ccd1d..1bf62bb57e918f4 100644 --- a/packages/next/lib/constants.ts +++ b/packages/next/lib/constants.ts @@ -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` diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index a3897e261244ace..4c5a30545b8399b 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -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(() => (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 => { - let pathname = parse(asPath).pathname - pathname = toRoute(!pathname || pathname === '/' ? '/index' : pathname) + _getStaticData = (asPath: string): Promise => { + 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 => { + let { pathname, query } = parse(asPath, true) + pathname = prepareRoute(pathname!) + return fetchNextData(pathname, query) } getInitialProps( diff --git a/packages/next/next-server/server/load-components.ts b/packages/next/next-server/server/load-components.ts index c8559610c9d3f77..78995e867d3db8a 100644 --- a/packages/next/next-server/server/load-components.ts +++ b/packages/next/next-server/server/load-components.ts @@ -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> +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, } diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index e2ac323dce02887..4c7b25dddd9c581 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -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, } diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index ec7fd0a4d63dfb7..cbc72b44a7cbca5 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -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 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 => { diff --git a/test/integration/getserverprops/pages/another/index.js b/test/integration/getserverprops/pages/another/index.js new file mode 100644 index 000000000000000..a5c3afb6343c63f --- /dev/null +++ b/test/integration/getserverprops/pages/another/index.js @@ -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 }) => ( + <> +

hello {world}

+ time: {time} + + to home + +
+ + to something + + +) diff --git a/test/integration/getserverprops/pages/blog/[post]/[comment].js b/test/integration/getserverprops/pages/blog/[post]/[comment].js new file mode 100644 index 000000000000000..292c98024942dc3 --- /dev/null +++ b/test/integration/getserverprops/pages/blog/[post]/[comment].js @@ -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 ( + <> +

Post: {post}

+

Comment: {comment}

+ time: {time} + + to home + + + ) +} diff --git a/test/integration/getserverprops/pages/blog/[post]/index.js b/test/integration/getserverprops/pages/blog/[post]/index.js new file mode 100644 index 000000000000000..aa4e0c5cc2bd12a --- /dev/null +++ b/test/integration/getserverprops/pages/blog/[post]/index.js @@ -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 ( + <> +

Post: {post}

+ time: {time} +
{JSON.stringify(params)}
+
{JSON.stringify(useRouter().query)}
+ + to home + + + ) +} diff --git a/test/integration/getserverprops/pages/blog/index.js b/test/integration/getserverprops/pages/blog/index.js new file mode 100644 index 000000000000000..056a1860a3e3dfa --- /dev/null +++ b/test/integration/getserverprops/pages/blog/index.js @@ -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 ( + <> +

Posts: {JSON.stringify(slugs)}

+ time: {time} + + to home + + + ) +} diff --git a/test/integration/getserverprops/pages/catchall/[...path].js b/test/integration/getserverprops/pages/catchall/[...path].js new file mode 100644 index 000000000000000..5e1bd3543f63fec --- /dev/null +++ b/test/integration/getserverprops/pages/catchall/[...path].js @@ -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 ( + <> +

hello: {world}

+ time: {time} +
{random}
+
{JSON.stringify(params)}
+
{JSON.stringify(useRouter().query)}
+ + to home + +
+ + to another + + + ) +} diff --git a/test/integration/getserverprops/pages/default-revalidate.js b/test/integration/getserverprops/pages/default-revalidate.js new file mode 100644 index 000000000000000..15bb81ba617e876 --- /dev/null +++ b/test/integration/getserverprops/pages/default-revalidate.js @@ -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 }) => ( + <> +

hello {world}

+ time: {time} + + to home + +
+ + to something + + +) diff --git a/test/integration/getserverprops/pages/index.js b/test/integration/getserverprops/pages/index.js new file mode 100644 index 000000000000000..f906758f846b1d8 --- /dev/null +++ b/test/integration/getserverprops/pages/index.js @@ -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 ( + <> +

hello {world}

+ time: {time} + + to another + +
+ + to something + +
+ + to normal + +
+ + to dynamic + + + to broken + +
+ + to another dynamic + + + to something?another=thing + + + ) +} + +export default Page diff --git a/test/integration/getserverprops/pages/invalid-keys.js b/test/integration/getserverprops/pages/invalid-keys.js new file mode 100644 index 000000000000000..6eeba67fb6a7730 --- /dev/null +++ b/test/integration/getserverprops/pages/invalid-keys.js @@ -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 ( + <> +

hello: {world}

+ time: {time} +
{random}
+
{JSON.stringify(params)}
+
{JSON.stringify(query)}
+
{JSON.stringify(useRouter().query)}
+ + to home + +
+ + to another + + + ) +} diff --git a/test/integration/getserverprops/pages/normal.js b/test/integration/getserverprops/pages/normal.js new file mode 100644 index 000000000000000..75ad8dfee1722d9 --- /dev/null +++ b/test/integration/getserverprops/pages/normal.js @@ -0,0 +1 @@ +export default () =>

a normal page

diff --git a/test/integration/getserverprops/pages/something.js b/test/integration/getserverprops/pages/something.js new file mode 100644 index 000000000000000..f3d5ef1ef4de2f0 --- /dev/null +++ b/test/integration/getserverprops/pages/something.js @@ -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 ( + <> +

hello: {world}

+ time: {time} +
{random}
+
{JSON.stringify(params)}
+
{JSON.stringify(query)}
+
{JSON.stringify(useRouter().query)}
+ + to home + +
+ + to another + + + ) +} diff --git a/test/integration/getserverprops/pages/user/[user]/profile.js b/test/integration/getserverprops/pages/user/[user]/profile.js new file mode 100644 index 000000000000000..a4fb1e13403bf04 --- /dev/null +++ b/test/integration/getserverprops/pages/user/[user]/profile.js @@ -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 ( + <> +

User: {user}

+ time: {time} + + to home + + + ) +} diff --git a/test/integration/getserverprops/test/index.test.js b/test/integration/getserverprops/test/index.test.js new file mode 100644 index 000000000000000..1c932da181e2a68 --- /dev/null +++ b/test/integration/getserverprops/test/index.test.js @@ -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() + }) +}) diff --git a/test/integration/getserverprops/world.txt b/test/integration/getserverprops/world.txt new file mode 100644 index 000000000000000..04fea06420ca608 --- /dev/null +++ b/test/integration/getserverprops/world.txt @@ -0,0 +1 @@ +world \ No newline at end of file diff --git a/test/integration/mixed-ssg-serverprops-error/pages/index.js b/test/integration/mixed-ssg-serverprops-error/pages/index.js new file mode 100644 index 000000000000000..dedf3d00be14bed --- /dev/null +++ b/test/integration/mixed-ssg-serverprops-error/pages/index.js @@ -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 }) =>

Hello {world}

diff --git a/test/integration/mixed-ssg-serverprops-error/pages/index.js.alt b/test/integration/mixed-ssg-serverprops-error/pages/index.js.alt new file mode 100644 index 000000000000000..910eef220630d25 --- /dev/null +++ b/test/integration/mixed-ssg-serverprops-error/pages/index.js.alt @@ -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 }) =>

Hello {world}

diff --git a/test/integration/mixed-ssg-serverprops-error/test/index.test.js b/test/integration/mixed-ssg-serverprops-error/test/index.test.js new file mode 100644 index 000000000000000..794cf77cae22f8b --- /dev/null +++ b/test/integration/mixed-ssg-serverprops-error/test/index.test.js @@ -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) + }) +})