]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat: allow passing state to history
authorEduardo San Martin Morote <posva13@gmail.com>
Tue, 24 Mar 2020 21:12:10 +0000 (22:12 +0100)
committerEduardo San Martin Morote <posva13@gmail.com>
Tue, 24 Mar 2020 21:12:10 +0000 (22:12 +0100)
e2e/modal/index.html
e2e/modal/index.ts
e2e/specs/modal.js
src/components/View.ts
src/history/memory.ts
src/index.ts
src/router.ts
src/types/index.ts

index 825586b1c607567e3a174ba608f03db0860a9379..7b2dd55209c736746a7a635d011824327474726d 100644 (file)
@@ -7,13 +7,42 @@
     <title>Vue Router Examples - Encoding</title>
     <!-- TODO: replace with local imports for promises and anything else needed -->
     <script src="https://polyfill.io/v3/polyfill.min.js?features=default%2Ces2015"></script>
+
+    <style>
+      #dialog {
+        top: 0;
+        left: 0;
+        position: fixed;
+        width: 100vw;
+        height: 100vh;
+        border: none;
+        margin: 0;
+        padding: 0;
+        background-color: rgb(0, 0, 0, 0.5);
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        padding-top: 2%;
+      }
+      #dialog:not([open]) {
+        display: none;
+      }
+
+      /* container */
+      #dialog > * {
+        background-color: white;
+        max-width: 500px;
+        max-height: 300px;
+        padding: 1rem;
+      }
+    </style>
   </head>
   <body>
     <a href="/">&lt;&lt; Back to Homepage</a>
     <hr />
 
     <div id="app">
-      <router-view></router-view>
+      <component :is="ViewComponent"></component>
     </div>
   </body>
 </html>
index 968dbe679adfbf3ac3044752a9d12d1cae6182e4..23ed52316ac352b3186120eed12d3e0118cde652 100644 (file)
@@ -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: `<div>
-  <h1>About</h1>
-  <p>If you came from a user modal, you should go back to it</p>
-  <button @click="back">Back</button>
-  </div>
-  `,
-  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 = {
   </ul>
 
   <dialog ref="modal" id="dialog">
-    <p>
-    User #{{ modalState.userId }}
-    <br>
-    Name: {{ users[modalState.userId].name }}
-    </p>
-    <router-link to="/about">Go somewhere else</router-link>
-    <br>
-    <button @click="closeUserModal">Close</button>
+    <div>
+      <div v-if="userId">
+        <p>
+        User #{{ userId }}
+        <br>
+        Name: {{ users[userId].name }}
+        </p>
+        <router-link to="/about">Go somewhere else</router-link>
+        <br>
+        <button @click="closeUserModal">Close</button>
+      </div>
+    </div>
   </dialog>
   </div>`,
   setup() {
-    const modal = ref()
+    const modal = ref<HTMLDialogElement | HTMLElement>()
+    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: `<div>
+    <h1>About</h1>
+    <button @click="back">Back</button>
+    <span> | </span>
+    <router-link to="/">Back home</router-link>
+  </div>`,
+  methods: {
+    back: () => history.back(),
+  },
+}
+
 const UserDetails: RouteComponent = {
   template: `<div>
     <h1>User #{{ id }}</h1>
@@ -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)
index 533a1711d608b89f995812b6222b3af9fb853dfd..82e2622b4895cc6c403074a32fa4c342f295d34d 100644 (file)
@@ -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')
index dcb8180dcabffd8316eeccb2a5c67f723b9b3dee..945e2cb73dfcbb8d45b6df1c3962382777f4511b 100644 (file)
@@ -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)
   },
 })
index dadbb4938077a7206db2949b4a19d5c57413b1fe..53362f1ce4dcc27ca28806db7a78ca8f27495154 100644 (file)
@@ -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) {
index e7e450e488afd86ad57bdbd253ceffcdf8fbcb29..cbb6054a4e453e5b7cf51859ae7952f92cde6418 100644 (file)
@@ -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)
 }
index 161561cba4403b0eb5daf2cf1a385e7b1c3b93d8..667398bf22259b043cdc05bfc2826b36b2a01ec8 100644 (file)
@@ -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<RouteLocationNormalized>): string
   push(to: RouteLocation): Promise<RouteLocationNormalizedResolved>
   replace(to: RouteLocation): Promise<RouteLocationNormalizedResolved>
 
@@ -193,6 +198,7 @@ export function createRouter({
   }
 
   function push(
+    // TODO: should not allow normalized version
     to: RouteLocation | RouteLocationNormalized
   ): Promise<RouteLocationNormalizedResolved> {
     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
index 6d4ca2f384f035ff63e47aa34ac7c6f754f9f9d6..2bf0f4384ec6f94b17cab90c33d424b2c9027b25 100644 (file)
@@ -8,6 +8,7 @@ import {
   ComputedRef,
 } from 'vue'
 import { RouteRecordNormalized } from '../matcher/types'
+import { HistoryState } from '../history/common'
 
 export type Lazy<T> = () => Promise<T>
 export type Override<T, U> = Pick<T, Exclude<keyof T, keyof U>> & 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