page-loader.ts 15.5 KB
Newer Older
1
import { 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 relPreload = hasRel('preload') ? 'preload' : relPrefetch

J
Joe Haddad 已提交
37 38
const hasNoModule = 'noModule' in document.createElement('script')

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

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

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

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

      link!.onload = res
      link!.onerror = rej
    }),
  ]
}
J
Joe Haddad 已提交
74

75 76 77 78
function appendLink(href: string, rel: string, as?: string): Promise<any> {
  const [link, res] = createLink(href, rel, as)
  document.head.appendChild(link)
  return res
J
Joe Haddad 已提交
79
}
80

81 82 83 84 85
export type GoodPageCache = {
  page: ComponentType
  mod: any
  styleSheets: string[]
}
J
Joe Haddad 已提交
86
export type PageCacheEntry = { error: any } | GoodPageCache
J
Joe Haddad 已提交
87

88
export default class PageLoader {
89 90
  private initialPage: string
  private initialStyleSheets: string[]
J
Joe Haddad 已提交
91 92 93 94 95 96 97
  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>
98
  private promisedDevPagesManifest?: Promise<any>
J
Joe Haddad 已提交
99

100 101 102 103 104 105 106 107 108
  constructor(
    buildId: string,
    assetPrefix: string,
    initialPage: string,
    initialStyleSheets: string[]
  ) {
    this.initialPage = initialPage
    this.initialStyleSheets = initialStyleSheets

109
    this.buildId = buildId
110 111
    this.assetPrefix = assetPrefix

112
    this.pageCache = {}
L
Luc 已提交
113
    this.pageRegisterEvents = mitt()
114 115 116 117 118 119 120 121 122 123
    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
    }

124 125 126 127 128
    this.promisedBuildManifest = new Promise((resolve) => {
      if ((window as any).__BUILD_MANIFEST) {
        resolve((window as any).__BUILD_MANIFEST)
      } else {
        ;(window as any).__BUILD_MANIFEST_CB = () => {
J
Joe Haddad 已提交
129
          resolve((window as any).__BUILD_MANIFEST)
130
        }
131 132 133
      }
    })

J
Joe Haddad 已提交
134
    /** @type {Promise<Set<string>>} */
J
Joe Haddad 已提交
135
    this.promisedSsgManifest = new Promise((resolve) => {
J
Joe Haddad 已提交
136 137
      if ((window as any).__SSG_MANIFEST) {
        resolve((window as any).__SSG_MANIFEST)
J
Joe Haddad 已提交
138
      } else {
J
Joe Haddad 已提交
139 140
        ;(window as any).__SSG_MANIFEST_CB = () => {
          resolve((window as any).__SSG_MANIFEST)
J
Joe Haddad 已提交
141 142 143
        }
      }
    })
144 145
  }

146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172
  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
      }
    }
  }

173
  // Returns a promise for the dependencies for a particular route
J
Joe Haddad 已提交
174 175
  getDependencies(route: string): Promise<string[]> {
    return this.promisedBuildManifest!.then((m) => {
176 177
      return m[route]
        ? m[route].map((url) => `${this.assetPrefix}/_next/${encodeURI(url)}`)
J
Joe Haddad 已提交
178
        : (this.pageRegisterEvents.emit(route, {
179
            error: pageLoadError(route),
J
Joe Haddad 已提交
180 181
          }),
          [])
182
    })
183 184
  }

