272 lines
7.6 KiB
JavaScript
272 lines
7.6 KiB
JavaScript
/* global document, window */
|
|
import mitt from '../next-server/lib/mitt'
|
|
|
|
const prefetchOrPreload = process.env.__NEXT_PREFETCH_PRELOAD
|
|
? 'prefetch'
|
|
: 'preload'
|
|
|
|
function supportsPreload(el) {
|
|
try {
|
|
return el.relList.supports(prefetchOrPreload)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
const hasPreload = supportsPreload(document.createElement('link'))
|
|
|
|
function preloadLink(url, resourceType) {
|
|
const link = document.createElement('link')
|
|
link.rel = prefetchOrPreload
|
|
link.crossOrigin = process.crossOrigin
|
|
link.href = url
|
|
link.as = resourceType
|
|
document.head.appendChild(link)
|
|
}
|
|
|
|
function loadStyle(url) {
|
|
const link = document.createElement('link')
|
|
link.rel = 'stylesheet'
|
|
link.crossOrigin = process.crossOrigin
|
|
link.href = url
|
|
document.head.appendChild(link)
|
|
}
|
|
|
|
export default class PageLoader {
|
|
constructor(buildId, assetPrefix) {
|
|
this.buildId = buildId
|
|
this.assetPrefix = assetPrefix
|
|
|
|
this.pageCache = {}
|
|
this.pageRegisterEvents = mitt()
|
|
this.loadingRoutes = {}
|
|
if (process.env.__NEXT_GRANULAR_CHUNKS) {
|
|
this.promisedBuildManifest = new Promise(resolve => {
|
|
if (window.__BUILD_MANIFEST) {
|
|
resolve(window.__BUILD_MANIFEST)
|
|
} else {
|
|
window.__BUILD_MANIFEST_CB = () => {
|
|
resolve(window.__BUILD_MANIFEST)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Returns a promise for the dependencies for a particular route
|
|
getDependencies(route) {
|
|
return this.promisedBuildManifest.then(
|
|
man =>
|
|
(man[route] && man[route].map(url => `/_next/${encodeURI(url)}`)) || []
|
|
)
|
|
}
|
|
|
|
normalizeRoute(route) {
|
|
if (route[0] !== '/') {
|
|
throw new Error(`Route name should start with a "/", got "${route}"`)
|
|
}
|
|
route = route.replace(/\/index$/, '/')
|
|
|
|
if (route === '/') return route
|
|
return route.replace(/\/$/, '')
|
|
}
|
|
|
|
loadPage(route) {
|
|
return this.loadPageScript(route).then(v => v.page)
|
|
}
|
|
|
|
loadPageScript(route) {
|
|
route = this.normalizeRoute(route)
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const fire = ({ error, page, mod }) => {
|
|
this.pageRegisterEvents.off(route, fire)
|
|
delete this.loadingRoutes[route]
|
|
|
|
if (error) {
|
|
reject(error)
|
|
} else {
|
|
resolve({ page, mod })
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Register a listener to get the page
|
|
this.pageRegisterEvents.on(route, fire)
|
|
|
|
// If the page is loading via SSR, we need to wait for it
|
|
// rather downloading it again.
|
|
if (document.querySelector(`script[data-next-page="${route}"]`)) {
|
|
return
|
|
}
|
|
|
|
if (!this.loadingRoutes[route]) {
|
|
if (process.env.__NEXT_GRANULAR_CHUNKS) {
|
|
this.getDependencies(route).then(deps => {
|
|
deps.forEach(d => {
|
|
if (
|
|
/\.js$/.test(d) &&
|
|
!document.querySelector(`script[src^="${d}"]`)
|
|
) {
|
|
this.loadScript(d, route, false)
|
|
}
|
|
if (
|
|
/\.css$/.test(d) &&
|
|
!document.querySelector(`link[rel=stylesheet][href^="${d}"]`)
|
|
) {
|
|
loadStyle(d) // FIXME: handle failure
|
|
}
|
|
})
|
|
this.loadRoute(route)
|
|
this.loadingRoutes[route] = true
|
|
})
|
|
} else {
|
|
this.loadRoute(route)
|
|
this.loadingRoutes[route] = true
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
async loadRoute(route) {
|
|
route = this.normalizeRoute(route)
|
|
let scriptRoute = route === '/' ? '/index.js' : `${route}.js`
|
|
|
|
const url = `${this.assetPrefix}/_next/static/${encodeURIComponent(
|
|
this.buildId
|
|
)}/pages${encodeURI(scriptRoute)}`
|
|
this.loadScript(url, route, true)
|
|
}
|
|
|
|
loadScript(url, route, isPage) {
|
|
const script = document.createElement('script')
|
|
if (process.env.__NEXT_MODERN_BUILD && 'noModule' in script) {
|
|
script.type = 'module'
|
|
// Only page bundle scripts need to have .module added to url,
|
|
// dependencies already have it added during build manifest creation
|
|
if (isPage) url = url.replace(/\.js$/, '.module.js')
|
|
}
|
|
script.crossOrigin = process.crossOrigin
|
|
script.src = url
|
|
script.onerror = () => {
|
|
const error = new Error(`Error loading script ${url}`)
|
|
error.code = 'PAGE_LOAD_ERROR'
|
|
this.pageRegisterEvents.emit(route, { error })
|
|
}
|
|
document.body.appendChild(script)
|
|
}
|
|
|
|
// This method if called by the route code.
|
|
registerPage(route, regFn) {
|
|
const register = () => {
|
|
try {
|
|
const mod = regFn()
|
|
const pageData = { page: mod.default || mod, mod }
|
|
this.pageCache[route] = pageData
|
|
this.pageRegisterEvents.emit(route, pageData)
|
|
} catch (error) {
|
|
this.pageCache[route] = { error }
|
|
this.pageRegisterEvents.emit(route, { error })
|
|
}
|
|
}
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
// Wait for webpack to become idle if it's not.
|
|
// More info: https://github.com/zeit/next.js/pull/1511
|
|
if (module.hot && module.hot.status() !== 'idle') {
|
|
console.log(
|
|
`Waiting for webpack to become "idle" to initialize the page: "${route}"`
|
|
)
|
|
|
|
const check = status => {
|
|
if (status === 'idle') {
|
|
module.hot.removeStatusHandler(check)
|
|
register()
|
|
}
|
|
}
|
|
module.hot.status(check)
|
|
return
|
|
}
|
|
}
|
|
|
|
register()
|
|
}
|
|
|
|
async prefetch(route, isDependency) {
|
|
route = this.normalizeRoute(route)
|
|
let scriptRoute = `${route === '/' ? '/index' : route}.js`
|
|
|
|
if (
|
|
process.env.__NEXT_MODERN_BUILD &&
|
|
'noModule' in document.createElement('script')
|
|
) {
|
|
scriptRoute = scriptRoute.replace(/\.js$/, '.module.js')
|
|
}
|
|
const url =
|
|
this.assetPrefix +
|
|
(isDependency
|
|
? route
|
|
: `/_next/static/${encodeURIComponent(this.buildId)}/pages${encodeURI(
|
|
scriptRoute
|
|
)}`)
|
|
|
|
// n.b. If preload is not supported, we fall back to `loadPage` which has
|
|
// its own deduping mechanism.
|
|
if (
|
|
document.querySelector(
|
|
`link[rel="${prefetchOrPreload}"][href^="${url}"], script[data-next-page="${route}"]`
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
|
|
// Inspired by quicklink, license: https://github.com/GoogleChromeLabs/quicklink/blob/master/LICENSE
|
|
let cn
|
|
if ((cn = navigator.connection)) {
|
|
// Don't prefetch if the user is on 2G or if Save-Data is enabled.
|
|
if ((cn.effectiveType || '').indexOf('2g') !== -1 || cn.saveData) {
|
|
return
|
|
}
|
|
}
|
|
|
|
if (process.env.__NEXT_GRANULAR_CHUNKS && !isDependency) {
|
|
;(await this.getDependencies(route)).forEach(url => {
|
|
this.prefetch(url, true)
|
|
})
|
|
}
|
|
|
|
// Feature detection is used to see if preload is supported
|
|
// If not fall back to loading script tags before the page is loaded
|
|
// https://caniuse.com/#feat=link-rel-preload
|
|
if (hasPreload) {
|
|
preloadLink(url, url.match(/\.css$/) ? 'style' : 'script')
|
|
return
|
|
}
|
|
|
|
if (isDependency) {
|
|
// loadPage will automatically handle depencies, so no need to
|
|
// preload them manually
|
|
return
|
|
}
|
|
|
|
if (document.readyState === 'complete') {
|
|
return this.loadPage(route).catch(() => {})
|
|
} else {
|
|
return new Promise(resolve => {
|
|
window.addEventListener('load', () => {
|
|
this.loadPage(route).then(
|
|
() => resolve(),
|
|
() => resolve()
|
|
)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}
|