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 16 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
25 changes: 25 additions & 0 deletions packages/next/client/index.js
Expand Up @@ -96,9 +96,28 @@ class Container extends React.Component {
})
}

// call router.replace to trigger data fetching while
// the fallback is shown

if (data.isFallback) {
ijjk marked this conversation as resolved.
Show resolved Hide resolved
router.replace(
{
pathname: page,
query: {
...router.query,
...parseQs(location.search.substr(1)),
},
},
asPath,
{ _h: 1 }
)
}

// If page was exported and has a querystring
// If it's a dynamic route or has a querystring

if (
!data.isFallback &&
router.isSsr &&
((data.nextExport &&
(isDynamicRoute(router.pathname) || location.search)) ||
Expand Down Expand Up @@ -218,6 +237,12 @@ export default async ({ webpackHMR: passedWebpackHMR } = {}) => {
const renderCtx = { App, Component, props, err: initialErr }

if (process.env.NODE_ENV === 'production') {
// kick off static data request now so it's in the cache
// when we re-render post-hydration
if (data.isFallback) {
router._getStaticData(asPath).catch(() => {})
ijjk marked this conversation as resolved.
Show resolved Hide resolved
}

render(renderCtx)
return emitter
}
Expand Down
112 changes: 78 additions & 34 deletions packages/next/next-server/lib/router/router.ts
Expand Up @@ -393,31 +393,60 @@ 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
const doRouteChange = (routeInfo: RouteInfo, emit: boolean) => {
const { error } = routeInfo

if (error && error.cancelled) {
return resolve(false)
}
if (error && error.cancelled) {
return resolve(false)
}

Router.events.emit('beforeHistoryChange', as)
this.changeState(method, url, addBasePath(as), options)
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
}
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 (error) {
Router.events.emit('routeChangeError', error, as)
throw error
if (emit) {
ijjk marked this conversation as resolved.
Show resolved Hide resolved
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 +515,40 @@ 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) {
return Promise.resolve({
...routeInfo,
props: {},
dataRes: this._getData(() =>
(isSSG
? this._getStaticData(as)
: this._getServerData(as)
).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
17 changes: 17 additions & 0 deletions packages/next/next-server/server/next-server.ts
Expand Up @@ -1010,6 +1010,23 @@ 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
query.__nextFallback = 'true'
let html = ''
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
17 changes: 15 additions & 2 deletions packages/next/next-server/server/render.tsx
Expand Up @@ -152,6 +152,7 @@ function renderDocument(
runtimeConfig,
nextExport,
autoExport,
isFallback,
dynamicImportsIds,
dangerousAsPath,
hasCssMode,
Expand Down Expand Up @@ -187,6 +188,7 @@ function renderDocument(
htmlProps: any
bodyTags: any
headTags: any
isFallback?: boolean
}
): string {
return (
Expand All @@ -203,6 +205,7 @@ function renderDocument(
runtimeConfig, // runtimeConfig if provided, otherwise don't sent in the resulting HTML
nextExport, // If this is a page exported by `next export`
autoExport, // If this is an auto exported page
isFallback,
dynamicIds:
dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds,
err: err ? serializeError(dev, err) : undefined, // Error if one happened, otherwise don't sent in the resulting HTML
Expand Down Expand Up @@ -294,6 +297,9 @@ export async function renderToHTML(
const bodyTags = (...args: any) => callMiddleware('bodyTags', args)
const htmlProps = (...args: any) => callMiddleware('htmlProps', args, true)

const isFallback = !!query.__nextFallback
delete query.__nextFallback

const isSpr = !!unstable_getStaticProps
const defaultAppGetInitialProps =
App.getInitialProps === (App as any).origGetInitialProps
Expand Down Expand Up @@ -428,7 +434,7 @@ export async function renderToHTML(
ctx,
})

if (isSpr) {
if (isSpr && !isFallback) {
const data = await unstable_getStaticProps!({
params: isDynamicRoute(pathname) ? (query as any) : undefined,
})
Expand Down Expand Up @@ -483,7 +489,7 @@ export async function renderToHTML(
renderOpts.err = err
}

if (unstable_getServerProps) {
if (unstable_getServerProps && !isFallback) {
const data = await unstable_getServerProps({
params,
query,
Expand All @@ -504,6 +510,12 @@ export async function renderToHTML(
// _app's getInitialProps for getServerProps if not this can be removed
if (isDataReq) return props
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The below code setting props.pageProps to an empty object probably has to come before this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't generate a fallback for data requests so this is ok to come before, unless you think it flows more clearly coming after?


// We don't call getStaticProps or getServerProps while generating
// the fallback so make sure to set pageProps to an empty object
if (isFallback) {
props.pageProps = {}
}

// the response might be finished on the getInitialProps call
if (isResSent(res) && !isSpr) return null

Expand Down Expand Up @@ -600,6 +612,7 @@ export async function renderToHTML(
headTags: await headTags(documentCtx),
bodyTags: await bodyTags(documentCtx),
htmlProps: await htmlProps(documentCtx),
isFallback,
docProps,
pathname,
ampPath,
Expand Down