Skip to content

Commit

Permalink
feat(router): support multiple apps at the same time
Browse files Browse the repository at this point in the history
  • Loading branch information
posva committed Jun 23, 2020
1 parent edd9c06 commit 565ec9d
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 78 deletions.
57 changes: 57 additions & 0 deletions __tests__/multipleApps.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createRouter, createMemoryHistory } from '../src'
import { h } from 'vue'
import { createDom } from './utils'
// import { mockWarn } from 'jest-mock-warn'

declare var __DEV__: boolean

const delay = (t: number) => new Promise(resolve => setTimeout(resolve, t))

function newRouter(options: Partial<Parameters<typeof createRouter>[0]> = {}) {
const history = options.history || createMemoryHistory()
const router = createRouter({
history,
routes: [
{
path: '/:pathMatch(.*)',
component: {
render: () => h('div', 'any route'),
},
},
],
...options,
})

return { history, router }
}

describe('Multiple apps', () => {
beforeAll(() => {
createDom()
const rootEl = document.createElement('div')
rootEl.id = 'app'
document.body.appendChild(rootEl)
})

it('does not listen to url changes before being ready', async () => {
const { router, history } = newRouter()

const spy = jest.fn((to, from, next) => {
next()
})
router.beforeEach(spy)

history.push('/foo')
history.push('/bar')
history.go(-1, true)

await delay(5)
expect(spy).not.toHaveBeenCalled()

await router.push('/baz')

history.go(-1, true)
await delay(5)
expect(spy).toHaveBeenCalledTimes(2)
})
})
33 changes: 21 additions & 12 deletions e2e/specs/multi-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,28 @@ module.exports = {
.back()
.assert.containsText('#guardcount', '4')

/**
* TODO:
* - add in-component guards and check each one of them is called
* - check `this` is the actual instance by injecting a global property
* per app equal to their id and using it somewhere in the template
*/
// unmounting apps should pause guards
// start by navigating 3 times
.click('#app-1 li:nth-child(1) a')
.click('#app-1 li:nth-child(2) a')
.click('#app-1 li:nth-child(1) a')
.assert.containsText('#guardcount', '7')
.click('#unmount1')
.click('#unmount2')
.assert.containsText('#guardcount', '7')
.back()
// one app is still mounted
.assert.containsText('#guardcount', '8')
.click('#unmount3')
.back()
.assert.containsText('#guardcount', '8')

// unmounting apps should end up removing the popstate listener
// .click('#unmount1')
// .click('#unmount2')
// .click('#unmount3')
// TODO: we need a way to hook into unmount
// .assert.containsText('#popcount', '0')
// mounting again should add the listeners again
.click('#mount1')
// the initial navigation
.assert.containsText('#guardcount', '9')
.click('#app-1 li:nth-child(2) a')
.assert.containsText('#guardcount', '10')

.end()
},
Expand Down
154 changes: 88 additions & 66 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
ComputedRef,
reactive,
unref,
computed,
} from 'vue'
import { RouteRecord, RouteRecordNormalized } from './matcher/types'
import { parseURL, stringifyURL, isSameRouteLocation } from './location'
Expand Down Expand Up @@ -138,6 +139,9 @@ export interface RouterOptions extends PathParserOptions {
}

