From: Eduardo San Martin Morote Date: Tue, 24 Mar 2020 21:12:10 +0000 (+0100) Subject: feat: allow passing state to history X-Git-Tag: v4.0.0-alpha.4~16 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=ac1c96f176dcad8aac03a86a1dccfbaab4b66520;p=thirdparty%2Fvuejs%2Frouter.git feat: allow passing state to history --- diff --git a/e2e/modal/index.html b/e2e/modal/index.html index 825586b1..7b2dd552 100644 --- a/e2e/modal/index.html +++ b/e2e/modal/index.html @@ -7,13 +7,42 @@ Vue Router Examples - Encoding + + << Back to Homepage
- +
diff --git a/e2e/modal/index.ts b/e2e/modal/index.ts index 968dbe67..23ed5231 100644 --- a/e2e/modal/index.ts +++ b/e2e/modal/index.ts @@ -1,6 +1,9 @@ -import { createRouter, createWebHistory, useRoute } from '../../src' -import { RouteComponent } from '../../src/types' -import { createApp, readonly, reactive, ref, watchEffect } from 'vue' +import { createRouter, createWebHistory, useRoute, useView } from '../../src' +import { + RouteComponent, + RouteLocationNormalizedResolved, +} from '../../src/types' +import { createApp, readonly, ref, watchEffect, computed, toRefs } from 'vue' const users = readonly([ { name: 'John' }, @@ -8,178 +11,15 @@ const users = readonly([ { name: 'James' }, ]) -const modalState = reactive({ - showModal: false, - userId: 0, -}) - -const enum GhostNavigation { - none = 0, - restoreGhostUrl, - backToOriginal, -} - -let navigationState: GhostNavigation = GhostNavigation.none -window.addEventListener('popstate', function customPopListener(event) { - let { state } = event - console.log('popstate!', navigationState, event.state) - - // nested state machine to handle - if (navigationState !== GhostNavigation.none) { - if (navigationState === GhostNavigation.restoreGhostUrl) { - webHistory.replace(state.ghostURL) - console.log('replaced ghost', state.ghostURL) - navigationState = GhostNavigation.backToOriginal - webHistory.back(false) - } else if (navigationState === GhostNavigation.backToOriginal) { - navigationState = GhostNavigation.none - Object.assign(modalState, state.modalState) - console.log('came from a ghost navigation, nothing to do') - // let's remove the guard from navigating away, it will be added again by afterEach when - // entering the url - historyCleaner && historyCleaner() - historyCleaner = undefined - event.stopImmediatePropagation() - } - - return - } +async function showUserModal(id: number) { + // add backgroundView state to the location so we can render a different view from the one + const backgroundView = router.currentRoute.value.fullPath - if (!state) return - // we did a back from a modal - if (state.forwardGhost && webHistory.state.ghostURL === state.forwardGhost) { - // make sure the url saved in the history stack is good - navigationState = GhostNavigation.restoreGhostUrl - cleanNavigationFromModalListener && cleanNavigationFromModalListener() - webHistory.forward(false) - // we did a forward to a modal - } else if ( - state.ghostURL && - state.ghostURL === webHistory.state.forwardGhost - ) { - webHistory.replace(state.displayURL) - event.stopImmediatePropagation() - Object.assign(modalState, state.modalState) - // TODO: setup same listeners as state S - // we did a back to a modal - } else if ( - state.ghostURL && - state.ghostURL === webHistory.state.backwardGhost - ) { - let remove = router.afterEach(() => { - Object.assign(modalState, state.modalState) - remove() - removeError() - }) - // if the navigation fails, remove the listeners - let removeError = router.onError(() => { - console.log('navigation aborted, removing stuff') - remove() - removeError() - }) - } - // if ((state && !state.forward) || state.showModal) { - // console.log('stopping it!') - // // copy showModal state - // modalState.showModal = !!state.showModal - // // don't let the router catch this one - // event.stopImmediatePropagation() - // } -}) - -const About: RouteComponent = { - template: `
-

About

-

If you came from a user modal, you should go back to it

- -
- `, - methods: { - back() { - window.history.back() - }, - }, -} - -let historyCleaner: (() => void) | undefined - -let cleanNavigationFromModalListener: (() => void) | undefined - -function setupPostNavigationFromModal(ghostURL: string) { - let removePost: (() => void) | undefined - const removeGuard = router.beforeEach((to, from, next) => { - console.log('From', from.fullPath, '->', to.fullPath) - // change the URL before leaving so that when we go back we are navigating to the right url - webHistory.replace(ghostURL) - console.log('changed url', ghostURL) - removeGuard() - removePost = router.afterEach(() => { - console.log('✅ navigated away') - webHistory.replace(webHistory.location, { - backwardGhost: ghostURL, - }) - removePost && removePost() - }) - - // trigger the navigation again, TODO: does it change anything - next(to.fullPath) + await router.push({ + name: 'user', + params: { id: '' + id }, + state: { backgroundView }, }) - - // remove any existing listener - cleanNavigationFromModalListener && cleanNavigationFromModalListener() - - cleanNavigationFromModalListener = () => { - removeGuard() - removePost && removePost() - cleanNavigationFromModalListener = undefined - } -} - -function showUserModal(id: number) { - const route = router.currentRoute.value - // generate a new entry that is exactly like the one we are on but with an extra query - // so it still counts like a navigation for the router when leaving it or when pushing on top - const ghostURLNormalized = router.resolve({ - path: route.path, - query: { ...route.query, __m: Math.random() }, - hash: route.hash, - }) - // the url we want to show - let url = router.resolve({ name: 'user', params: { id: '' + id } }) - const displayURL = url.fullPath - const ghostURL = ghostURLNormalized.fullPath - const originalURL = router.currentRoute.value.fullPath - - webHistory.replace(router.currentRoute.value, { - // save that we are going to a ghost route - forwardGhost: ghostURL, - // save current modalState to be able to restore it when navigating away - // from the modal - modalState: { ...modalState }, - }) - - // after saving the modal state, we can change it - modalState.userId = id - modalState.showModal = true - - // push a new entry in the history stack with the ghost url in the state - // to be able to restore it - webHistory.push(displayURL, { - // the url that should be displayed while being on this entry - displayURL, - // the original url TODO: is it necessary? - originalURL, - // the url that resolves to the same components as originalURL but slightly different - // so that the router doesn't consider it as a duplicated navigation - ghostURL, - modalState: { ...modalState }, - }) - - // make sure we clear what we did before leaving - // this will only trigger on `push`/`replace` because we are listening on `popstate` - // so that if we go to the previous entry we can stop the propagation so the router never knows - // and remove this listener ourselves - setupPostNavigationFromModal(ghostURL) } function closeUserModal() { @@ -198,38 +38,65 @@ const Home: RouteComponent = { -

- User #{{ modalState.userId }} -
- Name: {{ users[modalState.userId].name }} -

- Go somewhere else -
- +
+
+

+ User #{{ userId }} +
+ Name: {{ users[userId].name }} +

+ Go somewhere else +
+ +
+
`, setup() { - const modal = ref() + const modal = ref() + const route = useRoute() + const historyState = computed(() => route.fullPath && window.history.state) + + const userId = computed(() => route.params.id) watchEffect(() => { - if (!modal.value) return + const el = modal.value + if (!el) return - const show = modalState.showModal + const show = historyState.value.backgroundView console.log('show modal?', show) - if (show) modal.value.show() - else modal.value.close() + if (show) { + if ('show' in el) el.show() + else el.setAttribute('open', '') + } else { + if ('close' in el) el.close() + else el.removeAttribute('open') + } }) return { modal, + historyState, showUserModal, closeUserModal, - modalState, + userId, users, } }, } +const About: RouteComponent = { + template: `
+

About

+ + | + Back home +
`, + methods: { + back: () => history.back(), + }, +} + const UserDetails: RouteComponent = { template: `

User #{{ id }}

@@ -260,33 +127,22 @@ router.beforeEach((to, from, next) => { next() }) -router.afterEach(() => { - const { state } = window.history - console.log('afterEach', state) - if (state && state.displayURL) { - console.log('restoring', state.displayURL, 'for', state.originalURL) - // restore the state - Object.assign(modalState, state.modalState) - webHistory.replace(state.displayURL) - // history.pushState({ showModal: true }, '', url) - // historyCleaner && historyCleaner() - historyCleaner = router.beforeEach((to, from, next) => { - // add data to history state so it can be restored if we go back - webHistory.replace(state.ghostURL, { - modalState: { ...modalState }, - }) - // remove this guard - historyCleaner && historyCleaner() - // trigger the same navigation again - next(to.fullPath) - }) - } -}) - const app = createApp({ setup() { const route = useRoute() - return { route } + const routeWithModal = computed(() => { + if (historyState.value.backgroundView) { + return router.resolve( + historyState.value.backgroundView + ) as RouteLocationNormalizedResolved + } else { + return route + } + }) + const historyState = computed(() => route.fullPath && window.history.state) + const ViewComponent = useView({ route: routeWithModal, name: 'default' }) + + return { route, ViewComponent, historyState, ...toRefs(route) } }, }) app.use(router) diff --git a/e2e/specs/modal.js b/e2e/specs/modal.js index 533a1711..82e2622b 100644 --- a/e2e/specs/modal.js +++ b/e2e/specs/modal.js @@ -49,7 +49,7 @@ module.exports = { }, /** @type {import('nightwatch').NightwatchTest} */ - 'should not keep the modal when reloading'(browser) { + 'can keep the modal when reloading'(browser) { browser .url(baseURL) .waitForElementVisible('#app', 1000) @@ -58,35 +58,13 @@ module.exports = { .click('li:nth-child(2) button') .assert.visible('dialog') .refresh() - .assert.containsText('h1', 'User #1') .assert.urlEquals(baseURL + '/users/1') - .back() - // FIXME: reload - // .assert.urlEquals(baseURL + '/') - // .assert.containsText('h1', 'Home') - // .assert.not.visible('dialog') - - .end() - }, - - 'should not keep the modal when reloading and navigating to home again'( - browser - ) { - browser - .url(baseURL) - .waitForElementVisible('#app', 1000) .assert.containsText('h1', 'Home') - - .click('li:nth-child(2) button') .assert.visible('dialog') - .refresh() - .assert.containsText('h1', 'User #1') - .assert.urlEquals(baseURL + '/users/1') - .click('#app a') + .back() .assert.urlEquals(baseURL + '/') .assert.containsText('h1', 'Home') - // FIXME: reload - // .assert.not.visible('dialog') + .assert.not.visible('dialog') .end() }, @@ -102,13 +80,13 @@ module.exports = { .click('li:nth-child(2) a') .assert.urlEquals(baseURL + '/users/1') .assert.containsText('h1', 'User #1') - .click('a') + .click('#app a') .assert.urlEquals(baseURL + '/') .assert.containsText('h1', 'Home') .click('li:nth-child(3) a') .assert.urlEquals(baseURL + '/users/2') .assert.containsText('h1', 'User #2') - .click('a') + .click('#app a') .assert.urlEquals(baseURL + '/') .assert.containsText('h1', 'Home') .click('li:nth-child(2) button') diff --git a/src/components/View.ts b/src/components/View.ts index dcb8180d..945e2cb7 100644 --- a/src/components/View.ts +++ b/src/components/View.ts @@ -9,6 +9,7 @@ import { ComponentPublicInstance, unref, SetupContext, + toRefs, } from 'vue' import { RouteLocationMatched, @@ -85,7 +86,8 @@ export const View = defineComponent({ setup(props, { attrs }) { const route = inject(routeLocationKey)! - const renderView = useView({ route, name: props.name }) + const renderView = useView({ route, name: toRefs(props).name }) + return () => renderView(attrs) }, }) diff --git a/src/history/memory.ts b/src/history/memory.ts index dadbb493..53362f1c 100644 --- a/src/history/memory.ts +++ b/src/history/memory.ts @@ -55,6 +55,8 @@ export default function createMemoryHistory(base: string = ''): RouterHistory { const routerHistory: RouterHistory = { // rewritten by Object.defineProperty location: START, + // TODO: + state: {}, base, replace(to) { diff --git a/src/index.ts b/src/index.ts index e7e450e4..cbb6054a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ import createWebHistory from './history/html5' import createMemoryHistory from './history/memory' import createWebHashHistory from './history/hash' -import { inject } from 'vue' +import { inject, computed, reactive } from 'vue' import { routerKey, routeLocationKey } from './utils/injectionSymbols' +import { RouteLocationNormalizedResolved } from './types' export { LocationQuery, parseQuery, stringifyQuery } from './utils/query' @@ -23,8 +24,8 @@ export { export { createRouter, Router, RouterOptions, ErrorHandler } from './router' export { onBeforeRouteLeave } from './navigationGuards' -export { Link } from './components/Link' -export { View } from './components/View' +export { Link, useLink } from './components/Link' +export { View, useView } from './components/View' export { createWebHistory, createMemoryHistory, createWebHashHistory } @@ -33,5 +34,11 @@ export function useRouter() { } export function useRoute() { - return inject(routeLocationKey)! + const route = inject(routeLocationKey)! + const ret = {} as RouteLocationNormalizedResolved + for (let key in route.value) { + // @ts-ignore + ret[key] = computed(() => route.value[key]) + } + return reactive(ret) } diff --git a/src/router.ts b/src/router.ts index 161561cb..667398bf 100644 --- a/src/router.ts +++ b/src/router.ts @@ -11,7 +11,12 @@ import { MatcherLocationNormalized, RouteLocationNormalizedResolved, } from './types' -import { RouterHistory, parseURL, stringifyURL } from './history/common' +import { + RouterHistory, + parseURL, + stringifyURL, + HistoryState, +} from './history/common' import { ScrollToPosition, ScrollPosition, @@ -74,7 +79,7 @@ export interface Router { getRoutes(): RouteRecordNormalized[] resolve(to: RouteLocation): RouteLocationNormalized - createHref(to: RouteLocationNormalized): string + createHref(to: Immutable): string push(to: RouteLocation): Promise replace(to: RouteLocation): Promise @@ -193,6 +198,7 @@ export function createRouter({ } function push( + // TODO: should not allow normalized version to: RouteLocation | RouteLocationNormalized ): Promise { return pushWithRedirect(to, undefined) @@ -206,6 +212,7 @@ export function createRouter({ // Some functions will pass a normalized location and we don't need to resolve it again typeof to === 'object' && 'matched' in to ? to : resolve(to)) const from: RouteLocationNormalizedResolved = currentRoute.value + const data: HistoryState | undefined = (to as any).state // @ts-ignore: no need to check the string as force do not exist on a string const force: boolean | undefined = to.force @@ -243,7 +250,8 @@ export function createRouter({ from, true, // RouteLocationNormalized will give undefined - (to as RouteLocation).replace === true + (to as RouteLocation).replace === true, + data ) return currentRoute.value @@ -353,7 +361,8 @@ export function createRouter({ toLocation: RouteLocationNormalizedResolved, from: RouteLocationNormalizedResolved, isPush: boolean, - replace?: boolean + replace?: boolean, + data?: HistoryState ) { // a more recent navigation took place if (pendingLocation !== toLocation) { @@ -378,8 +387,8 @@ export function createRouter({ // change URL only if the user did a push/replace and if it's not the initial navigation because // it's just reflecting the url if (isPush) { - if (replace || isFirstNavigation) history.replace(toLocation) - else history.push(toLocation) + if (replace || isFirstNavigation) history.replace(toLocation, data) + else history.push(toLocation, data) } // accept current navigation diff --git a/src/types/index.ts b/src/types/index.ts index 6d4ca2f3..2bf0f438 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,7 @@ import { ComputedRef, } from 'vue' import { RouteRecordNormalized } from '../matcher/types' +import { HistoryState } from '../history/common' export type Lazy = () => Promise export type Override = Pick> & U @@ -58,6 +59,10 @@ export interface RouteLocationOptions { * Triggers the navigation even if the location is the same as the current one */ force?: boolean + /** + * State to save using the History API. This cannot contain any reactive values and some primitives like Symbols are forbidden. More info at TODO: link mdn + */ + state?: HistoryState } // User level location