page-loader.ts 13.0 KB
Newer Older
J
Joe Haddad 已提交
1
import type { ComponentType } from 'react'
J
Joe Haddad 已提交
2 3
import type { ClientSsgManifest } from '../build'
import type { ClientBuildManifest } from '../build/webpack/plugins/build-manifest-plugin'
4
import mitt from '../next-server/lib/mitt'
J
Joe Haddad 已提交
5
import type { MittEmitter } from '../next-server/lib/mitt'
6 7
import { addBasePath, markLoadingError } from '../next-server/lib/router/router'
import escapePathDelimiters from '../next-server/lib/router/utils/escape-path-delimiters'
J
Joe Haddad 已提交
8 9 10 11 12 13 14 15
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) {
16
  try {
J
Joe Haddad 已提交
17
    link = document.createElement('link')
J
Joe Haddad 已提交
18
    return link.relList.supports(rel)
J
Joe Haddad 已提交
19
  } catch {}
20 21
}

J
Joe Haddad 已提交
22
function pageLoadError(route: string) {
23
  return markLoadingError(new Error(`Error loading ${route}`))
24 25
}

J
Joe Haddad 已提交
26 27 28 29 30 31 32 33
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'
J
Joe Haddad 已提交
34 35 36

const hasNoModule = 'noModule' in document.createElement('script')

J
Joe Haddad 已提交
37 38 39
const requestIdleCallback: (fn: () => void) => void =
  (window as any).requestIdleCallback ||
  function (cb: () => void) {
40 41 42
    return setTimeout(cb, 1)
  }

J
Joe Haddad 已提交
43
function normalizeRoute(route: string) {
44 45 46 47 48 49 50 51
  if (route[0] !== '/') {
    throw new Error(`Route name should start with a "/", got "${route}"`)
  }

  if (route === '/') return route
  return route.replace(/\/$/, '')
}

J
Joe Haddad 已提交
52 53 54 55 56 57 58
function appendLink(
  href: string,
  rel: string,
  as?: string,
  link?: HTMLLinkElement
): Promise<any> {
  return new Promise((res, rej) => {
J
Joe Haddad 已提交
59
    link = document.createElement('link')
J
Joe Haddad 已提交
60
    link.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
J
Joe Haddad 已提交
61 62 63 64 65 66 67 68 69
    link.href = href
    link.rel = rel
    if (as) link.as = as

    link.onload = res
    link.onerror = rej

    document.head.appendChild(link)
  })
J
Joe Haddad 已提交
70
}
71

J
Joe Haddad 已提交
72
export type GoodPageCache = { page: ComponentType; mod: any }
J
Joe Haddad 已提交
73
export type PageCacheEntry = { error: any } | GoodPageCache
J
Joe Haddad 已提交
74

75
export default class PageLoader {
J
Joe Haddad 已提交
76 77 78 79 80 81 82 83 84
  private buildId: string
  private assetPrefix: string
  private pageCache: Record<string, PageCacheEntry>
  private pageRegisterEvents: MittEmitter
  private loadingRoutes: Record<string, boolean>
  private promisedBuildManifest?: Promise<ClientBuildManifest>
  private promisedSsgManifest?: Promise<ClientSsgManifest>

  constructor(buildId: string, assetPrefix: string, initialPage: string) {
85
    this.buildId = buildId
86 87
    this.assetPrefix = assetPrefix

88
    this.pageCache = {}
L
Luc 已提交
89
    this.pageRegisterEvents = mitt()
90 91 92 93 94 95 96 97 98 99
    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
    }

100
    if (process.env.NODE_ENV === 'production') {
J
Joe Haddad 已提交
101
      this.promisedBuildManifest = new Promise((resolve) => {
J
Joe Haddad 已提交
102 103
        if ((window as any).__BUILD_MANIFEST) {
          resolve((window as any).__BUILD_MANIFEST)
104
        } else {
J
Joe Haddad 已提交
105 106
          ;(window as any).__BUILD_MANIFEST_CB = () => {
            resolve((window as any).__BUILD_MANIFEST)
107 108 109 110
          }
        }
      })
    }
J
Joe Haddad 已提交
111
    /** @type {Promise<Set<string>>} */
J
Joe Haddad 已提交
112
    this.promisedSsgManifest = new Promise((resolve) => {
J
Joe Haddad 已提交
113 114
      if ((window as any).__SSG_MANIFEST) {
        resolve((window as any).__SSG_MANIFEST)
J
Joe Haddad 已提交
115
      } else {
J
Joe Haddad 已提交
116 117
        ;(window as any).__SSG_MANIFEST_CB = () => {
          resolve((window as any).__SSG_MANIFEST)
J
Joe Haddad 已提交
118 119 120
        }
      }
    })
121 122 123
  }

  // Returns a promise for the dependencies for a particular route
