Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial SSG fallback handling #10424

Merged
merged 22 commits into from Feb 7, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7bf0454
Add initial SSG fallback handling
ijjk Feb 4, 2020
c20da12
Remove extra changes and update fallback handling
ijjk Feb 5, 2020
0aba4c8
Merge remote-tracking branch 'upstream/canary' into add/ssg-loading-s…
ijjk Feb 5, 2020
99188d3
Remove extra timeout for testing
ijjk Feb 5, 2020
9a8e710
Update SSG tests in dynamic-routing suite
ijjk Feb 5, 2020
0e0d4f9
Merge remote-tracking branch 'upstream/canary' into add/ssg-loading-s…
ijjk Feb 5, 2020
4dc3f7f
Add racing to decide between rendering fallback and data
ijjk Feb 5, 2020
778db98
Update size-limit test
ijjk Feb 5, 2020
591b4a0
Update comment
ijjk Feb 5, 2020
efd508b
Make sure to follow correct route change order
ijjk Feb 5, 2020
524f4b0
Merge remote-tracking branch 'upstream/canary' into add/ssg-loading-s…
ijjk Feb 5, 2020
fc21a4b
Merge remote-tracking branch 'upstream/canary' into add/ssg-loading-s…
ijjk Feb 5, 2020
70546aa
Make comment more verbose for racing
ijjk Feb 5, 2020
a642f61
Merge branch 'canary' into add/ssg-loading-state
ijjk Feb 6, 2020
d56c583
Revert getStaticData to only return Promise
ijjk Feb 7, 2020
cebfcbc
Make sure to update URL on fallback
ijjk Feb 7, 2020
66e7e51
Add retrying for data, de-dupe initial fallback request, and merge fa…
ijjk Feb 7, 2020
e5db1bb
Update to add preload for fallback pages data
ijjk Feb 7, 2020
310e98d
Add test for data preload link
ijjk Feb 7, 2020
ff66599
Use pre-built fallback in production mode
ijjk Feb 7, 2020
6f50f3d
Remove preload link for fallback from _document
ijjk Feb 7, 2020
e647434
Update to make sure fallback is rendered correctly for serverless
ijjk Feb 7, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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)),
ijjk marked this conversation as resolved.
Show resolved Hide resolved
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)) {
ijjk marked this conversation as resolved.
Show resolved Hide resolved
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