mirror of
https://github.com/terribleplan/next.js.git
synced 2024-01-19 02:48:18 +00:00
818cd63ee9
Earlier it wait until the JSON page is fetched. But that's wrong and it affects when using libs like NProgress.
365 lines
9.3 KiB
JavaScript
365 lines
9.3 KiB
JavaScript
import { parse, format } from 'url'
|
|
import { EventEmitter } from 'events'
|
|
import evalScript from '../eval-script'
|
|
import shallowEquals from '../shallow-equals'
|
|
import PQueue from '../p-queue'
|
|
import { loadGetInitialProps, getLocationOrigin } from '../utils'
|
|
import { _notifyBuildIdMismatch } from './'
|
|
import fetch from 'unfetch'
|
|
|
|
if (typeof window !== 'undefined' && typeof navigator.serviceWorker !== 'undefined') {
|
|
navigator.serviceWorker.getRegistrations()
|
|
.then(registrations => {
|
|
registrations.forEach(registration => {
|
|
if (!registration.active) {
|
|
return
|
|
}
|
|
|
|
if (registration.active.scriptURL.indexOf('_next-prefetcher.js') === -1) {
|
|
return
|
|
}
|
|
|
|
console.warn(`Unregistered deprecated 'next/prefetch' ServiceWorker. See https://github.com/zeit/next.js#prefetching-pages`)
|
|
registration.unregister()
|
|
})
|
|
})
|
|
}
|
|
|
|
export default class Router extends EventEmitter {
|
|
constructor (pathname, query, { Component, ErrorComponent, err } = {}) {
|
|
super()
|
|
// represents the current component key
|
|
this.route = toRoute(pathname)
|
|
|
|
// set up the component cache (by route keys)
|
|
this.components = { [this.route]: { Component, err } }
|
|
|
|
// contain a map of promise of fetch routes
|
|
this.fetchingRoutes = {}
|
|
|
|
this.prefetchQueue = new PQueue({ concurrency: 2 })
|
|
this.ErrorComponent = ErrorComponent
|
|
this.pathname = pathname
|
|
this.query = query
|
|
this.subscriptions = new Set()
|
|
this.componentLoadCancel = null
|
|
this.onPopState = this.onPopState.bind(this)
|
|
|
|
if (typeof window !== 'undefined') {
|
|
// in order for `e.state` to work on the `onpopstate` event
|
|
// we have to register the initial route upon initialization
|
|
this.changeState('replaceState', format({ pathname, query }), getURL())
|
|
|
|
window.addEventListener('popstate', this.onPopState)
|
|
}
|
|
}
|
|
|
|
async onPopState (e) {
|
|
if (!e.state) {
|
|
// We get state as undefined for two reasons.
|
|
// 1. With older safari (< 8) and older chrome (< 34)
|
|
// 2. When the URL changed with #
|
|
//
|
|
// In the both cases, we don't need to proceed and change the route.
|
|
// (as it's already changed)
|
|
// But we can simply replace the state with the new changes.
|
|
// Actually, for (1) we don't need to nothing. But it's hard to detect that event.
|
|
// So, doing the following for (1) does no harm.
|
|
const { pathname, query } = this
|
|
this.replace(format({ pathname, query }), getURL())
|
|
return
|
|
}
|
|
|
|
const { url, as } = e.state
|
|
const { pathname, query } = parse(url, true)
|
|
|
|
this.abortComponentLoad(as)
|
|
|
|
if (!this.urlIsNew(pathname, query)) {
|
|
this.emit('routeChangeStart', as)
|
|
this.emit('routeChangeComplete', as)
|
|
return
|
|
}
|
|
|
|
const route = toRoute(pathname)
|
|
|
|
this.emit('routeChangeStart', as)
|
|
const {
|
|
data,
|
|
props,
|
|
error
|
|
} = await this.getRouteInfo(route, pathname, query, as)
|
|
|
|
if (error && error.cancelled) {
|
|
return
|
|
}
|
|
|
|
this.route = route
|
|
this.set(pathname, query, { ...data, props })
|
|
|
|
if (error) {
|
|
this.emit('routeChangeError', error, as)
|
|
} else {
|
|
this.emit('routeChangeComplete', as)
|
|
}
|
|
}
|
|
|
|
update (route, Component) {
|
|
const data = this.components[route] || {}
|
|
const newData = { ...data, Component }
|
|
this.components[route] = newData
|
|
|
|
if (route === this.route) {
|
|
this.notify(newData)
|
|
}
|
|
}
|
|
|
|
async reload (route) {
|
|
delete this.components[route]
|
|
delete this.fetchingRoutes[route]
|
|
|
|
if (route !== this.route) return
|
|
|
|
const url = window.location.href
|
|
const { pathname, query } = parse(url, true)
|
|
|
|
this.emit('routeChangeStart', url)
|
|
const {
|
|
data,
|
|
props,
|
|
error
|
|
} = await this.getRouteInfo(route, pathname, query, url)
|
|
|
|
if (error && error.cancelled) {
|
|
return
|
|
}
|
|
|
|
this.notify({ ...data, props })
|
|
|
|
if (error) {
|
|
this.emit('routeChangeError', error, url)
|
|
throw error
|
|
}
|
|
|
|
this.emit('routeChangeComplete', url)
|
|
}
|
|
|
|
back () {
|
|
window.history.back()
|
|
}
|
|
|
|
push (url, as = url) {
|
|
return this.change('pushState', url, as)
|
|
}
|
|
|
|
replace (url, as = url) {
|
|
return this.change('replaceState', url, as)
|
|
}
|
|
|
|
async change (method, url, as) {
|
|
this.abortComponentLoad(as)
|
|
const { pathname, query } = parse(url, true)
|
|
|
|
// If asked to change the current URL we should reload the current page
|
|
// (not location.reload() but reload getInitalProps and other Next.js stuffs)
|
|
// We also need to set the method = replaceState always
|
|
// as this should not go into the history (That's how browsers work)
|
|
if (!this.urlIsNew(pathname, query)) {
|
|
method = 'replaceState'
|
|
}
|
|
|
|
const route = toRoute(pathname)
|
|
|
|
this.emit('routeChangeStart', as)
|
|
const {
|
|
data, props, error
|
|
} = await this.getRouteInfo(route, pathname, query, as)
|
|
|
|
if (error && error.cancelled) {
|
|
return false
|
|
}
|
|
|
|
this.changeState(method, url, as)
|
|
|
|
this.route = route
|
|
this.set(pathname, query, { ...data, props })
|
|
|
|
if (error) {
|
|
this.emit('routeChangeError', error, as)
|
|
throw error
|
|
}
|
|
|
|
this.emit('routeChangeComplete', as)
|
|
return true
|
|
}
|
|
|
|
changeState (method, url, as) {
|
|
if (method !== 'pushState' || getURL() !== as) {
|
|
window.history[method]({ url, as }, null, as)
|
|
}
|
|
}
|
|
|
|
async getRouteInfo (route, pathname, query, as) {
|
|
const routeInfo = {}
|
|
|
|
try {
|
|
routeInfo.data = await this.fetchComponent(route, as)
|
|
if (!routeInfo.data) {
|
|
return null
|
|
}
|
|
|
|
const { Component, err, jsonPageRes } = routeInfo.data
|
|
const ctx = { err, pathname, query, jsonPageRes }
|
|
routeInfo.props = await this.getInitialProps(Component, ctx)
|
|
} catch (err) {
|
|
if (err.cancelled) {
|
|
return { error: err }
|
|
}
|
|
|
|
const Component = this.ErrorComponent
|
|
routeInfo.data = { Component, err }
|
|
const ctx = { err, pathname, query }
|
|
routeInfo.props = await this.getInitialProps(Component, ctx)
|
|
|
|
routeInfo.error = err
|
|
console.error(err)
|
|
}
|
|
|
|
return routeInfo
|
|
}
|
|
|
|
set (pathname, query, data) {
|
|
this.pathname = pathname
|
|
this.query = query
|
|
this.notify(data)
|
|
}
|
|
|
|
urlIsNew (pathname, query) {
|
|
return this.pathname !== pathname || !shallowEquals(query, this.query)
|
|
}
|
|
|
|
async prefetch (url) {
|
|
// We don't add support for prefetch in the development mode.
|
|
// If we do that, our on-demand-entries optimization won't performs better
|
|
if (process.env.NODE_ENV === 'development') return
|
|
|
|
const { pathname } = parse(url)
|
|
const route = toRoute(pathname)
|
|
return this.prefetchQueue.add(() => this.fetchRoute(route))
|
|
}
|
|
|
|
async fetchComponent (route, as) {
|
|
let data = this.components[route]
|
|
if (data) return data
|
|
|
|
let cancelled = false
|
|
const cancel = this.componentLoadCancel = function () {
|
|
cancelled = true
|
|
}
|
|
|
|
const jsonPageRes = await this.fetchRoute(route)
|
|
const jsonData = await jsonPageRes.json()
|
|
|
|
if (jsonData.buildIdMismatch) {
|
|
_notifyBuildIdMismatch(as)
|
|
|
|
const error = Error('Abort due to BUILD_ID mismatch')
|
|
error.cancelled = true
|
|
throw error
|
|
}
|
|
|
|
const newData = {
|
|
...loadComponent(jsonData),
|
|
jsonPageRes
|
|
}
|
|
|
|
if (cancelled) {
|
|
const error = new Error(`Abort fetching component for route: "${route}"`)
|
|
error.cancelled = true
|
|
throw error
|
|
}
|
|
|
|
if (cancel === this.componentLoadCancel) {
|
|
this.componentLoadCancel = null
|
|
}
|
|
|
|
this.components[route] = newData
|
|
return newData
|
|
}
|
|
|
|
async getInitialProps (Component, ctx) {
|
|
let cancelled = false
|
|
const cancel = () => { cancelled = true }
|
|
this.componentLoadCancel = cancel
|
|
|
|
const props = await loadGetInitialProps(Component, ctx)
|
|
|
|
if (cancel === this.componentLoadCancel) {
|
|
this.componentLoadCancel = null
|
|
}
|
|
|
|
if (cancelled) {
|
|
const err = new Error('Loading initial props cancelled')
|
|
err.cancelled = true
|
|
throw err
|
|
}
|
|
|
|
return props
|
|
}
|
|
|
|
async fetchRoute (route) {
|
|
let promise = this.fetchingRoutes[route]
|
|
if (!promise) {
|
|
promise = this.fetchingRoutes[route] = this.doFetchRoute(route)
|
|
}
|
|
|
|
const res = await promise
|
|
// We need to clone this to prevent reading the body twice
|
|
// Becuase it's possible only once to read res.json() or a similar method.
|
|
return res.clone()
|
|
}
|
|
|
|
doFetchRoute (route) {
|
|
const { buildId } = window.__NEXT_DATA__
|
|
const url = `/_next/${encodeURIComponent(buildId)}/pages${route}`
|
|
return fetch(url, {
|
|
method: 'GET',
|
|
headers: { 'Accept': 'application/json' }
|
|
})
|
|
}
|
|
|
|
abortComponentLoad (as) {
|
|
this.emit('routeChangeError', new Error('Route Cancelled'), as)
|
|
if (this.componentLoadCancel) {
|
|
this.componentLoadCancel()
|
|
this.componentLoadCancel = null
|
|
}
|
|
}
|
|
|
|
notify (data) {
|
|
this.subscriptions.forEach((fn) => fn(data))
|
|
}
|
|
|
|
subscribe (fn) {
|
|
this.subscriptions.add(fn)
|
|
return () => this.subscriptions.delete(fn)
|
|
}
|
|
}
|
|
|
|
function getURL () {
|
|
const { href } = window.location
|
|
const origin = getLocationOrigin()
|
|
return href.substring(origin.length)
|
|
}
|
|
|
|
function toRoute (path) {
|
|
return path.replace(/\/$/, '') || '/'
|
|
}
|
|
|
|
function loadComponent (jsonData) {
|
|
const module = evalScript(jsonData.component)
|
|
const Component = module.default || module
|
|
|
|
return { Component, err: jsonData.err }
|
|
}
|