J
Joe Haddad 已提交
124 125
  getDependencies(route: string): Promise<string[]> {
    return this.promisedBuildManifest!.then((m) => {
126 127
      return m[route]
        ? m[route].map((url) => `${this.assetPrefix}/_next/${encodeURI(url)}`)
J
Joe Haddad 已提交
128
        : (this.pageRegisterEvents.emit(route, {
129
            error: pageLoadError(route),
J
Joe Haddad 已提交
130 131
          }),
          [])
132
    })
133 134
  }

J
Joe Haddad 已提交
135 136 137 138
  /**
   * @param {string} href the route href (file-system path)
   * @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes
   */
J
Joe Haddad 已提交
139
  getDataHref(href: string, asPath: string, ssg: boolean) {
140 141 142 143 144
    const { pathname: hrefPathname, searchParams, search } = parseRelativeUrl(
      href
    )
    const query = searchParamsToUrlQuery(searchParams)
    const { pathname: asPathname } = parseRelativeUrl(asPath)
145 146
    const route = normalizeRoute(hrefPathname)

J
Joe Haddad 已提交
147
    const getHrefForSlug = (path: string) => {
148
      const dataRoute = getAssetPathFromRoute(path, '.json')
149 150 151
      return addBasePath(
        `/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}`
      )
152
    }
J
Joe Haddad 已提交
153

J
Joe Haddad 已提交
154 155
    let isDynamic: boolean = isDynamicRoute(route),
      interpolatedRoute: string | undefined
J
Joe Haddad 已提交
156 157 158 159 160 161 162 163 164 165 166 167
    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 (
J
Joe Haddad 已提交
168
        !Object.keys(dynamicGroups).every((param) => {
169
          let value = dynamicMatches[param] || ''
170
          const { repeat, optional } = dynamicGroups[param]
J
Joe Haddad 已提交
171 172 173

          // support single-level catch-all
          // TODO: more robust handling for user-error (passing `/`)
174 175
          let replaced = `[${repeat ? '...' : ''}${param}]`
          if (optional) {
176
            replaced = `${!value ? '/' : ''}[${replaced}]`
177
          }
178
          if (repeat && !Array.isArray(value)) value = [value]
J
Joe Haddad 已提交
179 180

          return (
181
            (optional || param in dynamicMatches) &&
J
Joe Haddad 已提交
182
            // Interpolate group into data URL if present
183
            (interpolatedRoute =
J
Joe Haddad 已提交
184
              interpolatedRoute!.replace(
185 186
                replaced,
                repeat
J
Joe Haddad 已提交
187 188
                  ? (value as string[]).map(escapePathDelimiters).join('/')
                  : escapePathDelimiters(value as string)
189
              ) || '/')
J
Joe Haddad 已提交
190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208
          )
        })
      ) {
        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
   */
J
Joe Haddad 已提交
209
  prefetchData(href: string, asPath: string) {
210
    const { pathname: hrefPathname } = parseRelativeUrl(href)
J
Joe Haddad 已提交
211
    const route = normalizeRoute(hrefPathname)
J
Joe Haddad 已提交
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227
    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 `<link rel=prefetch>` tag for above computed `href`.
            appendLink(_dataHref, relPrefetch, 'fetch')
        })
      }
    )
J
Joe Haddad 已提交
228 229
  }

J
Joe Haddad 已提交
230
  loadPage(route: string): Promise<GoodPageCache> {
231
    route = normalizeRoute(route)
232

J
Joe Haddad 已提交
233
    return new Promise<GoodPageCache>((resolve, reject) => {
234 235 236
      // If there's a cached version of the page, let's use it.
      const cachedPage = this.pageCache[route]
      if (cachedPage) {
J
Joe Haddad 已提交
237 238 239 240 241
        if ('error' in cachedPage) {
          reject(cachedPage.error)
        } else {
          resolve(cachedPage)
        }
242 243 244
        return
      }

J
Joe Haddad 已提交
245
      const fire = (pageToCache: PageCacheEntry) => {
246
        this.pageRegisterEvents.off(route, fire)
247
        delete this.loadingRoutes[route]
248

J
Joe Haddad 已提交
249 250
        if ('error' in pageToCache) {
          reject(pageToCache.error)
251
        } else {
J
Joe Haddad 已提交
252
          resolve(pageToCache)
253 254
        }
      }
255

256
      // Register a listener to get the page
257
      this.pageRegisterEvents.on(route, fire)
258

259
      if (!this.loadingRoutes[route]) {
260
        this.loadingRoutes[route] = true
261
        if (process.env.NODE_ENV === 'production') {
J
Joe Haddad 已提交
262 263
          this.getDependencies(route).then((deps) => {
            deps.forEach((d) => {
264
              if (
265
                d.endsWith('.js') &&
266 267
                !document.querySelector(`script[src^="${d}"]`)
              ) {
268
                this.loadScript(d, route)
269
              }
270
              if (
271
                d.endsWith('.css') &&
272 273
                !document.querySelector(`link[rel=stylesheet][href^="${d}"]`)
              ) {
J
Joe Haddad 已提交
274 275 276 277
                appendLink(d, 'stylesheet').catch(() => {
                  // FIXME: handle failure
                  // Right now, this is needed to prevent an unhandled rejection.
                })
278
              }
279 280 281
            })
          })
        } else {
282 283
          // Development only. In production the page file is part of the build manifest
          route = normalizeRoute(route)
284
          let scriptRoute = getAssetPathFromRoute(route, '.js')
285

286
          const url = `${this.assetPrefix}/_next/static/chunks/pages${encodeURI(
287
            scriptRoute
288
          )}`
289
          this.loadScript(url, route)
290
        }
291
      }
292 293 294
    })
  }