J
Joe Haddad 已提交
185 186 187 188
  /**
   * @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 已提交
189
  getDataHref(href: string, asPath: string, ssg: boolean) {
190 191 192 193 194
    const { pathname: hrefPathname, searchParams, search } = parseRelativeUrl(
      href
    )
    const query = searchParamsToUrlQuery(searchParams)
    const { pathname: asPathname } = parseRelativeUrl(asPath)
195 196
    const route = normalizeRoute(hrefPathname)

J
Joe Haddad 已提交
197
    const getHrefForSlug = (path: string) => {
198
      const dataRoute = getAssetPathFromRoute(path, '.json')
199 200 201
      return addBasePath(
        `/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}`
      )
202
    }
J
Joe Haddad 已提交
203

J
Joe Haddad 已提交
204 205
    let isDynamic: boolean = isDynamicRoute(route),
      interpolatedRoute: string | undefined
J
Joe Haddad 已提交
206 207 208 209 210 211 212 213 214 215 216 217
    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 已提交
218
        !Object.keys(dynamicGroups).every((param) => {
219
          let value = dynamicMatches[param] || ''
220
          const { repeat, optional } = dynamicGroups[param]
J
Joe Haddad 已提交
221 222 223

          // support single-level catch-all
          // TODO: more robust handling for user-error (passing `/`)
224 225
          let replaced = `[${repeat ? '...' : ''}${param}]`
          if (optional) {
226
            replaced = `${!value ? '/' : ''}[${replaced}]`
227
          }
228
          if (repeat && !Array.isArray(value)) value = [value]
J
Joe Haddad 已提交
229 230

          return (
231
            (optional || param in dynamicMatches) &&
J
Joe Haddad 已提交
232
            // Interpolate group into data URL if present
233
            (interpolatedRoute =
J
Joe Haddad 已提交
234
              interpolatedRoute!.replace(
235 236
                replaced,
                repeat
J
Joe Haddad 已提交
237 238
                  ? (value as string[]).map(escapePathDelimiters).join('/')
                  : escapePathDelimiters(value as string)
239
              ) || '/')
J
Joe Haddad 已提交
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258
          )
        })
      ) {
        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 已提交
259
  prefetchData(href: string, asPath: string) {
260
    const { pathname: hrefPathname } = parseRelativeUrl(href)
J
Joe Haddad 已提交
261
    const route = normalizeRoute(hrefPathname)
J
Joe Haddad 已提交
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277
    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 已提交
278 279
  }

J
Joe Haddad 已提交
280
  loadPage(route: string): Promise<GoodPageCache> {
281
    route = normalizeRoute(route)
282

J
Joe Haddad 已提交
283
    return new Promise<GoodPageCache>((resolve, reject) => {
284 285 286
      // If there's a cached version of the page, let's use it.
      const cachedPage = this.pageCache[route]
      if (cachedPage) {
J
Joe Haddad 已提交
287 288 289 290 291
        if ('error' in cachedPage) {
          reject(cachedPage.error)
        } else {
          resolve(cachedPage)
        }
292 293 294
        return
      }

J
Joe Haddad 已提交
295
      const fire = (pageToCache: PageCacheEntry) => {
296
        this.pageRegisterEvents.off(route, fire)
297
        delete this.loadingRoutes[route]
298

J
Joe Haddad 已提交
299 300
        if ('error' in pageToCache) {
          reject(pageToCache.error)
301
        } else {
J
Joe Haddad 已提交
302
          resolve(pageToCache)
303 304
        }
      }
305

306
      // Register a listener to get the page
307
      this.pageRegisterEvents.on(route, fire)
308

309
      if (!this.loadingRoutes[route]) {
310
        this.loadingRoutes[route] = true
311
        if (process.env.NODE_ENV === 'production') {
J
Joe Haddad 已提交
312 313
          this.getDependencies(route).then((deps) => {
            deps.forEach((d) => {
314
              if (
315
                d.endsWith('.js') &&
316 317
                !document.querySelector(`script[src^="${d}"]`)
              ) {
318
                this.loadScript(d, route)
319
              }
320 321 322 323 324 325

              // Prefetch CSS as it'll be needed when the page JavaScript
              // evaluates. This will only trigger if explicit prefetching is
              // disabled for a <Link>... prefetching in this case is desirable
              // because we *know* it's going to be used very soon (page was
              // loaded).
326
              if (
327
                d.endsWith('.css') &&
328 329 330
                !document.querySelector(
                  `link[rel="${relPreload}"][href^="${d}"]`
                )
331
              ) {
332 333
                appendLink(d, relPreload, 'style').catch(() => {
                  /* ignore preload error */
J
Joe Haddad 已提交
334
                })
335
              }
336 337 338
            })
          })
        } else {
339 340
          // Development only. In production the page file is part of the build manifest
          route = normalizeRoute(route)
341
          let scriptRoute = getAssetPathFromRoute(route, '.js')
342

343
          const url = `${this.assetPrefix}/_next/static/chunks/pages${encodeURI(
344
            scriptRoute
345
          )}`
346
          this.loadScript(url, route)
347
        }
348
      }
349 350 351
    })
  }

