Skip to content

Commit

Permalink
Add initial SSG fallback handling (#10424)
Browse files Browse the repository at this point in the history
* Add initial SSG fallback handling

* Remove extra changes and update fallback handling

* Remove extra timeout for testing

* Update SSG tests in dynamic-routing suite

* Add racing to decide between rendering fallback and data

* Update size-limit test

* Update comment

* Make sure to follow correct route change order

* Make comment more verbose for racing

* Revert getStaticData to only return Promise

* Make sure to update URL on fallback

* Add retrying for data, de-dupe initial fallback request, and merge fallback replace

* Update to add preload for fallback pages data

* Add test for data preload link

* Use pre-built fallback in production mode

* Remove preload link for fallback from _document

* Update to make sure fallback is rendered correctly for serverless
  • Loading branch information
ijjk committed Feb 7, 2020
1 parent 5e4850c commit 3099f08
Show file tree
Hide file tree
Showing 12 changed files with 254 additions and 96 deletions.
31 changes: 17 additions & 14 deletions packages/next/build/index.ts
Expand Up @@ -88,7 +88,7 @@ export type SsgRoute = {

export type DynamicSsgRoute = {
routeRegex: string

fallback: string
dataRoute: string
dataRouteRegex: string
}
Expand Down Expand Up @@ -639,15 +639,16 @@ export default async function build(dir: string, conf = null): Promise<void> {
// n.b. we cannot handle this above in combinedPages because the dynamic
// page must be in the `pages` array, but not in the mapping.
exportPathMap: (defaultMap: any) => {
// Remove dynamically routed pages from the default path map. These
// pages cannot be prerendered because we don't have enough information
// to do so.
// Generate fallback for dynamically routed pages to use as
// the loading state for pages while the data is being populated
//
// Note: prerendering disables automatic static optimization.
ssgPages.forEach(page => {
if (isDynamicRoute(page)) {
tbdPrerenderRoutes.push(page)
delete defaultMap[page]
// set __nextFallback query so render doesn't call
// getStaticProps/getServerProps
defaultMap[page] = { page, query: { __nextFallback: true } }
}
})
// Append the "well-known" routes we should prerender for, e.g. blog
Expand Down Expand Up @@ -711,13 +712,12 @@ export default async function build(dir: string, conf = null): Promise<void> {
for (const page of combinedPages) {
const isSsg = ssgPages.has(page)
const isDynamic = isDynamicRoute(page)
const file = normalizePagePath(page)
// The dynamic version of SSG pages are not prerendered. Below, we handle
// the specific prerenders of these.
if (!(isSsg && isDynamic)) {
await moveExportedPage(page, file, isSsg, 'html')
}
const hasAmp = hybridAmpPages.has(page)
let file = normalizePagePath(page)

// We should always have an HTML file to move for each page
await moveExportedPage(page, file, isSsg, 'html')

if (hasAmp) {
await moveExportedPage(`${page}.amp`, `${file}.amp`, isSsg, 'html')
}
Expand All @@ -734,8 +734,9 @@ export default async function build(dir: string, conf = null): Promise<void> {
dataRoute: path.posix.join('/_next/data', buildId, `${file}.json`),
}
} else {
// For a dynamic SSG page, we did not copy its html nor data exports.
// Instead, we must copy specific versions of this page as defined by
// For a dynamic SSG page, we did not copy its data exports and only
// copy the fallback HTML file.
// We must also copy specific versions of this page as defined by
// `unstable_getStaticPaths` (additionalSsgPaths).
const extraRoutes = additionalSsgPaths.get(page) || []
for (const route of extraRoutes) {
Expand Down Expand Up @@ -778,15 +779,17 @@ export default async function build(dir: string, conf = null): Promise<void> {
if (ssgPages.size > 0) {
const finalDynamicRoutes: PrerenderManifest['dynamicRoutes'] = {}
tbdPrerenderRoutes.forEach(tbdRoute => {
const normalizedRoute = normalizePagePath(tbdRoute)
const dataRoute = path.posix.join(
'/_next/data',
buildId,
`${normalizePagePath(tbdRoute)}.json`
`${normalizedRoute}.json`
)

finalDynamicRoutes[tbdRoute] = {
routeRegex: getRouteRegex(tbdRoute).re.source,
dataRoute,
fallback: `${normalizedRoute}.html`,
dataRouteRegex: getRouteRegex(
dataRoute.replace(/\.json$/, '')
).re.source.replace(/\(\?:\\\/\)\?\$$/, '\\.json$'),
Expand Down
Expand Up @@ -308,7 +308,9 @@ const nextServerlessLoader: loader.Loader = function() {
// 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)
const isFallback = parsedUrl.query.__nextFallback
let result = await renderToHTML(req, res, "${page}", Object.assign({}, unstable_getStaticProps ? {} : parsedUrl.query, nowParams ? nowParams : params, _params, isFallback ? { __nextFallback: 'true' } : {}), renderOpts)
if (_nextData && !fromExport) {
const payload = JSON.stringify(renderOpts.pageData)
Expand Down
9 changes: 6 additions & 3 deletions packages/next/client/index.js
Expand Up @@ -44,6 +44,7 @@ const {
assetPrefix,
runtimeConfig,
dynamicIds,
isFallback,
} = data

const prefix = assetPrefix || ''
Expand Down Expand Up @@ -98,10 +99,12 @@ class Container extends React.Component {

// If page was exported and has a querystring
// If it's a dynamic route or has a querystring
// if it's a fallback page
if (
router.isSsr &&
((data.nextExport &&
(isDynamicRoute(router.pathname) || location.search)) ||
(isFallback ||
(data.nextExport &&
(isDynamicRoute(router.pathname) || location.search)) ||
(Component && Component.__N_SSG && location.search))
) {
// update query on mount for exported pages
Expand All @@ -118,7 +121,7 @@ class Container extends React.Component {
// client-side hydration. Your app should _never_ use this property.
// It may change at any time without notice.
_h: 1,
shallow: true,
shallow: !isFallback,
}
)
}
Expand Down
136 changes: 97 additions & 39 deletions packages/next/next-server/lib/router/router.ts
Expand Up @@ -78,17 +78,15 @@ const fetchNextData = (
)
.then(res => {
if (!res.ok) {
throw new Error(`Failed to load static props`)
const error = new Error(`Failed to load static props`)
;(error as any).statusCode = res.status
throw error
}
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 {
Expand Down Expand Up @@ -393,31 +391,65 @@ export default class Router implements BaseRouter {

// If shallow is true and the route exists in the router cache we reuse the previous result
this.getRouteInfo(route, pathname, query, as, shallow).then(routeInfo => {
const { error } = routeInfo
let emitHistory = false

if (error && error.cancelled) {
return resolve(false)
}
const doRouteChange = (routeInfo: RouteInfo, complete: boolean) => {
const { error } = routeInfo

Router.events.emit('beforeHistoryChange', as)
this.changeState(method, url, addBasePath(as), options)
if (error && error.cancelled) {
return resolve(false)
}

if (process.env.NODE_ENV !== 'production') {
const appComp: any = this.components['/_app'].Component
;(window as any).next.isPrerendered =
appComp.getInitialProps === appComp.origGetInitialProps &&
!(routeInfo.Component as any).getInitialProps
}
if (!emitHistory) {
emitHistory = true
Router.events.emit('beforeHistoryChange', as)
this.changeState(method, url, addBasePath(as), options)
}

if (process.env.NODE_ENV !== 'production') {
const appComp: any = this.components['/_app'].Component
;(window as any).next.isPrerendered =
appComp.getInitialProps === appComp.origGetInitialProps &&
!(routeInfo.Component as any).getInitialProps
}

this.set(route, pathname, query, as, routeInfo)

this.set(route, pathname, query, as, routeInfo)
if (complete) {
if (error) {
Router.events.emit('routeChangeError', error, as)
throw error
}

if (error) {
Router.events.emit('routeChangeError', error, as)
throw error
Router.events.emit('routeChangeComplete', as)
resolve(true)
}
}

Router.events.emit('routeChangeComplete', as)
return resolve(true)
if ((routeInfo as any).dataRes) {
const dataRes = (routeInfo as any).dataRes as Promise<RouteInfo>

// to prevent a flash of the fallback page we delay showing it for
// 110ms and race the timeout with the data response. If the data
// beats the timeout we skip showing the fallback
Promise.race([
new Promise(resolve => setTimeout(() => resolve(false), 110)),
dataRes,
])
.then((data: any) => {
if (!data) {
// data didn't win the race, show fallback
doRouteChange(routeInfo, false)
}
return dataRes
})
.then(finalData => {
// render with the data and complete route change
doRouteChange(finalData as RouteInfo, true)
}, reject)
} else {
doRouteChange(routeInfo, true)
}
}, reject)
})
}
Expand Down Expand Up @@ -486,25 +518,51 @@ 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`
{
pathname,
query,
asPath: as,
} as any
)
).then(props => {
const isSSG = (Component as any).__N_SSG
const isSSP = (Component as any).__N_SSP

const handleData = (props: any) => {
routeInfo.props = props
this.components[route] = routeInfo
return routeInfo
})
}

// resolve with fallback routeInfo and promise for data
if (isSSG || isSSP) {
const dataMethod = () =>
isSSG ? this._getStaticData(as) : this._getServerData(as)

const retry = (error: Error & { statusCode: number }) => {
if (error.statusCode === 404) {
throw error
}
return dataMethod()
}

return Promise.resolve({
...routeInfo,
props: {},
dataRes: this._getData(() =>
dataMethod()
// we retry for data twice unless we get a 404
.catch(retry)
.catch(retry)
.then((props: any) => handleData(props))
),
})
}

return this._getData<RouteInfo>(() =>
this.getInitialProps(
Component,
// we provide AppTree later so this needs to be `any`
{
pathname,
query,
asPath: as,
} as any
)
).then(props => handleData(props))
})
.catch(err => {
return new Promise(resolve => {
Expand Down
1 change: 1 addition & 0 deletions packages/next/next-server/lib/utils.ts
Expand Up @@ -76,6 +76,7 @@ export type NEXT_DATA = {
runtimeConfig?: { [key: string]: any }
nextExport?: boolean
autoExport?: boolean
isFallback?: boolean
dynamicIds?: string[]
err?: Error & { statusCode?: number }
}
Expand Down
29 changes: 28 additions & 1 deletion packages/next/next-server/server/next-server.ts
Expand Up @@ -43,7 +43,12 @@ import Router, {
} from './router'
import { sendHTML } from './send-html'
import { serveStatic } from './serve-static'
import { getSprCache, initializeSprCache, setSprCache } from './spr-cache'
import {
getSprCache,
initializeSprCache,
setSprCache,
getFallback,
} from './spr-cache'
import { isBlockedPage } from './utils'
import {
Redirect,
Expand Down Expand Up @@ -1010,6 +1015,28 @@ export default class Server {
return { html, pageData, sprRevalidate }
})

// render fallback if cached data wasn't available
if (!isResSent(res) && !isDataReq && isDynamicRoute(pathname)) {
let html = ''

if (!this.renderOpts.dev) {
html = await getFallback(pathname)
} else {
query.__nextFallback = 'true'
if (isLikeServerless) {
this.prepareServerlessUrl(req, query)
html = await (result.Component as any).renderReqToHTML(req, res)
} else {
html = (await renderToHTML(req, res, pathname, query, {
...result,
...opts,
})) as string
}
}

this.__sendPayload(res, html, 'text/html; charset=utf-8')
}

return doRender(ssgCacheKey, []).then(
async ({ isOrigin, value: { html, pageData, sprRevalidate } }) => {
// Respond to the request if a payload wasn't sent above (from cache)
Expand Down

0 comments on commit 3099f08

Please sign in to comment.