import { ComponentType } from 'react' import type { ClientSsgManifest } from '../build' import type { ClientBuildManifest } from '../build/webpack/plugins/build-manifest-plugin' import mitt from '../next-server/lib/mitt' import type { MittEmitter } from '../next-server/lib/mitt' import { addBasePath, markLoadingError } from '../next-server/lib/router/router' import escapePathDelimiters from '../next-server/lib/router/utils/escape-path-delimiters' import getAssetPathFromRoute from '../next-server/lib/router/utils/get-asset-path-from-route' import { isDynamicRoute } from '../next-server/lib/router/utils/is-dynamic' import { parseRelativeUrl } from '../next-server/lib/router/utils/parse-relative-url' import { searchParamsToUrlQuery } from '../next-server/lib/router/utils/querystring' import { getRouteMatcher } from '../next-server/lib/router/utils/route-matcher' import { getRouteRegex } from '../next-server/lib/router/utils/route-regex' function hasRel(rel: string, link?: HTMLLinkElement) { try { link = document.createElement('link') return link.relList.supports(rel) } catch {} } function pageLoadError(route: string) { return markLoadingError(new Error(`Error loading ${route}`)) } 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' const relPreload = hasRel('preload') ? 'preload' : relPrefetch const hasNoModule = 'noModule' in document.createElement('script') const requestIdleCallback: (fn: () => void) => void = (window as any).requestIdleCallback || function (cb: () => void) { return setTimeout(cb, 1) } function normalizeRoute(route: string) { if (route[0] !== '/') { throw new Error(`Route name should start with a "/", got "${route}"`) } if (route === '/') return route return route.replace(/\/$/, '') } export function createLink( href: string, rel: string, as?: string, link?: HTMLLinkElement ): [HTMLLinkElement, Promise] { link = document.createElement('link') return [ link, new Promise((res, rej) => { // The order of property assignment here is intentional: if (as) link!.as = as link!.rel = rel link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN! link!.onload = res link!.onerror = rej // `href` should always be last: link!.href = href }), ] } function appendLink(href: string, rel: string, as?: string): Promise { const [link, res] = createLink(href, rel, as) document.head.appendChild(link) return res } function loadScript(url: string): Promise { return new Promise((res, rej) => { const script = document.createElement('script') if (process.env.__NEXT_MODERN_BUILD && hasNoModule) { script.type = 'module' } script.crossOrigin = process.env.__NEXT_CROSS_ORIGIN! script.src = url script.onload = res script.onerror = () => rej(pageLoadError(url)) document.body.appendChild(script) }) } export type GoodPageCache = { page: ComponentType mod: any styleSheets: string[] } export type PageCacheEntry = { error: any } | GoodPageCache export default class PageLoader { private initialPage: string private initialStyleSheets: string[] private buildId: string private assetPrefix: string private pageCache: Record private pageRegisterEvents: MittEmitter private loadingRoutes: Record private promisedBuildManifest?: Promise private promisedSsgManifest?: Promise private promisedDevPagesManifest?: Promise constructor( buildId: string, assetPrefix: string, initialPage: string, initialStyleSheets: string[] ) { this.initialPage = initialPage this.initialStyleSheets = initialStyleSheets this.buildId = buildId this.assetPrefix = assetPrefix this.pageCache = {} this.pageRegisterEvents = mitt() 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 } this.promisedBuildManifest = new Promise((resolve) => { if ((window as any).__BUILD_MANIFEST) { resolve((window as any).__BUILD_MANIFEST) } else { ;(window as any).__BUILD_MANIFEST_CB = () => { resolve((window as any).__BUILD_MANIFEST) } } }) /** @type {Promise>} */ this.promisedSsgManifest = new Promise((resolve) => { if ((window as any).__SSG_MANIFEST) { resolve((window as any).__SSG_MANIFEST) } else { ;(window as any).__SSG_MANIFEST_CB = () => { resolve((window as any).__SSG_MANIFEST) } } }) } getPageList() { if (process.env.NODE_ENV === 'production') { return this.promisedBuildManifest!.then( (buildManifest) => buildManifest.sortedPages ) } else { if ((window as any).__DEV_PAGES_MANIFEST) { return (window as any).__DEV_PAGES_MANIFEST.pages } else { if (!this.promisedDevPagesManifest) { this.promisedDevPagesManifest = fetch( `${this.assetPrefix}/_next/static/development/_devPagesManifest.json` ) .then((res) => res.json()) .then((manifest) => { ;(window as any).__DEV_PAGES_MANIFEST = manifest return manifest.pages }) .catch((err) => { console.log(`Failed to fetch devPagesManifest`, err) }) } return this.promisedDevPagesManifest } } } // Returns a promise for the dependencies for a particular route private getDependencies(route: string): Promise { return this.promisedBuildManifest!.then((m) => { return m[route] ? m[route].map((url) => `${this.assetPrefix}/_next/${encodeURI(url)}`) : Promise.reject(pageLoadError(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 */ getDataHref(href: string, asPath: string, ssg: boolean) { const { pathname: hrefPathname, searchParams, search } = parseRelativeUrl( href ) const query = searchParamsToUrlQuery(searchParams) const { pathname: asPathname } = parseRelativeUrl(asPath) const route = normalizeRoute(hrefPathname) const getHrefForSlug = (path: string) => { const dataRoute = getAssetPathFromRoute(path, '.json') return addBasePath( `/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}` ) } let isDynamic: boolean = isDynamicRoute(route), interpolatedRoute: string | undefined 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 ( !Object.keys(dynamicGroups).every((param) => { let value = dynamicMatches[param] || '' const { repeat, optional } = dynamicGroups[param] // support single-level catch-all // TODO: more robust handling for user-error (passing `/`) let replaced = `[${repeat ? '...' : ''}${param}]` if (optional) { replaced = `${!value ? '/' : ''}[${replaced}]` } if (repeat && !Array.isArray(value)) value = [value] return ( (optional || param in dynamicMatches) && // Interpolate group into data URL if present (interpolatedRoute = interpolatedRoute!.replace( replaced, repeat ? (value as string[]).map(escapePathDelimiters).join('/') : escapePathDelimiters(value as string) ) || '/') ) }) ) { interpolatedRoute = '' // did not satisfy all requirements // n.b. We ignore this error because we handle warning for this case in // development in the `` 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: string, asPath: string) { const { pathname: hrefPathname } = parseRelativeUrl(href) const route = normalizeRoute(hrefPathname) return this.promisedSsgManifest!.then( (s: ClientSsgManifest, _dataHref?: string) => { requestIdleCallback(() => { // Check if the route requires a data file s.has(route) && // Try to generate data href, noop when falsy (_dataHref = this.getDataHref(href, asPath, true)) && // noop when data has already been prefetched (dedupe) !document.querySelector( `link[rel="${relPrefetch}"][href^="${_dataHref}"]` ) && // Inject the `` tag for above computed `href`. appendLink(_dataHref, relPrefetch, 'fetch') }) } ) } loadPage(route: string): Promise { route = normalizeRoute(route) return new Promise((resolve, reject) => { // If there's a cached version of the page, let's use it. const cachedPage = this.pageCache[route] if (cachedPage) { if ('error' in cachedPage) { reject(cachedPage.error) } else { resolve(cachedPage) } return } const fire = (pageToCache: PageCacheEntry) => { this.pageRegisterEvents.off(route, fire) delete this.loadingRoutes[route] if ('error' in pageToCache) { reject(pageToCache.error) } else { resolve(pageToCache) } } // Register a listener to get the page this.pageRegisterEvents.on(route, fire) if (!this.loadingRoutes[route]) { this.loadingRoutes[route] = true if (process.env.NODE_ENV === 'production') { this.getDependencies(route) .then((deps) => { const pending: Promise[] = [] deps.forEach((d) => { if ( d.endsWith('.js') && !document.querySelector(`script[src^="${d}"]`) ) { pending.push(loadScript(d)) } // Prefetch CSS as it'll be needed when the page JavaScript // evaluates. This will only trigger if explicit prefetching is // disabled for a ... prefetching in this case is desirable // because we *know* it's going to be used very soon (page was // loaded). if ( d.endsWith('.css') && !document.querySelector( `link[rel="${relPreload}"][href^="${d}"]` ) ) { // This is not pushed into `pending` because we don't need to // wait for these to resolve. To prevent an unhandled // rejection, we swallow the error which is handled later in // the rendering cycle (this is just a preload optimization). appendLink(d, relPreload, 'style').catch(() => { /* ignore preload error */ }) } }) return Promise.all(pending) }) .catch((err) => { // Mark the page as failed to load if any of its required scripts // fail to load: this.pageCache[route] = { error: err } fire({ error: err }) }) } else { // Development only. In production the page file is part of the build manifest route = normalizeRoute(route) let scriptRoute = getAssetPathFromRoute(route, '.js') const url = `${this.assetPrefix}/_next/static/chunks/pages${encodeURI( scriptRoute )}` loadScript(url).catch((err) => { // Mark the page as failed to load if its script fails to load: this.pageCache[route] = { error: err } fire({ error: err }) }) } } }) } // This method if called by the route code. registerPage(route: string, regFn: () => any) { const register = (styleSheets: string[]) => { try { const mod = regFn() const pageData: PageCacheEntry = { page: mod.default || mod, mod, styleSheets, } 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/vercel/next.js/pull/1511 if ((module as any).hot && (module as any).hot.status() !== 'idle') { console.log( `Waiting for webpack to become "idle" to initialize the page: "${route}"` ) const check = (status: string) => { if (status === 'idle') { ;(module as any).hot.removeStatusHandler(check) register( /* css is handled via style-loader in development */ [] ) } } ;(module as any).hot.status(check) return } } const promisedDeps: Promise = // Shared styles will already be on the page: route === '/_app' || // We use `style-loader` in development: process.env.NODE_ENV !== 'production' ? Promise.resolve([]) : route === this.initialPage ? Promise.resolve(this.initialStyleSheets) : // Tests that this does not block hydration: // test/integration/css-fixtures/hydrate-without-deps/ this.getDependencies(route) promisedDeps.then( (deps) => register(deps.filter((d) => d.endsWith('.css'))), (error) => { this.pageCache[route] = { error } this.pageRegisterEvents.emit(route, { error }) } ) } /** * @param {string} route * @param {boolean} [isDependency] */ prefetch(route: string, isDependency?: boolean): Promise { // https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118 // License: Apache 2.0 let cn if ((cn = (navigator as any).connection)) { // Don't prefetch if using 2G or if Save-Data is enabled. if (cn.saveData || /2g/.test(cn.effectiveType)) return Promise.resolve() } /** @type {string} */ let url if (isDependency) { url = route } else { if (process.env.NODE_ENV !== 'production') { route = normalizeRoute(route) const ext = process.env.__NEXT_MODERN_BUILD && hasNoModule ? '.module.js' : '.js' const scriptRoute = getAssetPathFromRoute(route, ext) url = `${this.assetPrefix}/_next/static/${encodeURIComponent( this.buildId )}/pages${encodeURI(scriptRoute)}` } } return Promise.all( document.querySelector(`link[rel="${relPrefetch}"][href^="${url}"]`) ? [] : [ url && appendLink( url, relPrefetch, url.endsWith('.css') ? 'style' : 'script' ), process.env.NODE_ENV === 'production' && !isDependency && this.getDependencies(route).then((urls) => Promise.all( urls.map((dependencyUrl) => this.prefetch(dependencyUrl, true) ) ) ), ] ).then( // do not return any data () => {}, // swallow prefetch errors () => {} ) } }