page-loader.ts 16.4 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
): [HTMLLinkElement, Promise<any>] {
  link = document.createElement('link')
  return [
    link,
    new Promise((res, rej) => {
64
      // The order of property assignment here is intentional:
65
      if (as) link!.as = as
66 67
      link!.rel = rel
      link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
68 69
      link!.onload = res
      link!.onerror = rej
70 71 72

      // `href` should always be last:
      link!.href = href
73 74 75
    }),
  ]
}
J
Joe Haddad 已提交
76

77 78 79 80
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 已提交
81
}
82

83 84 85 86 87 88 89 90 91 92 93 94 95 96
function loadScript(url: string): Promise<any> {
  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)
  })
}

97 98 99 100 101
export type GoodPageCache = {
  page: ComponentType
  mod: any
  styleSheets: string[]
}
J
Joe Haddad 已提交
102
export type PageCacheEntry = { error: any } | GoodPageCache
J
Joe Haddad 已提交
103

104
export default class PageLoader {
105 106
  private initialPage: string
  private initialStyleSheets: string[]
J
Joe Haddad 已提交
107 108 109 110 111 112 113
  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>
114
  private promisedDevPagesManifest?: Promise<any>
J
Joe Haddad 已提交
115

116 117 118 119 120 121 122 123 124
  constructor(
    buildId: string,
    assetPrefix: string,
    initialPage: string,
    initialStyleSheets: string[]
  ) {
    this.initialPage = initialPage
    this.initialStyleSheets = initialStyleSheets

125
    this.buildId = buildId
126 127
    this.assetPrefix = assetPrefix

128
    this.pageCache = {}
L
Luc 已提交
129
    this.pageRegisterEvents = mitt()
130 131 132 133 134 135 136 137 138 139
    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
    }

140 141 142 143 144
    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 已提交
145
          resolve((window as any).__BUILD_MANIFEST)
146
        }
147 148 149
      }
    })

J
Joe Haddad 已提交
150
    /** @type {Promise<Set<string>>} */
J
Joe Haddad 已提交
151
    this.promisedSsgManifest = new Promise((resolve) => {
J
Joe Haddad 已提交
152 153
      if ((window as any).__SSG_MANIFEST) {
        resolve((window as any).__SSG_MANIFEST)
J
Joe Haddad 已提交
154
      } else {
J
Joe Haddad 已提交
155 156
        ;(window as any).__SSG_MANIFEST_CB = () => {
          resolve((window as any).__SSG_MANIFEST)
J
Joe Haddad 已提交
157 158 159
        }
      }
    })
160 161
  }

162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188
  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
      }
    }
  }

189
  // Returns a promise for the dependencies for a particular route
190
  private getDependencies(route: string): Promise<string[]> {
J
Joe Haddad 已提交
191
    return this.promisedBuildManifest!.then((m) => {
192 193
      return m[route]
        ? m[route].map((url) => `${this.assetPrefix}/_next/${encodeURI(url)}`)
194
        : Promise.reject(pageLoadError(route))
195
    })
196 197
  }