export interface Router {
/**
* @internal
*/
readonly history: RouterHistory
readonly currentRoute: Ref<RouteLocationNormalizedLoaded>
readonly options: RouterOptions
Expand Down Expand Up @@ -665,79 +669,82 @@ export function createRouter(options: RouterOptions): Router {
markAsReady()
}

let removeHistoryListener: () => void
// attach listener to history to trigger navigations
routerHistory.listen((to, _from, info) => {
// TODO: in dev try catch to correctly log the matcher error
// cannot be a redirect route because it was in history
const toLocation = resolve(to.fullPath) as RouteLocationNormalized
function setupListeners() {
removeHistoryListener = routerHistory.listen((to, _from, info) => {
// TODO: in dev try catch to correctly log the matcher error
// cannot be a redirect route because it was in history
const toLocation = resolve(to.fullPath) as RouteLocationNormalized

pendingLocation = toLocation
const from = currentRoute.value

// TODO: should be moved to web history?
if (isBrowser) {
saveScrollPosition(
getScrollKey(from.fullPath, info.delta),
computeScrollPosition()
)
}

pendingLocation = toLocation
const from = currentRoute.value
navigate(toLocation, from)
.catch((error: NavigationFailure | NavigationRedirectError) => {
// a more recent navigation took place
if (pendingLocation !== toLocation) {
return createRouterError<NavigationFailure>(
ErrorTypes.NAVIGATION_CANCELLED,
{
from,
to: toLocation,
}
)
}
if (error.type === ErrorTypes.NAVIGATION_ABORTED) {
return error as NavigationFailure
}
if (error.type === ErrorTypes.NAVIGATION_GUARD_REDIRECT) {
routerHistory.go(-info.delta, false)
// the error is already handled by router.push we just want to avoid
// logging the error
pushWithRedirect(
(error as NavigationRedirectError).to,
toLocation
).catch(() => {
// TODO: in dev show warning, in prod triggerError, same as initial navigation
})
// avoid the then branch
return Promise.reject()
}
// TODO: test on different browsers ensure consistent behavior
routerHistory.go(-info.delta, false)
// unrecognized error, transfer to the global handler
return triggerError(error)
})
.then((failure: NavigationFailure | void) => {
failure =
failure ||
finalizeNavigation(
// after navigation, all matched components are resolved
toLocation as RouteLocationNormalizedLoaded,
from,
false
)

// TODO: should be moved to web history?
if (isBrowser) {
saveScrollPosition(
getScrollKey(from.fullPath, info.delta),
computeScrollPosition()
)
}
// revert the navigation
if (failure) routerHistory.go(-info.delta, false)

navigate(toLocation, from)
.catch((error: NavigationFailure | NavigationRedirectError) => {
// a more recent navigation took place
if (pendingLocation !== toLocation) {
return createRouterError<NavigationFailure>(
ErrorTypes.NAVIGATION_CANCELLED,
{
from,
to: toLocation,
}
)
}
if (error.type === ErrorTypes.NAVIGATION_ABORTED) {
return error as NavigationFailure
}
if (error.type === ErrorTypes.NAVIGATION_GUARD_REDIRECT) {
routerHistory.go(-info.delta, false)
// the error is already handled by router.push we just want to avoid
// logging the error
pushWithRedirect(
(error as NavigationRedirectError).to,
toLocation
).catch(() => {
// TODO: in dev show warning, in prod triggerError, same as initial navigation
})
// avoid the then branch
return Promise.reject()
}
// TODO: test on different browsers ensure consistent behavior
routerHistory.go(-info.delta, false)
// unrecognized error, transfer to the global handler
return triggerError(error)
})
.then((failure: NavigationFailure | void) => {
failure =
failure ||
finalizeNavigation(
// after navigation, all matched components are resolved
triggerAfterEach(
toLocation as RouteLocationNormalizedLoaded,
from,
false
failure
)

// revert the navigation
if (failure) routerHistory.go(-info.delta, false)

triggerAfterEach(
toLocation as RouteLocationNormalizedLoaded,
from,
failure
)
})
.catch(() => {
// TODO: same as above
})
})
})
.catch(() => {
// TODO: same as above
})
})
}

// Initialization and Errors

Expand Down Expand Up @@ -780,6 +787,7 @@ export function createRouter(options: RouterOptions): Router {
function markAsReady(err?: any): void {
if (ready) return
ready = true
setupListeners()
readyHandlers
.list()
.forEach(([resolve, reject]) => (err ? reject(err) : resolve()))
Expand Down Expand Up @@ -828,6 +836,7 @@ export function createRouter(options: RouterOptions): Router {
}

let started: boolean | undefined
const installedApps = new Set<App>()

const router: Router = {
currentRoute,
Expand Down Expand Up @@ -893,6 +902,19 @@ export function createRouter(options: RouterOptions): Router {

app.provide(routerKey, router)
app.provide(routeLocationKey, reactive(reactiveRoute))

let unmountApp = app.unmount
installedApps.add(app)
app.unmount = function () {
installedApps.delete(app)
if (installedApps.size < 1) {
removeHistoryListener()
currentRoute.value = START_LOCATION_NORMALIZED
started = false
ready = false
}
unmountApp.call(this, arguments)
}
},
}

Expand Down

0 comments on commit 565ec9d

Please sign in to comment.