diff --git a/packages/next/build/babel/plugins/next-ssg-transform.ts b/packages/next/build/babel/plugins/next-ssg-transform.ts index d9230fd16d9b..5219cfa43d9f 100644 --- a/packages/next/build/babel/plugins/next-ssg-transform.ts +++ b/packages/next/build/babel/plugins/next-ssg-transform.ts @@ -6,10 +6,12 @@ const prerenderId = '__NEXT_SPR' 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 = { diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 173ee869bcd5..2df90e754835 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -254,22 +254,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, { @@ -401,6 +402,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 additionalSprPaths = new Map>() const pageInfos = new Map() const pagesManifest = JSON.parse(await fsReadFile(manifestPath, 'utf8')) @@ -500,6 +502,8 @@ export default async function build(dir: string, conf = null): Promise { } else if (result.static && customAppGetInitialProps === false) { staticPages.add(page) isStatic = true + } else if (result.serverProps) { + serverPropsPages.add(page) } } catch (err) { if (err.message !== 'INVALID_DEFAULT_EXPORT') throw err @@ -520,6 +524,29 @@ 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) { + routesManifest.serverPropsRoutes[page] = { + page, + dataRoute: path.posix.join( + '/_next/data', + buildId, + `${page === '/' ? '/index' : page}.json` + ), + } + } + + await fsWriteFile( + routesManifestPath, + JSON.stringify(routesManifest), + 'utf8' + ) + } + if (invalidPages.size > 0) { throw new Error( `Build optimization failed: found page${ diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 7dcc9c7f7d3b..eaa6523268d2 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -5,7 +5,11 @@ import path from 'path' import { isValidElementType } from 'react-is' import stripAnsi from 'strip-ansi' import { Redirect, Rewrite } from '../lib/check-custom-routes' -import { SPR_GET_INITIAL_PROPS_CONFLICT } from '../lib/constants' +import { + SPR_GET_INITIAL_PROPS_CONFLICT, + SERVER_PROPS_GET_INIT_PROPS_CONFLICT, + SERVER_PROPS_SPR_CONFLICT, +} from '../lib/constants' import prettyBytes from '../lib/pretty-bytes' import { recursiveReadDir } from '../lib/recursive-readdir' import { DEFAULT_REDIRECT_STATUS } from '../next-server/lib/constants' @@ -482,6 +486,7 @@ export async function isPageStatic( static?: boolean prerender?: boolean isHybridAmp?: boolean + serverProps?: boolean prerenderRoutes?: string[] | undefined }> { try { @@ -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(SPR_GET_INITIAL_PROPS_CONFLICT) } + if (hasGetInitialProps && hasServerProps) { + throw new Error(SERVER_PROPS_GET_INIT_PROPS_CONFLICT) + } + + if (hasStaticProps && hasServerProps) { + throw new Error(SERVER_PROPS_SPR_CONFLICT) + } + // A page cannot have static parameters if it is not a dynamic page. if (hasStaticProps && hasStaticPaths && !isDynamicRoute(page)) { throw new Error( @@ -579,9 +593,10 @@ export async function isPageStatic( const config = mod.config || {} return { - static: !hasStaticProps && !hasGetInitialProps, + static: !hasStaticProps && !hasGetInitialProps && !hasServerProps, isHybridAmp: config.amp === 'hybrid', prerenderRoutes: prerenderPaths, + serverProps: hasServerProps, prerender: hasStaticProps, } } catch (err) { diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index fdc47e8fde56..596d2e6924bf 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -185,6 +185,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} @@ -206,6 +207,7 @@ const nextServerlessLoader: loader.Loader = function() { Document, buildManifest, unstable_getStaticProps, + unstable_getServerProps, unstable_getStaticPaths, reactLoadableManifest, canonicalBase: "${canonicalBase}", @@ -213,10 +215,10 @@ const nextServerlessLoader: loader.Loader = function() { assetPrefix: "${assetPrefix}", ..._renderOpts } - let sprData = false + let _nextData = false if (req.url.match(/_next\\/data/)) { - sprData = true + _nextData = true req.url = req.url .replace(new RegExp('/_next/data/${escapedBuildId}/'), '/') .replace(/\\.json$/, '') @@ -235,7 +237,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,14 +275,17 @@ const nextServerlessLoader: loader.Loader = function() { } let result = await renderToHTML(req, res, "${page}", Object.assign({}, unstable_getStaticProps ? {} : parsedUrl.query, nowParams ? nowParams : params, _params), renderOpts) - if (sprData && !fromExport) { - const payload = JSON.stringify(renderOpts.sprData) + 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\` - ) + + if (renderOpts.revalidate) { + res.setHeader( + 'Cache-Control', + \`s-maxage=\${renderOpts.revalidate}, stale-while-revalidate\` + ) + } res.end(payload) return null } diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index da26b61dd831..42371a80d646 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -276,7 +276,7 @@ export default async function( } const progress = !options.silent && createProgress(filteredPaths.length) - const sprDataDir = options.buildExport + const pageDataDir = options.buildExport ? outDir : join(outDir, '_next/data', buildId) @@ -318,7 +318,7 @@ export default async function( distDir, buildId, outDir, - sprDataDir, + pageDataDir, renderOpts, serverRuntimeConfig, subFolders, @@ -360,7 +360,7 @@ export default async function( subFolders && route !== '/index' ? `${sep}index` : '' }.html` ) - const jsonDest = join(sprDataDir, `${route}.json`) + const jsonDest = join(pageDataDir, `${route}.json`) await mkdirp(dirname(htmlDest)) await mkdirp(dirname(jsonDest)) diff --git a/packages/next/export/worker.js b/packages/next/export/worker.js index 072faae8dd11..27f0dd8d4b0d 100644 --- a/packages/next/export/worker.js +++ b/packages/next/export/worker.js @@ -25,7 +25,7 @@ export default async function({ distDir, buildId, outDir, - sprDataDir, + pageDataDir, renderOpts, buildExport, serverRuntimeConfig, @@ -234,14 +234,14 @@ export default async function({ } } - if (curRenderOpts.sprData) { + if (curRenderOpts.pageData) { const dataFile = join( - sprDataDir, + pageDataDir, htmlFilename.replace(/\.html$/, '.json') ) await mkdirp(dirname(dataFile)) - await writeFileP(dataFile, JSON.stringify(curRenderOpts.sprData), 'utf8') + await writeFileP(dataFile, JSON.stringify(curRenderOpts.pageData), 'utf8') } results.fromBuildExportRevalidate = curRenderOpts.revalidate diff --git a/packages/next/lib/constants.ts b/packages/next/lib/constants.ts index be1c47741295..04c38ec8af2c 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 SPR_GET_INITIAL_PROPS_CONFLICT = `You can not use getInitialProps with unstable_getStaticProps. To use SPR, 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_SPR_CONFLICT = `You can not use unstable_getStaticProps with unstable_getServerProps. To use SPR, please remove your unstable_getServerProps` diff --git a/packages/next/next-server/server/load-components.ts b/packages/next/next-server/server/load-components.ts index a6aee255a0cc..59ade5681b33 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, @@ -22,6 +24,11 @@ export type LoadComponentsReturnType = { revalidate?: number | boolean } unstable_getStaticPaths?: () => void + unstable_getServerProps?: (context: { + req: IncomingMessage + res: ServerResponse + query: ParsedUrlQuery + }) => Promise<{ [key: string]: any }> buildManifest?: any reactLoadableManifest?: any Document?: any @@ -90,6 +97,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 ddb5d861aeec..daae97544e47 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -380,7 +380,7 @@ export default class Server { req, res, pathname, - { _nextSprData: '1' }, + { _nextDataReq: '1' }, parsedUrl ) return { @@ -831,10 +831,36 @@ export default class Server { typeof result.Component.renderReqToHTML === 'function' const isSpr = !!result.unstable_getStaticProps + // 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 = `/_next/data/${this.buildId}${pathname}.json` + } + // non-spr requests should render like normal if (!isSpr) { // handle serverless if (isLikeServerless) { + if (isDataReq) { + const renderResult = await result.Component.renderReqToHTML( + req, + res, + true + ) + + this.__sendPayload( + res, + JSON.stringify(renderResult?.renderOpts?.pageData), + 'application/json' + ) + return null + } const curUrl = parseUrl(req.url!, true) req.url = formatUrl({ ...curUrl, @@ -847,30 +873,37 @@ export default class Server { return result.Component.renderReqToHTML(req, res) } + if (isDataReq && typeof result.unstable_getServerProps === 'function') { + const props = await renderToHTML(req, res, pathname, query, { + ...result, + ...opts, + isDataReq, + }) + this.__sendPayload(res, JSON.stringify(props), 'application/json') + return null + } + return renderToHTML(req, res, pathname, query, { ...result, ...opts, }) } - // Toggle whether or not this is an SPR Data request - const isSprData = isSpr && query._nextSprData - delete query._nextSprData - // Compute the SPR cache key const sprCacheKey = parseUrl(req.url || '').pathname! + const isPageData = isSpr && isDataReq // Complete the response with cached data if its present const cachedData = await getSprCache(sprCacheKey) if (cachedData) { - const data = isSprData + const data = isPageData ? JSON.stringify(cachedData.pageData) : cachedData.html this.__sendPayload( res, data, - isSprData ? 'application/json' : 'text/html; charset=utf-8', + isPageData ? 'application/json' : 'text/html; charset=utf-8', cachedData.curRevalidate ) @@ -882,20 +915,12 @@ 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 && isSprData) { - 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 - sprData: any + pageData: any sprRevalidate: number | false }> { - let sprData: any + let pageData: any let html: string | null let sprRevalidate: number | false @@ -905,7 +930,7 @@ export default class Server { renderResult = await result.Component.renderReqToHTML(req, res, true) html = renderResult.html - sprData = renderResult.renderOpts.sprData + pageData = renderResult.renderOpts.pageData sprRevalidate = renderResult.renderOpts.revalidate } else { const renderOpts = { @@ -915,21 +940,21 @@ export default class Server { renderResult = await renderToHTML(req, res, pathname, query, renderOpts) html = renderResult - sprData = renderOpts.sprData + pageData = renderOpts.pageData sprRevalidate = renderOpts.revalidate } - return { html, sprData, sprRevalidate } + return { html, pageData, sprRevalidate } }) return doRender(sprCacheKey, []).then( - async ({ isOrigin, value: { html, sprData, sprRevalidate } }) => { + async ({ isOrigin, value: { html, pageData, sprRevalidate } }) => { // Respond to the request if a payload wasn't sent above (from cache) if (!isResSent(res)) { this.__sendPayload( res, - isSprData ? JSON.stringify(sprData) : html, - isSprData ? 'application/json' : 'text/html; charset=utf-8', + isPageData ? JSON.stringify(pageData) : html, + isPageData ? 'application/json' : 'text/html; charset=utf-8', sprRevalidate ) } @@ -938,7 +963,7 @@ export default class Server { if (isOrigin) { await setSprCache( sprCacheKey, - { html: html!, pageData: sprData }, + { html: html!, pageData: pageData }, sprRevalidate ) } @@ -969,7 +994,7 @@ export default class Server { res, pathname, result.unstable_getStaticProps - ? { _nextSprData: query._nextSprData } + ? { _nextDataReq: query._nextDataReq } : query, result, { ...this.renderOpts, amphtml, hasAmp } @@ -995,7 +1020,7 @@ export default class Server { // only add params for SPR enabled pages { ...(result.unstable_getStaticProps - ? { _nextSprData: query._nextSprData } + ? { _nextDataReq: query._nextDataReq } : query), ...params, }, diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index aebb107c555c..6147ce3af21b 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -28,7 +28,11 @@ import { isInAmpMode } from '../lib/amp' // Uses a module path because of the compiled output directory location import { PageConfig } from 'next/types' import { isDynamicRoute } from '../lib/router/utils/is-dynamic' -import { SPR_GET_INITIAL_PROPS_CONFLICT } from '../../lib/constants' +import { + SPR_GET_INITIAL_PROPS_CONFLICT, + SERVER_PROPS_GET_INIT_PROPS_CONFLICT, + SERVER_PROPS_SPR_CONFLICT, +} from '../../lib/constants' import { AMP_RENDER_TARGET } from '../lib/constants' export type ManifestItem = { @@ -150,11 +154,17 @@ type RenderOpts = { ampValidator?: (html: string, pathname: string) => Promise unstable_getStaticProps?: (params: { params: any | undefined - }) => { + }) => Promise<{ props: any revalidate?: number | boolean - } + }> unstable_getStaticPaths?: () => void + unstable_getServerProps?: (context: { + req: IncomingMessage + res: ServerResponse + query: ParsedUrlQuery + }) => Promise<{ [key: string]: any }> + isDataReq: boolean } function renderDocument( @@ -272,6 +282,8 @@ export async function renderToHTML( ErrorDebug, unstable_getStaticProps, unstable_getStaticPaths, + unstable_getServerProps, + isDataReq, } = renderOpts const callMiddleware = async (method: string, args: any[], props = false) => { @@ -307,7 +319,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' && @@ -327,6 +342,14 @@ export async function renderToHTML( throw new Error(SPR_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_SPR_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` @@ -470,7 +493,7 @@ export async function renderToHTML( props.pageProps = data.props // pass up revalidate and props for export ;(renderOpts as any).revalidate = data.revalidate - ;(renderOpts as any).sprData = props + ;(renderOpts as any).pageData = props } } catch (err) { if (!dev || !err) throw err @@ -478,6 +501,18 @@ export async function renderToHTML( renderOpts.err = err } + if (unstable_getServerProps) { + props.pageProps = await unstable_getServerProps({ + query, + req, + res, + }) + ;(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 diff --git a/test/integration/getserverprops/pages/another/index.js b/test/integration/getserverprops/pages/another/index.js new file mode 100644 index 000000000000..7f588dcf3a87 --- /dev/null +++ b/test/integration/getserverprops/pages/another/index.js @@ -0,0 +1,36 @@ +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 { + 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 000000000000..840676c6f756 --- /dev/null +++ b/test/integration/getserverprops/pages/blog/[post]/[comment].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 { + 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 000000000000..155a19d77f4e --- /dev/null +++ b/test/integration/getserverprops/pages/blog/[post]/index.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({ query }) { + if (query.post === 'post-10') { + await new Promise(resolve => { + setTimeout(() => resolve(), 1000) + }) + } + + if (query.post === 'post-100') { + throw new Error('such broken..') + } + + return { + query, + post: query.post, + time: (await import('perf_hooks')).performance.now(), + } +} + +export default ({ post, time, query }) => { + return ( + <> +

Post: {post}

+ time: {time} +
{JSON.stringify(query)}
+
{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 000000000000..341930d994fc --- /dev/null +++ b/test/integration/getserverprops/pages/blog/index.js @@ -0,0 +1,22 @@ +import React from 'react' +import Link from 'next/link' + +// eslint-disable-next-line camelcase +export async function unstable_getServerProps() { + return { + 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/default-revalidate.js b/test/integration/getserverprops/pages/default-revalidate.js new file mode 100644 index 000000000000..d078e6a08e8a --- /dev/null +++ b/test/integration/getserverprops/pages/default-revalidate.js @@ -0,0 +1,23 @@ +import Link from 'next/link' + +// eslint-disable-next-line camelcase +export async function unstable_getServerProps() { + return { + 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 000000000000..f41bb0230114 --- /dev/null +++ b/test/integration/getserverprops/pages/index.js @@ -0,0 +1,42 @@ +import Link from 'next/link' + +// eslint-disable-next-line camelcase +export async function unstable_getServerProps() { + return { + 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 + + + ) +} + +export default Page diff --git a/test/integration/getserverprops/pages/normal.js b/test/integration/getserverprops/pages/normal.js new file mode 100644 index 000000000000..75ad8dfee172 --- /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 000000000000..94965ee53975 --- /dev/null +++ b/test/integration/getserverprops/pages/something.js @@ -0,0 +1,32 @@ +import React from 'react' +import Link from 'next/link' +import { useRouter } from 'next/router' + +// eslint-disable-next-line camelcase +export async function unstable_getServerProps({ query }) { + return { + world: 'world', + params: query || {}, + 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/user/[user]/profile.js b/test/integration/getserverprops/pages/user/[user]/profile.js new file mode 100644 index 000000000000..722fbc24671e --- /dev/null +++ b/test/integration/getserverprops/pages/user/[user]/profile.js @@ -0,0 +1,22 @@ +import React from 'react' +import Link from 'next/link' + +// eslint-disable-next-line camelcase +export async function unstable_getServerProps({ query }) { + return { + 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 000000000000..f9d2ad05303b --- /dev/null +++ b/test/integration/getserverprops/test/index.test.js @@ -0,0 +1,308 @@ +/* eslint-env jest */ +/* global jasmine */ +import fs from 'fs-extra' +import { join } from 'path' +import webdriver from 'next-webdriver' +import cheerio from 'cheerio' +import { + renderViaHTTP, + fetchViaHTTP, + findPort, + launchApp, + killApp, + waitFor, + nextBuild, + nextStart, +} 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 = () => ({ + '/user/[user]/profile': { + dataRoute: `/_next/data/${buildId}/user/[user]/profile.json`, + page: '/user/[user]/profile', + }, + '/': { + dataRoute: `/_next/data/${buildId}/index.json`, + page: '/', + }, + '/blog/[post]/[comment]': { + dataRoute: `/_next/data/${buildId}/blog/[post]/[comment].json`, + page: '/blog/[post]/[comment]', + }, + '/blog': { + dataRoute: `/_next/data/${buildId}/blog.json`, + page: '/blog', + }, + '/default-revalidate': { + dataRoute: `/_next/data/${buildId}/default-revalidate.json`, + page: '/default-revalidate', + }, + '/another': { + dataRoute: `/_next/data/${buildId}/another.json`, + page: '/another', + }, + '/blog/[post]': { + dataRoute: `/_next/data/${buildId}/blog/[post].json`, + page: '/blog/[post]', + }, + '/something': { + dataRoute: `/_next/data/${buildId}/something.json`, + page: '/something', + }, +}) + +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() + if (dev) { + expect(snapTime).not.toMatch(nextTime) + } else { + expect(snapTime).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({ hello: 'world', post: 'post-1' }) + const query = $('#query').text() + expect(JSON.parse(query)).toEqual({ hello: 'world', post: 'post-1' }) + }) + + 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 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 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 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 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 routesManifest = await fs.readJSON( + join(appDir, '.next/routes-manifest.json') + ) + + expect(routesManifest.serverPropsRoutes).toEqual(expectedManifestRoutes()) + }) + } +} + +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 000000000000..04fea06420ca --- /dev/null +++ b/test/integration/getserverprops/world.txt @@ -0,0 +1 @@ +world \ No newline at end of file