rsnext/packages/next/client/route-loader.ts
Jiachi Liu 582cb3766d
Fix: only contain middleware in dev middleware manifest (#39217)
x-ref #39199

The change in #39199 isn't correct. Middleware manifest should only contain middleware route, so that when router navigates, it only try to apply middleware instead of checking all edge routes. This PR also changes the middleware manifest global value from array to object for easier access

## Bug

- [ ] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`
2022-08-01 13:23:24 +00:00

433 lines
13 KiB
TypeScript

import type { ComponentType } from 'react'
import getAssetPathFromRoute from '../shared/lib/router/utils/get-asset-path-from-route'
import { __unsafeCreateTrustedScriptURL } from './trusted-types'
import { requestIdleCallback } from './request-idle-callback'
// 3.8s was arbitrarily chosen as it's what https://web.dev/interactive
// considers as "Good" time-to-interactive. We must assume something went
// wrong beyond this point, and then fall-back to a full page transition to
// show the user something of value.
const MS_MAX_IDLE_DELAY = 3800
declare global {
interface Window {
__BUILD_MANIFEST?: Record<string, string[]>
__BUILD_MANIFEST_CB?: Function
__MIDDLEWARE_MANIFEST?: { location: string }
__MIDDLEWARE_MANIFEST_CB?: Function
}
}
interface LoadedEntrypointSuccess {
component: ComponentType
exports: any
}
interface LoadedEntrypointFailure {
error: unknown
}
type RouteEntrypoint = LoadedEntrypointSuccess | LoadedEntrypointFailure
interface RouteStyleSheet {
href: string
content: string
}
interface LoadedRouteSuccess extends LoadedEntrypointSuccess {
styles: RouteStyleSheet[]
}
interface LoadedRouteFailure {
error: unknown
}
type RouteLoaderEntry = LoadedRouteSuccess | LoadedRouteFailure
interface Future<V> {
resolve: (entrypoint: V) => void
future: Promise<V>
}
function withFuture<T>(
key: string,
map: Map<string, Future<T> | T>,
generator?: () => Promise<T>
): Promise<T> {
let entry: Future<T> | T | undefined = map.get(key)
if (entry) {
if ('future' in entry) {
return entry.future
}
return Promise.resolve(entry)
}
let resolver: (entrypoint: T) => void
const prom: Promise<T> = new Promise<T>((resolve) => {
resolver = resolve
})
map.set(key, (entry = { resolve: resolver!, future: prom }))
return generator
? generator()
// eslint-disable-next-line no-sequences
.then((value) => (resolver(value), value))
.catch((err) => {
map.delete(key)
throw err
})
: prom
}
export interface RouteLoader {
whenEntrypoint(route: string): Promise<RouteEntrypoint>
onEntrypoint(route: string, execute: () => unknown): void
loadRoute(route: string, prefetch?: boolean): Promise<RouteLoaderEntry>
prefetch(route: string): Promise<void>
}
function hasPrefetch(link?: HTMLLinkElement): boolean {
try {
link = document.createElement('link')
return (
// detect IE11 since it supports prefetch but isn't detected
// with relList.support
(!!window.MSInputMethodContext && !!(document as any).documentMode) ||
link.relList.supports('prefetch')
)
} catch {
return false
}
}
const canPrefetch: boolean = hasPrefetch()
function prefetchViaDom(
href: string,
as: string,
link?: HTMLLinkElement
): Promise<any> {
return new Promise<void>((res, rej) => {
const selector = `
link[rel="prefetch"][href^="${href}"],
link[rel="preload"][href^="${href}"],
script[src^="${href}"]`
if (document.querySelector(selector)) {
return res()
}
link = document.createElement('link')
// The order of property assignment here is intentional:
if (as) link!.as = as
link!.rel = `prefetch`
link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
link!.onload = res as any
link!.onerror = rej
// `href` should always be last:
link!.href = href
document.head.appendChild(link)
})
}
const ASSET_LOAD_ERROR = Symbol('ASSET_LOAD_ERROR')
// TODO: unexport
export function markAssetError(err: Error): Error {
return Object.defineProperty(err, ASSET_LOAD_ERROR, {})
}
export function isAssetError(err?: Error): boolean | undefined {
return err && ASSET_LOAD_ERROR in err
}
function appendScript(
src: TrustedScriptURL | string,
script?: HTMLScriptElement
): Promise<unknown> {
return new Promise((resolve, reject) => {
script = document.createElement('script')
// The order of property assignment here is intentional.
// 1. Setup success/failure hooks in case the browser synchronously
// executes when `src` is set.
script.onload = resolve
script.onerror = () =>
reject(markAssetError(new Error(`Failed to load script: ${src}`)))
// 2. Configure the cross-origin attribute before setting `src` in case the
// browser begins to fetch.
script.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
// 3. Finally, set the source and inject into the DOM in case the child
// must be appended for fetching to start.
script.src = src as string
document.body.appendChild(script)
})
}
// We wait for pages to be built in dev before we start the route transition
// timeout to prevent an un-necessary hard navigation in development.
let devBuildPromise: Promise<void> | undefined
// Resolve a promise that times out after given amount of milliseconds.
function resolvePromiseWithTimeout<T>(
p: Promise<T>,
ms: number,
err: Error
): Promise<T> {
return new Promise((resolve, reject) => {
let cancelled = false
p.then((r) => {
// Resolved, cancel the timeout
cancelled = true
resolve(r)
}).catch(reject)
// We wrap these checks separately for better dead-code elimination in
// production bundles.
if (process.env.NODE_ENV === 'development') {
;(devBuildPromise || Promise.resolve()).then(() => {
requestIdleCallback(() =>
setTimeout(() => {
if (!cancelled) {
reject(err)
}
}, ms)
)
})
}
if (process.env.NODE_ENV !== 'development') {
requestIdleCallback(() =>
setTimeout(() => {
if (!cancelled) {
reject(err)
}
}, ms)
)
}
})
}
// TODO: stop exporting or cache the failure
// It'd be best to stop exporting this. It's an implementation detail. We're
// only exporting it for backwards compatibility with the `page-loader`.
// Only cache this response as a last resort if we cannot eliminate all other
// code branches that use the Build Manifest Callback and push them through
// the Route Loader interface.
export function getClientBuildManifest() {
if (self.__BUILD_MANIFEST) {
return Promise.resolve(self.__BUILD_MANIFEST)
}
const onBuildManifest = new Promise<Record<string, string[]>>((resolve) => {
// Mandatory because this is not concurrent safe:
const cb = self.__BUILD_MANIFEST_CB
self.__BUILD_MANIFEST_CB = () => {
resolve(self.__BUILD_MANIFEST!)
cb && cb()
}
})
return resolvePromiseWithTimeout(
onBuildManifest,
MS_MAX_IDLE_DELAY,
markAssetError(new Error('Failed to load client build manifest'))
)
}
interface RouteFiles {
scripts: (TrustedScriptURL | string)[]
css: string[]
}
function getFilesForRoute(
assetPrefix: string,
route: string
): Promise<RouteFiles> {
if (process.env.NODE_ENV === 'development') {
const scriptUrl =
assetPrefix +
'/_next/static/chunks/pages' +
encodeURI(getAssetPathFromRoute(route, '.js'))
return Promise.resolve({
scripts: [__unsafeCreateTrustedScriptURL(scriptUrl)],
// Styles are handled by `style-loader` in development:
css: [],
})
}
return getClientBuildManifest().then((manifest) => {
if (!(route in manifest)) {
throw markAssetError(new Error(`Failed to lookup route: ${route}`))
}
const allFiles = manifest[route].map(
(entry) => assetPrefix + '/_next/' + encodeURI(entry)
)
return {
scripts: allFiles
.filter((v) => v.endsWith('.js'))
.map((v) => __unsafeCreateTrustedScriptURL(v)),
css: allFiles.filter((v) => v.endsWith('.css')),
}
})
}
export function createRouteLoader(assetPrefix: string): RouteLoader {
const entrypoints: Map<string, Future<RouteEntrypoint> | RouteEntrypoint> =
new Map()
const loadedScripts: Map<string, Promise<unknown>> = new Map()
const styleSheets: Map<string, Promise<RouteStyleSheet>> = new Map()
const routes: Map<string, Future<RouteLoaderEntry> | RouteLoaderEntry> =
new Map()
function maybeExecuteScript(
src: TrustedScriptURL | string
): Promise<unknown> {
// With HMR we might need to "reload" scripts when they are
// disposed and readded. Executing scripts twice has no functional
// differences
if (process.env.NODE_ENV !== 'development') {
let prom: Promise<unknown> | undefined = loadedScripts.get(src.toString())
if (prom) {
return prom
}
// Skip executing script if it's already in the DOM:
if (document.querySelector(`script[src^="${src}"]`)) {
return Promise.resolve()
}
loadedScripts.set(src.toString(), (prom = appendScript(src)))
return prom
} else {
return appendScript(src)
}
}
function fetchStyleSheet(href: string): Promise<RouteStyleSheet> {
let prom: Promise<RouteStyleSheet> | undefined = styleSheets.get(href)
if (prom) {
return prom
}
styleSheets.set(
href,
(prom = fetch(href)
.then((res) => {
if (!res.ok) {
throw new Error(`Failed to load stylesheet: ${href}`)
}
return res.text().then((text) => ({ href: href, content: text }))
})
.catch((err) => {
throw markAssetError(err)
}))
)
return prom
}
return {
whenEntrypoint(route: string) {
return withFuture(route, entrypoints)
},
onEntrypoint(route: string, execute: undefined | (() => unknown)) {
;(execute
? Promise.resolve()
.then(() => execute())
.then(
(exports: any) => ({
component: (exports && exports.default) || exports,
exports: exports,
}),
(err) => ({ error: err })
)
: Promise.resolve(undefined)
).then((input: RouteEntrypoint | undefined) => {
const old = entrypoints.get(route)
if (old && 'resolve' in old) {
if (input) {
entrypoints.set(route, input)
old.resolve(input)
}
} else {
if (input) {
entrypoints.set(route, input)
} else {
entrypoints.delete(route)
}
// when this entrypoint has been resolved before
// the route is outdated and we want to invalidate
// this cache entry
routes.delete(route)
}
})
},
loadRoute(route: string, prefetch?: boolean) {
return withFuture<RouteLoaderEntry>(route, routes, () => {
let devBuildPromiseResolve: () => void
if (process.env.NODE_ENV === 'development') {
devBuildPromise = new Promise<void>((resolve) => {
devBuildPromiseResolve = resolve
})
}
return resolvePromiseWithTimeout(
getFilesForRoute(assetPrefix, route)
.then(({ scripts, css }) => {
return Promise.all([
entrypoints.has(route)
? []
: Promise.all(scripts.map(maybeExecuteScript)),
Promise.all(css.map(fetchStyleSheet)),
] as const)
})
.then((res) => {
return this.whenEntrypoint(route).then((entrypoint) => ({
entrypoint,
styles: res[1],
}))
}),
MS_MAX_IDLE_DELAY,
markAssetError(new Error(`Route did not complete loading: ${route}`))
)
.then(({ entrypoint, styles }) => {
const res: RouteLoaderEntry = Object.assign<
{ styles: RouteStyleSheet[] },
RouteEntrypoint
>({ styles: styles! }, entrypoint)
return 'error' in entrypoint ? entrypoint : res
})
.catch((err) => {
if (prefetch) {
// we don't want to cache errors during prefetch
throw err
}
return { error: err }
})
.finally(() => devBuildPromiseResolve?.())
})
},
prefetch(route: string): Promise<void> {
// 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()
}
return getFilesForRoute(assetPrefix, route)
.then((output) =>
Promise.all(
canPrefetch
? output.scripts.map((script) =>
prefetchViaDom(script.toString(), 'script')
)
: []
)
)
.then(() => {
requestIdleCallback(() => this.loadRoute(route, true).catch(() => {}))
})
.catch(
// swallow prefetch errors
() => {}
)
},
}
}