2020-03-02 18:14:40 +01:00
|
|
|
import { parse } from 'url'
|
2019-09-04 16:00:54 +02:00
|
|
|
import mitt from '../next-server/lib/mitt'
|
2020-03-02 18:14:40 +01:00
|
|
|
import { isDynamicRoute } from './../next-server/lib/router/utils/is-dynamic'
|
|
|
|
import { getRouteMatcher } from './../next-server/lib/router/utils/route-matcher'
|
|
|
|
import { getRouteRegex } from './../next-server/lib/router/utils/route-regex'
|
2017-04-11 18:37:59 +02:00
|
|
|
|
2019-12-24 21:40:06 +01:00
|
|
|
function hasRel(rel, link) {
|
2018-11-25 00:47:39 +01:00
|
|
|
try {
|
2019-12-24 16:07:44 +01:00
|
|
|
link = document.createElement('link')
|
2019-12-24 21:40:06 +01:00
|
|
|
return link.relList.supports(rel)
|
2019-12-24 16:07:44 +01:00
|
|
|
} catch {}
|
2019-12-11 11:46:12 +01:00
|
|
|
}
|
|
|
|
|
2020-06-08 20:11:00 +02:00
|
|
|
function pageLoadError(route) {
|
|
|
|
const error = new Error(`Error loading ${route}`)
|
|
|
|
error.code = 'PAGE_LOAD_ERROR'
|
|
|
|
return error
|
|
|
|
}
|
|
|
|
|
2019-12-24 21:40:06 +01:00
|
|
|
const relPrefetch =
|
|
|
|
hasRel('preload') && !hasRel('prefetch')
|
|
|
|
? // https://caniuse.com/#feat=link-rel-preload
|
|
|
|
// macOS and iOS (Safari does not support prefetch)
|
|
|
|
'preload'
|
|
|
|
: // https://caniuse.com/#feat=link-rel-prefetch
|
|
|
|
// IE 11, Edge 12+, nearly all evergreen
|
|
|
|
'prefetch'
|
2019-12-24 16:07:44 +01:00
|
|
|
|
|
|
|
const hasNoModule = 'noModule' in document.createElement('script')
|
|
|
|
|
2020-03-02 18:14:40 +01:00
|
|
|
/** @param {string} route */
|
2020-01-04 17:58:32 +01:00
|
|
|
function normalizeRoute(route) {
|
|
|
|
if (route[0] !== '/') {
|
|
|
|
throw new Error(`Route name should start with a "/", got "${route}"`)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (route === '/') return route
|
|
|
|
return route.replace(/\/$/, '')
|
|
|
|
}
|
|
|
|
|
2020-06-04 19:32:45 +02:00
|
|
|
export function getAssetPath(route) {
|
|
|
|
return route === '/'
|
|
|
|
? '/index'
|
|
|
|
: /^\/index(\/|$)/.test(route)
|
|
|
|
? `/index${route}`
|
|
|
|
: `${route}`
|
|
|
|
}
|
|
|
|
|
2019-12-24 16:07:44 +01:00
|
|
|
function appendLink(href, rel, as) {
|
|
|
|
return new Promise((res, rej, link) => {
|
|
|
|
link = document.createElement('link')
|
2020-06-07 01:00:03 +02:00
|
|
|
link.crossOrigin = process.env.__NEXT_CROSS_ORIGIN
|
2019-12-24 16:07:44 +01:00
|
|
|
link.href = href
|
|
|
|
link.rel = rel
|
|
|
|
if (as) link.as = as
|
|
|
|
|
|
|
|
link.onload = res
|
|
|
|
link.onerror = rej
|
|
|
|
|
|
|
|
document.head.appendChild(link)
|
|
|
|
})
|
2019-07-31 18:03:38 +02:00
|
|
|
}
|
2017-04-06 08:49:00 +02:00
|
|
|
|
2017-04-03 20:10:24 +02:00
|
|
|
export default class PageLoader {
|
2020-06-08 20:11:00 +02:00
|
|
|
constructor(buildId, assetPrefix, initialPage) {
|
2017-04-03 20:10:24 +02:00
|
|
|
this.buildId = buildId
|
2017-04-18 06:18:43 +02:00
|
|
|
this.assetPrefix = assetPrefix
|
|
|
|
|
2017-04-03 20:10:24 +02:00
|
|
|
this.pageCache = {}
|
2019-01-04 21:49:21 +01:00
|
|
|
this.pageRegisterEvents = mitt()
|
2020-06-08 20:11:00 +02:00
|
|
|
this.loadingRoutes = {
|
|
|
|
// By default these 2 pages are being loaded in the initial html
|
|
|
|
'/_app': true,
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: get rid of this limitation for rendering the error page
|
|
|
|
if (initialPage !== '/_error') {
|
|
|
|
this.loadingRoutes[initialPage] = true
|
|
|
|
}
|
|
|
|
|
2020-06-02 16:45:07 +02:00
|
|
|
if (process.env.NODE_ENV === 'production') {
|
2020-05-18 21:24:37 +02:00
|
|
|
this.promisedBuildManifest = new Promise((resolve) => {
|
2019-08-08 19:14:33 +02:00
|
|
|
if (window.__BUILD_MANIFEST) {
|
|
|
|
resolve(window.__BUILD_MANIFEST)
|
|
|
|
} else {
|
|
|
|
window.__BUILD_MANIFEST_CB = () => {
|
|
|
|
resolve(window.__BUILD_MANIFEST)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
2020-03-02 18:14:40 +01:00
|
|
|
/** @type {Promise<Set<string>>} */
|
2020-05-18 21:24:37 +02:00
|
|
|
this.promisedSsgManifest = new Promise((resolve) => {
|
2020-03-02 18:14:40 +01:00
|
|
|
if (window.__SSG_MANIFEST) {
|
|
|
|
resolve(window.__SSG_MANIFEST)
|
|
|
|
} else {
|
|
|
|
window.__SSG_MANIFEST_CB = () => {
|
|
|
|
resolve(window.__SSG_MANIFEST)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
2019-08-08 19:14:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// Returns a promise for the dependencies for a particular route
|
2019-11-11 04:24:53 +01:00
|
|
|
getDependencies(route) {
|
2020-06-08 20:11:00 +02:00
|
|
|
return this.promisedBuildManifest.then((m) => {
|
|
|
|
return m[route]
|
|
|
|
? m[route].map((url) => `${this.assetPrefix}/_next/${encodeURI(url)}`)
|
|
|
|
: this.pageRegisterEvents.emit(route, {
|
|
|
|
error: pageLoadError(route),
|
|
|
|
}) ?? []
|
|
|
|
})
|
2017-04-03 20:10:24 +02:00
|
|
|
}
|
|
|
|
|
2020-03-02 18:14:40 +01:00
|
|
|
/**
|
|
|
|
* @param {string} href the route href (file-system path)
|
|
|
|
* @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes
|
|
|
|
*/
|
|
|
|
getDataHref(href, asPath) {
|
2020-04-26 17:14:39 +02:00
|
|
|
const getHrefForSlug = (/** @type string */ path) => {
|
2020-06-04 19:32:45 +02:00
|
|
|
const dataRoute = getAssetPath(path)
|
|
|
|
return `${this.assetPrefix}/_next/data/${this.buildId}${dataRoute}.json`
|
2020-04-26 17:14:39 +02:00
|
|
|
}
|
2020-03-02 18:14:40 +01:00
|
|
|
|
|
|
|
const { pathname: hrefPathname, query } = parse(href, true)
|
|
|
|
const { pathname: asPathname } = parse(asPath)
|
|
|
|
|
|
|
|
const route = normalizeRoute(hrefPathname)
|
|
|
|
|
|
|
|
let isDynamic = isDynamicRoute(route),
|
|
|
|
interpolatedRoute
|
|
|
|
if (isDynamic) {
|
|
|
|
const dynamicRegex = getRouteRegex(route)
|
|
|
|
const dynamicGroups = dynamicRegex.groups
|
|
|
|
const dynamicMatches =
|
|
|
|
// Try to match the dynamic route against the asPath
|
|
|
|
getRouteMatcher(dynamicRegex)(asPathname) ||
|
|
|
|
// Fall back to reading the values from the href
|
|
|
|
// TODO: should this take priority; also need to change in the router.
|
|
|
|
query
|
|
|
|
|
|
|
|
interpolatedRoute = route
|
|
|
|
if (
|
2020-05-18 21:24:37 +02:00
|
|
|
!Object.keys(dynamicGroups).every((param) => {
|
2020-03-02 18:14:40 +01:00
|
|
|
let value = dynamicMatches[param]
|
|
|
|
const repeat = dynamicGroups[param].repeat
|
|
|
|
|
|
|
|
// support single-level catch-all
|
|
|
|
// TODO: more robust handling for user-error (passing `/`)
|
|
|
|
if (repeat && !Array.isArray(value)) value = [value]
|
|
|
|
|
|
|
|
return (
|
|
|
|
param in dynamicMatches &&
|
|
|
|
// Interpolate group into data URL if present
|
|
|
|
(interpolatedRoute = interpolatedRoute.replace(
|
|
|
|
`[${repeat ? '...' : ''}${param}]`,
|
|
|
|
repeat
|
|
|
|
? value.map(encodeURIComponent).join('/')
|
|
|
|
: encodeURIComponent(value)
|
|
|
|
))
|
|
|
|
)
|
|
|
|
})
|
|
|
|
) {
|
|
|
|
interpolatedRoute = '' // did not satisfy all requirements
|
|
|
|
|
|
|
|
// n.b. We ignore this error because we handle warning for this case in
|
|
|
|
// development in the `<Link>` component directly.
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return isDynamic
|
|
|
|
? interpolatedRoute && getHrefForSlug(interpolatedRoute)
|
|
|
|
: getHrefForSlug(route)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {string} href the route href (file-system path)
|
|
|
|
* @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes
|
|
|
|
*/
|
|
|
|
prefetchData(href, asPath) {
|
|
|
|
const { pathname: hrefPathname } = parse(href, true)
|
|
|
|
const route = normalizeRoute(hrefPathname)
|
|
|
|
return this.promisedSsgManifest.then(
|
|
|
|
(s, _dataHref) =>
|
|
|
|
// Check if the route requires a data file
|
|
|
|
s.has(route) &&
|
|
|
|
// Try to generate data href, noop when falsy
|
|
|
|
(_dataHref = this.getDataHref(href, asPath)) &&
|
|
|
|
// noop when data has already been prefetched (dedupe)
|
|
|
|
!document.querySelector(
|
|
|
|
`link[rel="${relPrefetch}"][href^="${_dataHref}"]`
|
|
|
|
) &&
|
|
|
|
// Inject the `<link rel=prefetch>` tag for above computed `href`.
|
|
|
|
appendLink(_dataHref, relPrefetch, 'fetch')
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2019-11-11 04:24:53 +01:00
|
|
|
loadPage(route) {
|
2020-01-04 17:58:32 +01:00
|
|
|
route = normalizeRoute(route)
|
2017-04-03 20:10:24 +02:00
|
|
|
|
2017-04-11 18:37:59 +02:00
|
|
|
return new Promise((resolve, reject) => {
|
2020-06-08 20:11:00 +02:00
|
|
|
// If there's a cached version of the page, let's use it.
|
|
|
|
const cachedPage = this.pageCache[route]
|
|
|
|
if (cachedPage) {
|
|
|
|
const { error, page, mod } = cachedPage
|
|
|
|
error ? reject(error) : resolve({ page, mod })
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2019-10-03 21:15:23 +02:00
|
|
|
const fire = ({ error, page, mod }) => {
|
2017-04-17 22:15:50 +02:00
|
|
|
this.pageRegisterEvents.off(route, fire)
|
2017-06-23 07:16:55 +02:00
|
|
|
delete this.loadingRoutes[route]
|
2017-04-03 20:10:24 +02:00
|
|
|
|
2017-04-11 18:37:59 +02:00
|
|
|
if (error) {
|
|
|
|
reject(error)
|
|
|
|
} else {
|
2019-10-03 21:15:23 +02:00
|
|
|
resolve({ page, mod })
|
2017-04-11 18:37:59 +02:00
|
|
|
}
|
|
|
|
}
|
2017-04-03 20:10:24 +02:00
|
|
|
|
2017-06-23 07:16:55 +02:00
|
|
|
// Register a listener to get the page
|
2017-04-17 22:15:50 +02:00
|
|
|
this.pageRegisterEvents.on(route, fire)
|
2017-04-03 20:10:24 +02:00
|
|
|
|
2017-04-11 18:37:59 +02:00
|
|
|
if (!this.loadingRoutes[route]) {
|
2019-12-31 19:19:39 +01:00
|
|
|
this.loadingRoutes[route] = true
|
2020-06-02 16:45:07 +02:00
|
|
|
if (process.env.NODE_ENV === 'production') {
|
2020-05-18 21:24:37 +02:00
|
|
|
this.getDependencies(route).then((deps) => {
|
|
|
|
deps.forEach((d) => {
|
2019-11-11 04:24:53 +01:00
|
|
|
if (
|
2020-06-08 20:11:00 +02:00
|
|
|
d.endsWith('.js') &&
|
2019-11-11 04:24:53 +01:00
|
|
|
!document.querySelector(`script[src^="${d}"]`)
|
|
|
|
) {
|
2020-06-08 20:11:00 +02:00
|
|
|
this.loadScript(d, route)
|
2019-08-08 19:14:33 +02:00
|
|
|
}
|
2019-12-11 11:46:12 +01:00
|
|
|
if (
|
2020-06-08 20:11:00 +02:00
|
|
|
d.endsWith('.css') &&
|
2019-12-11 11:46:12 +01:00
|
|
|
!document.querySelector(`link[rel=stylesheet][href^="${d}"]`)
|
|
|
|
) {
|
2019-12-24 16:07:44 +01:00
|
|
|
appendLink(d, 'stylesheet').catch(() => {
|
|
|
|
// FIXME: handle failure
|
|
|
|
// Right now, this is needed to prevent an unhandled rejection.
|
|
|
|
})
|
2019-12-11 11:46:12 +01:00
|
|
|
}
|
2019-08-08 19:14:33 +02:00
|
|
|
})
|
|
|
|
})
|
|
|
|
} else {
|
2020-06-08 20:11:00 +02:00
|
|
|
// Development only. In production the page file is part of the build manifest
|
|
|
|
route = normalizeRoute(route)
|
|
|
|
let scriptRoute = getAssetPath(route)
|
|
|
|
|
|
|
|
const url = `${this.assetPrefix}/_next/static/${encodeURIComponent(
|
|
|
|
this.buildId
|
|
|
|
)}/pages${encodeURI(scriptRoute)}.js`
|
|
|
|
this.loadScript(url, route)
|
2019-08-08 19:14:33 +02:00
|
|
|
}
|
2017-04-11 18:37:59 +02:00
|
|
|
}
|
2017-04-03 20:10:24 +02:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2020-06-08 20:11:00 +02:00
|
|
|
loadScript(url, route) {
|
2019-08-08 19:14:33 +02:00
|
|
|
const script = document.createElement('script')
|
2019-12-24 16:07:44 +01:00
|
|
|
if (process.env.__NEXT_MODERN_BUILD && hasNoModule) {
|
2019-07-25 04:16:32 +02:00
|
|
|
script.type = 'module'
|
|
|
|
}
|
2020-06-07 01:00:03 +02:00
|
|
|
script.crossOrigin = process.env.__NEXT_CROSS_ORIGIN
|
2019-12-05 19:10:37 +01:00
|
|
|
script.src = url
|
2017-04-11 18:37:59 +02:00
|
|
|
script.onerror = () => {
|
2020-06-08 20:11:00 +02:00
|
|
|
this.pageRegisterEvents.emit(route, { error: pageLoadError(url) })
|
2017-04-03 20:10:24 +02:00
|
|
|
}
|
|
|
|
document.body.appendChild(script)
|
|
|
|
}
|
|
|
|
|
|
|
|
// This method if called by the route code.
|
2019-11-11 04:24:53 +01:00
|
|
|
registerPage(route, regFn) {
|
2017-04-11 16:33:18 +02:00
|
|
|
const register = () => {
|
2017-06-16 13:13:55 +02:00
|
|
|
try {
|
2019-09-11 19:21:10 +02:00
|
|
|
const mod = regFn()
|
|
|
|
const pageData = { page: mod.default || mod, mod }
|
|
|
|
this.pageCache[route] = pageData
|
|
|
|
this.pageRegisterEvents.emit(route, pageData)
|
2017-06-16 13:13:55 +02:00
|
|
|
} catch (error) {
|
|
|
|
this.pageCache[route] = { error }
|
|
|
|
this.pageRegisterEvents.emit(route, { error })
|
|
|
|
}
|
2017-04-06 08:49:00 +02:00
|
|
|
}
|
|
|
|
|
2018-12-31 19:06:36 +01:00
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
|
|
// Wait for webpack to become idle if it's not.
|
2020-05-27 23:51:11 +02:00
|
|
|
// More info: https://github.com/vercel/next.js/pull/1511
|
2018-12-31 19:06:36 +01:00
|
|
|
if (module.hot && module.hot.status() !== 'idle') {
|
2019-05-29 13:57:26 +02:00
|
|
|
console.log(
|
|
|
|
`Waiting for webpack to become "idle" to initialize the page: "${route}"`
|
|
|
|
)
|
2018-12-31 19:06:36 +01:00
|
|
|
|
2020-05-18 21:24:37 +02:00
|
|
|
const check = (status) => {
|
2018-12-31 19:06:36 +01:00
|
|
|
if (status === 'idle') {
|
|
|
|
module.hot.removeStatusHandler(check)
|
|
|
|
register()
|
|
|
|
}
|
2017-04-06 08:49:00 +02:00
|
|
|
}
|
2018-12-31 19:06:36 +01:00
|
|
|
module.hot.status(check)
|
|
|
|
return
|
2017-04-06 08:49:00 +02:00
|
|
|
}
|
|
|
|
}
|
2018-12-31 19:06:36 +01:00
|
|
|
|
|
|
|
register()
|
2017-04-03 20:10:24 +02:00
|
|
|
}
|
2017-04-04 21:55:56 +02:00
|
|
|
|
2020-03-02 18:14:40 +01:00
|
|
|
/**
|
|
|
|
* @param {string} route
|
|
|
|
* @param {boolean} [isDependency]
|
|
|
|
*/
|
2020-01-04 17:58:32 +01:00
|
|
|
prefetch(route, isDependency) {
|
2019-12-24 16:07:44 +01:00
|
|
|
// https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118
|
|
|
|
// License: Apache 2.0
|
2019-07-31 18:03:38 +02:00
|
|
|
let cn
|
|
|
|
if ((cn = navigator.connection)) {
|
2019-12-24 16:07:44 +01:00
|
|
|
// Don't prefetch if using 2G or if Save-Data is enabled.
|
2020-01-04 17:58:32 +01:00
|
|
|
if (cn.saveData || /2g/.test(cn.effectiveType)) return Promise.resolve()
|
2018-12-13 19:08:23 +01:00
|
|
|
}
|
|
|
|
|
2020-03-02 18:14:40 +01:00
|
|
|
/** @type {string} */
|
2020-01-06 16:55:39 +01:00
|
|
|
let url
|
2019-12-24 16:07:44 +01:00
|
|
|
if (isDependency) {
|
2020-01-06 16:55:39 +01:00
|
|
|
url = route
|
2019-12-24 16:07:44 +01:00
|
|
|
} else {
|
2020-06-08 20:11:00 +02:00
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
|
|
route = normalizeRoute(route)
|
2019-08-08 19:14:33 +02:00
|
|
|
|
2020-06-08 20:11:00 +02:00
|
|
|
const scriptRoute = getAssetPath(route)
|
|
|
|
const ext =
|
|
|
|
process.env.__NEXT_MODERN_BUILD && hasNoModule ? '.module.js' : '.js'
|
2019-12-24 16:07:44 +01:00
|
|
|
|
2020-06-08 20:11:00 +02:00
|
|
|
url = `${this.assetPrefix}/_next/static/${encodeURIComponent(
|
|
|
|
this.buildId
|
|
|
|
)}/pages${encodeURI(scriptRoute)}${ext}`
|
|
|
|
}
|
2019-08-08 19:14:33 +02:00
|
|
|
}
|
2019-04-04 23:54:01 +02:00
|
|
|
|
2020-01-04 17:58:32 +01:00
|
|
|
return Promise.all(
|
2020-06-08 20:11:00 +02:00
|
|
|
document.querySelector(`link[rel="${relPrefetch}"][href^="${url}"]`)
|
2020-01-04 17:58:32 +01:00
|
|
|
? []
|
|
|
|
: [
|
2020-06-08 20:11:00 +02:00
|
|
|
url &&
|
|
|
|
appendLink(
|
|
|
|
url,
|
|
|
|
relPrefetch,
|
|
|
|
url.endsWith('.css') ? 'style' : 'script'
|
|
|
|
),
|
2020-06-02 16:45:07 +02:00
|
|
|
process.env.NODE_ENV === 'production' &&
|
2020-01-04 17:58:32 +01:00
|
|
|
!isDependency &&
|
2020-05-18 21:24:37 +02:00
|
|
|
this.getDependencies(route).then((urls) =>
|
2020-06-01 23:00:22 +02:00
|
|
|
Promise.all(
|
|
|
|
urls.map((dependencyUrl) =>
|
|
|
|
this.prefetch(dependencyUrl, true)
|
|
|
|
)
|
|
|
|
)
|
2020-01-04 17:58:32 +01:00
|
|
|
),
|
|
|
|
]
|
|
|
|
).then(
|
2019-12-24 16:07:44 +01:00
|
|
|
// do not return any data
|
|
|
|
() => {},
|
|
|
|
// swallow prefetch errors
|
|
|
|
() => {}
|
|
|
|
)
|
2018-11-25 00:47:39 +01:00
|
|
|
}
|
2017-04-03 20:10:24 +02:00
|
|
|
}
|