J
Joe Haddad 已提交
352
  loadScript(url: string, route: string) {
353
    const script = document.createElement('script')
J
Joe Haddad 已提交
354
    if (process.env.__NEXT_MODERN_BUILD && hasNoModule) {
355 356
      script.type = 'module'
    }
J
Joe Haddad 已提交
357
    script.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
J
Joe Haddad 已提交
358
    script.src = url
359
    script.onerror = () => {
360
      this.pageRegisterEvents.emit(route, { error: pageLoadError(url) })
361 362 363 364 365
    }
    document.body.appendChild(script)
  }

  // This method if called by the route code.
J
Joe Haddad 已提交
366
  registerPage(route: string, regFn: () => any) {
367
    const register = (styleSheets: string[]) => {
368
      try {
369
        const mod = regFn()
370 371 372 373 374
        const pageData: PageCacheEntry = {
          page: mod.default || mod,
          mod,
          styleSheets,
        }
375 376
        this.pageCache[route] = pageData
        this.pageRegisterEvents.emit(route, pageData)
377 378 379 380
      } catch (error) {
        this.pageCache[route] = { error }
        this.pageRegisterEvents.emit(route, { error })
      }
381 382
    }

383 384
    if (process.env.NODE_ENV !== 'production') {
      // Wait for webpack to become idle if it's not.
385
      // More info: https://github.com/vercel/next.js/pull/1511
J
Joe Haddad 已提交
386
      if ((module as any).hot && (module as any).hot.status() !== 'idle') {
387 388 389
        console.log(
          `Waiting for webpack to become "idle" to initialize the page: "${route}"`
        )
390

J
Joe Haddad 已提交
391
        const check = (status: string) => {
392
          if (status === 'idle') {
J
Joe Haddad 已提交
393
            ;(module as any).hot.removeStatusHandler(check)
394 395 396 397
            register(
              /* css is handled via style-loader in development */
              []
            )
398
          }
399
        }
J
Joe Haddad 已提交
400
        ;(module as any).hot.status(check)
401
        return
402 403
      }
    }
404

405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422
    const promisedDeps: Promise<string[]> =
      // 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 })
      }
    )
423
  }
424

J
Joe Haddad 已提交
425 426 427 428
  /**
   * @param {string} route
   * @param {boolean} [isDependency]
   */
J
Joe Haddad 已提交
429
  prefetch(route: string, isDependency?: boolean): Promise<void> {
J
Joe Haddad 已提交
430 431
    // https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118
    // License: Apache 2.0
J
Joe Haddad 已提交
432
    let cn
J
Joe Haddad 已提交
433
    if ((cn = (navigator as any).connection)) {
J
Joe Haddad 已提交
434
      // Don't prefetch if using 2G or if Save-Data is enabled.
435
      if (cn.saveData || /2g/.test(cn.effectiveType)) return Promise.resolve()
436 437
    }

J
Joe Haddad 已提交
438
    /** @type {string} */
439
    let url
J
Joe Haddad 已提交
440
    if (isDependency) {
441
      url = route
J
Joe Haddad 已提交
442
    } else {
443 444
      if (process.env.NODE_ENV !== 'production') {
        route = normalizeRoute(route)
445

446 447
        const ext =
          process.env.__NEXT_MODERN_BUILD && hasNoModule ? '.module.js' : '.js'
448
        const scriptRoute = getAssetPathFromRoute(route, ext)
J
Joe Haddad 已提交
449

450 451
        url = `${this.assetPrefix}/_next/static/${encodeURIComponent(
          this.buildId
452
        )}/pages${encodeURI(scriptRoute)}`
453
      }
454
    }
455

456
    return Promise.all(
457
      document.querySelector(`link[rel="${relPrefetch}"][href^="${url}"]`)
458 459
        ? []
        : [
460 461 462 463 464 465
            url &&
              appendLink(
                url,
                relPrefetch,
                url.endsWith('.css') ? 'style' : 'script'
              ),
466
            process.env.NODE_ENV === 'production' &&
467
              !isDependency &&
J
Joe Haddad 已提交
468
              this.getDependencies(route).then((urls) =>
469 470 471 472 473
                Promise.all(
                  urls.map((dependencyUrl) =>
                    this.prefetch(dependencyUrl, true)
                  )
                )
474 475 476
              ),
          ]
    ).then(
J
Joe Haddad 已提交
477 478 479 480 481
      // do not return any data
      () => {},
      // swallow prefetch errors
      () => {}
    )
482
  }
483
}