J
Joe Haddad 已提交
295
  loadScript(url: string, route: string) {
296
    const script = document.createElement('script')
J
Joe Haddad 已提交
297
    if (process.env.__NEXT_MODERN_BUILD && hasNoModule) {
298 299
      script.type = 'module'
    }
J
Joe Haddad 已提交
300
    script.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
J
Joe Haddad 已提交
301
    script.src = url
302
    script.onerror = () => {
303
      this.pageRegisterEvents.emit(route, { error: pageLoadError(url) })
304 305 306 307 308
    }
    document.body.appendChild(script)
  }

  // This method if called by the route code.
J
Joe Haddad 已提交
309
  registerPage(route: string, regFn: () => any) {
310
    const register = () => {
311
      try {
312 313 314 315
        const mod = regFn()
        const pageData = { page: mod.default || mod, mod }
        this.pageCache[route] = pageData
        this.pageRegisterEvents.emit(route, pageData)
316 317 318 319
      } catch (error) {
        this.pageCache[route] = { error }
        this.pageRegisterEvents.emit(route, { error })
      }
320 321
    }

322 323
    if (process.env.NODE_ENV !== 'production') {
      // Wait for webpack to become idle if it's not.
324
      // More info: https://github.com/vercel/next.js/pull/1511
J
Joe Haddad 已提交
325
      if ((module as any).hot && (module as any).hot.status() !== 'idle') {
326 327 328
        console.log(
          `Waiting for webpack to become "idle" to initialize the page: "${route}"`
        )
329

J
Joe Haddad 已提交
330
        const check = (status: string) => {
331
          if (status === 'idle') {
J
Joe Haddad 已提交
332
            ;(module as any).hot.removeStatusHandler(check)
333 334
            register()
          }
335
        }
J
Joe Haddad 已提交
336
        ;(module as any).hot.status(check)
337
        return
338 339
      }
    }
340 341

    register()
342
  }
343

J
Joe Haddad 已提交
344 345 346 347
  /**
   * @param {string} route
   * @param {boolean} [isDependency]
   */
J
Joe Haddad 已提交
348
  prefetch(route: string, isDependency?: boolean): Promise<void> {
J
Joe Haddad 已提交
349 350
    // https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118
    // License: Apache 2.0
J
Joe Haddad 已提交
351
    let cn
J
Joe Haddad 已提交
352
    if ((cn = (navigator as any).connection)) {
J
Joe Haddad 已提交
353
      // Don't prefetch if using 2G or if Save-Data is enabled.
354
      if (cn.saveData || /2g/.test(cn.effectiveType)) return Promise.resolve()
355 356
    }

J
Joe Haddad 已提交
357
    /** @type {string} */
358
    let url
J
Joe Haddad 已提交
359
    if (isDependency) {
360
      url = route
J
Joe Haddad 已提交
361
    } else {
362 363
      if (process.env.NODE_ENV !== 'production') {
        route = normalizeRoute(route)
364

365 366
        const ext =
          process.env.__NEXT_MODERN_BUILD && hasNoModule ? '.module.js' : '.js'
367
        const scriptRoute = getAssetPathFromRoute(route, ext)
J
Joe Haddad 已提交
368

369 370
        url = `${this.assetPrefix}/_next/static/${encodeURIComponent(
          this.buildId
371
        )}/pages${encodeURI(scriptRoute)}`
372
      }
373
    }
374

375
    return Promise.all(
376
      document.querySelector(`link[rel="${relPrefetch}"][href^="${url}"]`)
377 378
        ? []
        : [
379 380 381 382 383 384
            url &&
              appendLink(
                url,
                relPrefetch,
                url.endsWith('.css') ? 'style' : 'script'
              ),
385
            process.env.NODE_ENV === 'production' &&
386
              !isDependency &&
J
Joe Haddad 已提交
387
              this.getDependencies(route).then((urls) =>
388 389 390 391 392
                Promise.all(
                  urls.map((dependencyUrl) =>
                    this.prefetch(dependencyUrl, true)
                  )
                )
393 394 395
              ),
          ]
    ).then(
J
Joe Haddad 已提交
396 397 398 399 400
      // do not return any data
      () => {},
      // swallow prefetch errors
      () => {}
    )
401
  }
402
}