J
Joe Haddad 已提交
198 199 200 201
  /**
   * @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 已提交
202
  getDataHref(href: string, asPath: string, ssg: boolean) {
203 204 205 206 207
    const { pathname: hrefPathname, searchParams, search } = parseRelativeUrl(
      href
    )
    const query = searchParamsToUrlQuery(searchParams)
    const { pathname: asPathname } = parseRelativeUrl(asPath)
208 209
    const route = normalizeRoute(hrefPathname)

J
Joe Haddad 已提交
210
    const getHrefForSlug = (path: string) => {
211
      const dataRoute = getAssetPathFromRoute(path, '.json')
212 213 214
      return addBasePath(
        `/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}`
      )
215
    }
J
Joe Haddad 已提交
216

J
Joe Haddad 已提交
217 218
    let isDynamic: boolean = isDynamicRoute(route),
      interpolatedRoute: string | undefined
J
Joe Haddad 已提交
219 220 221 222 223 224 225 226 227 228 229 230
    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 已提交
231
        !Object.keys(dynamicGroups).every((param) => {
232
          let value = dynamicMatches[param] || ''
233
          const { repeat, optional } = dynamicGroups[param]
J
Joe Haddad 已提交
234 235 236

          // support single-level catch-all
          // TODO: more robust handling for user-error (passing `/`)
237 238
          let replaced = `[${repeat ? '...' : ''}${param}]`
          if (optional) {
239
            replaced = `${!value ? '/' : ''}[${replaced}]`
240
          }
241
          if (repeat && !Array.isArray(value)) value = [value]
J
Joe Haddad 已提交
242 243

          return (
244
            (optional || param in dynamicMatches) &&
J
Joe Haddad 已提交
245
            // Interpolate group into data URL if present
246
            (interpolatedRoute =
J
Joe Haddad 已提交
247
              interpolatedRoute!.replace(
248 249
                replaced,
                repeat
J
Joe Haddad 已提交
250 251
                  ? (value as string[]).map(escapePathDelimiters).join('/')
                  : escapePathDelimiters(value as string)
252
              ) || '/')
J
Joe Haddad 已提交
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271
          )
        })
      ) {
        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 已提交
272
  prefetchData(href: string, asPath: string) {
273
    const { pathname: hrefPathname } = parseRelativeUrl(href)
J
Joe Haddad 已提交
274
    const route = normalizeRoute(hrefPathname)
J
Joe Haddad 已提交
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290
    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 已提交
291 292
  }

J
Joe Haddad 已提交
293
  loadPage(route: string): Promise<GoodPageCache> {
294
    route = normalizeRoute(route)
295

J
Joe Haddad 已提交
296
    return new Promise<GoodPageCache>((resolve, reject) => {
297 298 299
      // If there's a cached version of the page, let's use it.
      const cachedPage = this.pageCache[route]
      if (cachedPage) {
J
Joe Haddad 已提交
300 301 302 303 304
        if ('error' in cachedPage) {
          reject(cachedPage.error)
        } else {
          resolve(cachedPage)
        }
305 306 307
        return
      }

J
Joe Haddad 已提交
308
      const fire = (pageToCache: PageCacheEntry) => {
309
        this.pageRegisterEvents.off(route, fire)
310
        delete this.loadingRoutes[route]
311

J
Joe Haddad 已提交
312 313
        if ('error' in pageToCache) {
          reject(pageToCache.error)
314
        } else {
J
Joe Haddad 已提交
315
          resolve(pageToCache)
316 317
        }
      }
318

319
      // Register a listener to get the page
320
      this.pageRegisterEvents.on(route, fire)
321

322
      if (!this.loadingRoutes[route]) {
323
        this.loadingRoutes[route] = true
324
        if (process.env.NODE_ENV === 'production') {
325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362
          this.getDependencies(route)
            .then((deps) => {
              const pending: Promise<any>[] = []
              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 <Link>... 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 })
363 364
            })
        } else {
365 366
          // Development only. In production the page file is part of the build manifest
          route = normalizeRoute(route)
367
          let scriptRoute = getAssetPathFromRoute(route, '.js')
368

369
          const url = `${this.assetPrefix}/_next/static/chunks/pages${encodeURI(
370
            scriptRoute
371
          )}`
372 373 374 375 376
          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 })
          })
377
        }
378
      }
379 380 381 382
    })
  }

  // This method if called by the route code.
J
Joe Haddad 已提交
383
  registerPage(route: string, regFn: () => any) {
384
    const register = (styleSheets: string[]) => {
385
      try {
386
        const mod = regFn()
387 388 389 390 391
        const pageData: PageCacheEntry = {
          page: mod.default || mod,
          mod,
          styleSheets,
        }
392 393
        this.pageCache[route] = pageData
        this.pageRegisterEvents.emit(route, pageData)
394 395 396 397
      } catch (error) {
        this.pageCache[route] = { error }
        this.pageRegisterEvents.emit(route, { error })
      }
398 399
    }

400 401
    if (process.env.NODE_ENV !== 'production') {
      // Wait for webpack to become idle if it's not.
402
      // More info: https://github.com/vercel/next.js/pull/1511
J
Joe Haddad 已提交
403
      if ((module as any).hot && (module as any).hot.status() !== 'idle') {
404 405 406
        console.log(
          `Waiting for webpack to become "idle" to initialize the page: "${route}"`
        )
407

J
Joe Haddad 已提交
408
        const check = (status: string) => {
409
          if (status === 'idle') {
J
Joe Haddad 已提交
410
            ;(module as any).hot.removeStatusHandler(check)
411 412 413 414
            register(
              /* css is handled via style-loader in development */
              []
            )
415
          }
416
        }
J
Joe Haddad 已提交
417
        ;(module as any).hot.status(check)
418
        return
419 420
      }
    }
421

422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439
    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 })
      }
    )
440
  }
441

J
Joe Haddad 已提交
442 443 444 445
  /**
   * @param {string} route
   * @param {boolean} [isDependency]
   */
J
Joe Haddad 已提交
446
  prefetch(route: string, isDependency?: boolean): Promise<void> {
J
Joe Haddad 已提交
447 448
    // https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118
    // License: Apache 2.0
J
Joe Haddad 已提交
449
    let cn
J
Joe Haddad 已提交
450
    if ((cn = (navigator as any).connection)) {
J
Joe Haddad 已提交
451
      // Don't prefetch if using 2G or if Save-Data is enabled.
452
      if (cn.saveData || /2g/.test(cn.effectiveType)) return Promise.resolve()
453 454
    }

J
Joe Haddad 已提交
455
    /** @type {string} */
456
    let url
J
Joe Haddad 已提交
457
    if (isDependency) {
458
      url = route
J
Joe Haddad 已提交
459
    } else {
460 461
      if (process.env.NODE_ENV !== 'production') {
        route = normalizeRoute(route)
462

463 464
        const ext =
          process.env.__NEXT_MODERN_BUILD && hasNoModule ? '.module.js' : '.js'
465
        const scriptRoute = getAssetPathFromRoute(route, ext)
J
Joe Haddad 已提交
466

467 468
        url = `${this.assetPrefix}/_next/static/${encodeURIComponent(
          this.buildId
469
        )}/pages${encodeURI(scriptRoute)}`
470
      }
471
    }
472

473
    return Promise.all(
474
      document.querySelector(`link[rel="${relPrefetch}"][href^="${url}"]`)
475 476
        ? []
        : [
477 478 479 480 481 482
            url &&
              appendLink(
                url,
                relPrefetch,
                url.endsWith('.css') ? 'style' : 'script'
              ),
483
            process.env.NODE_ENV === 'production' &&
484
              !isDependency &&
J
Joe Haddad 已提交
485
              this.getDependencies(route).then((urls) =>
486 487 488 489 490
                Promise.all(
                  urls.map((dependencyUrl) =>
                    this.prefetch(dependencyUrl, true)
                  )
                )
491 492 493
              ),
          ]
    ).then(
J
Joe Haddad 已提交
494 495 496 497 498
      // do not return any data
      () => {},
      // swallow prefetch errors
      () => {}
    )
499
  }
500
}