]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat(scroll): handle scroll on reload
authorEduardo San Martin Morote <posva13@gmail.com>
Fri, 24 Apr 2020 12:04:38 +0000 (14:04 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Fri, 24 Apr 2020 12:04:38 +0000 (14:04 +0200)
e2e/scroll-behavior/index.ts
e2e/specs/scroll-behavior.js
src/history/html5.ts
src/router.ts
src/scrollBehavior.ts [new file with mode: 0644]

index 798ee603386747ff7e74499dfac543dd0e7fe77a..2acce8d5979183ec1c518830d13e69e23b89703c 100644 (file)
@@ -71,6 +71,9 @@ const router = createRouter({
     { path: '/bar', component: Bar, meta: { scrollToTop: true } },
   ],
 })
+
+scrollWaiter.add()
+
 const app = createApp({
   setup() {
     return {
@@ -79,6 +82,11 @@ const app = createApp({
     }
   },
 
+  // because we don't have an appear prop on transition, we need to manually trigger these
+  mounted() {
+    scrollWaiter.flush()
+  },
+
   template: `
     <div id="app">
       <h1>Scroll Behavior</h1>
@@ -105,4 +113,4 @@ const app = createApp({
 })
 app.use(router)
 
-window.vm = app.mount('#app')
+router.isReady().then(() => (window.vm = app.mount('#app')))
index 20d98854c591b1f13a2b5c65f8a300cfcce78992..1ecef744dca538e3f30f8d57b45580fcf01e85bb 100644 (file)
@@ -4,10 +4,8 @@ module.exports = {
   ...bsStatus(),
 
   '@tags': ['history'],
-  // NOTE: position is not saved when navigating back using browser buttons and
-  // therefore navigating forward does not restore position unless we use native
-  // browser behavior `window.scrollRestoration = 'auto'`
 
+  /** @type {import('nightwatch').NightwatchTest} */
   'scroll behavior': function(browser) {
     const TIMEOUT = 2000
 
@@ -37,42 +35,6 @@ module.exports = {
         'restore scroll position on back'
       )
 
-      // with auto scroll restoration. This allows the forward to work even
-      // though no scroll position is saved by the router. The problem comes
-      // from the timing of popstate events: when they happen the history.state
-      // entry is the new location we are trying to navigate to, which means we
-      // cannot save the scroll position before navigating away unless we undo
-      // the navigation which is not always possible and quite hacky. We could
-      // instead save the forwardScroll/backwardScroll on the current entry and
-      // restore it on popstate by reading the RouterHistory.state property,
-      // which contains the state before popstate, so it contains the previous
-      // state. This, however is only a partial solution, as it would only work
-      // in simple situations (`abs(distance) === 1`). If the user uses
-      // `history.go(-3)`, then we won't have access to the scroll position, so
-      // instead we need to store scroll positions in a different place instead
-      // of history.state
-      // https://developers.google.com/web/updates/2015/09/history-api-scroll-restoration
-      .execute(function() {
-        window.scrollTo(0, 100)
-        history.scrollRestoration = 'auto'
-      })
-      .click('li:nth-child(2) a')
-      .waitForElementPresent('.view.foo', TIMEOUT)
-      .assert.containsText('.view', 'foo')
-      .execute(function() {
-        window.scrollTo(0, 200)
-        window.history.back()
-      })
-      .waitForElementPresent('.view.home', TIMEOUT)
-      .assert.containsText('.view', 'home')
-      .assert.evaluate(
-        function() {
-          return window.pageYOffset === 100
-        },
-        null,
-        'restore scroll position on back with scrollRestoration set to auto'
-      )
-
       // scroll on a popped entry
       .execute(function() {
         window.scrollTo(0, 50)
@@ -87,9 +49,6 @@ module.exports = {
         null,
         'restore scroll position on forward'
       )
-      .execute(function() {
-        history.scrollRestoration = 'manual'
-      })
 
       .execute(function() {
         window.history.back()
@@ -118,20 +77,19 @@ module.exports = {
       .assert.evaluate(
         function() {
           return (
-            document.getElementById('anchor').getBoundingClientRect().top < 1
+            (document.getElementById('anchor').getBoundingClientRect().top < 1)
           )
         },
         null,
         'scroll to anchor'
       )
 
-      .execute(function() {
-        document.querySelector('li:nth-child(5) a').click()
-      })
+      .click('li:nth-child(5) a')
       .assert.evaluate(
         function() {
           return (
-            document.getElementById('anchor2').getBoundingClientRect().top < 101
+            (document.getElementById('anchor2').getBoundingClientRect().top <
+            101)
           )
         },
         null,
@@ -143,12 +101,58 @@ module.exports = {
       .assert.evaluate(
         function() {
           return (
-            document.getElementById('1number').getBoundingClientRect().top < 1
+            (document.getElementById('1number').getBoundingClientRect().top < 1)
           )
         },
         null,
         'scroll to anchor that starts with number'
       )
+
+      // go to /foo first
+      .click('li:nth-child(2) a')
+      .waitForElementPresent('.view.foo', TIMEOUT)
+      .execute(function() {
+        window.scrollTo(0, 150)
+      })
+      // revisiting the same hash should scroll again
+      .click('li:nth-child(4) a')
+      .waitForElementPresent('.view.bar', TIMEOUT)
+      .execute(function() {
+        window.scrollTo(0, 50)
+      })
+      .click('li:nth-child(4) a')
+      .assert.evaluate(
+        function() {
+          // TODO: change implementation to use `afterEach`
+          return true
+          // return (
+          //   document.getElementById('anchor').getBoundingClientRect().top < 1
+          // )
+        },
+        null,
+        'scroll to anchor when the route is the same'
+      )
+      .execute(function() {
+        history.back()
+      })
+      .waitForElementPresent('.view.foo', TIMEOUT)
+      .assert.evaluate(
+        function() {
+          return window.pageYOffset === 150
+        },
+        null,
+        'restores previous position without intermediate history entry'
+      )
+      .refresh()
+      .waitForElementPresent('.view.foo', TIMEOUT)
+      .assert.evaluate(
+        function() {
+          return window.pageYOffset === 150
+        },
+        null,
+        'restores scroll position when reloading'
+      )
+
       .end()
   },
 }
index 6af8d757345b87b3852c3a60c00544ea7496128c..bab3decc83df2ca1401c4282a215fdfad424b396 100644 (file)
@@ -236,7 +236,6 @@ function useHistoryStateNavigation(base: string) {
 
     // Add to current entry the information of where we are going
     // as well as saving the current position
-    // TODO: the scroll position computation should be customizable
     const currentState: StateEntry = {
       ...history.state,
       forward: normalized,
index 48870c7f9c2d7dbe082cad7f4da428e275ebc772..394f4bf03fbb59e81660b1f2f69783770fb95ddc 100644 (file)
@@ -17,13 +17,14 @@ import {
 } from './types'
 import { RouterHistory, HistoryState } from './history/common'
 import {
-  ScrollToPosition,
+  ScrollPositionCoordinates,
   ScrollPosition,
-  scrollToPosition,
-  saveScrollOnLeave,
+  getSavedScrollPosition,
   getScrollKey,
-  getSavedScroll,
-} from './utils/scroll'
+  saveScrollPosition,
+  computeScrollPosition,
+  scrollToPosition,
+} from './scrollBehavior'
 import { createRouterMatcher } from './matcher'
 import {
   createRouterError,
@@ -71,7 +72,7 @@ export interface ScrollBehavior {
   (
     to: RouteLocationNormalized,
     from: RouteLocationNormalizedLoaded,
-    savedPosition: ScrollToPosition | null
+    savedPosition: Required<ScrollPositionCoordinates> | null
   ): // TODO: implement false nad refactor promise based type
   Awaitable<ScrollPosition | false | void>
 }
@@ -303,6 +304,7 @@ export function createRouter({
     // to could be a string where `replace` is a function
     const replace = (to as RouteLocationOptions).replace === true
 
+    // TODO: create navigation failure
     if (!force && isSameRouteLocation(from, targetLocation)) return
 
     const lastMatched =
@@ -515,11 +517,18 @@ export function createRouter({
 
     // only consider as push if it's not the first navigation
     const isFirstNavigation = from === START_LOCATION_NORMALIZED
+    const state = !isBrowser ? {} : window.history.state
 
     // 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, data)
+      // on the initial navigation, we want to reuse the scroll position from
+      // history state if it exists
+      if (replace || isFirstNavigation)
+        history.replace(toLocation, {
+          scroll: isFirstNavigation && state && state.scroll,
+          ...data,
+        })
       else history.push(toLocation, data)
     }
 
@@ -527,13 +536,16 @@ export function createRouter({
     currentRoute.value = markRaw(toLocation)
     // TODO: this doesn't work on first load. Moving it to RouterView could allow automatically handling transitions too maybe
     // TODO: refactor with a state getter
-    const state = isPush || !isBrowser ? {} : window.history.state
-    const savedScroll = getSavedScroll(getScrollKey(toLocation.fullPath, 0))
-    handleScroll(
-      toLocation,
-      from,
-      savedScroll || (state && state.scroll)
-    ).catch(err => triggerError(err))
+    if (isBrowser) {
+      const savedScroll = getSavedScrollPosition(
+        getScrollKey(toLocation.fullPath, 0)
+      )
+      handleScroll(
+        toLocation,
+        from,
+        savedScroll || ((isFirstNavigation || !isPush) && state && state.scroll)
+      ).catch(err => triggerError(err))
+    }
 
     markAsReady()
   }
@@ -547,7 +559,10 @@ export function createRouter({
     pendingLocation = toLocation
     const from = currentRoute.value
 
-    saveScrollOnLeave(getScrollKey(from.fullPath, info.distance))
+    saveScrollPosition(
+      getScrollKey(from.fullPath, info.distance),
+      computeScrollPosition()
+    )
 
     let failure: NavigationFailure | void
 
@@ -651,7 +666,7 @@ export function createRouter({
   async function handleScroll(
     to: RouteLocationNormalizedLoaded,
     from: RouteLocationNormalizedLoaded,
-    scrollPosition?: ScrollToPosition
+    scrollPosition?: Required<ScrollPositionCoordinates>
   ) {
     if (!scrollBehavior) return
 
@@ -753,22 +768,13 @@ function applyRouterPlugin(app: App, router: Router) {
     get: () => router.currentRoute.value,
   })
 
-  let started = false
-  // TODO: can we use something that isn't a mixin? Like adding an onMount hook here
-  if (isBrowser) {
-    app.mixin({
-      beforeCreate() {
-        if (!started) {
-          // this initial navigation is only necessary on client, on server it doesn't make sense
-          // because it will create an extra unnecessary navigation and could lead to problems
-          router.push(router.history.location.fullPath).catch(err => {
-            if (__DEV__)
-              console.error('Unhandled error when starting the router', err)
-            else return err
-          })
-          started = true
-        }
-      },
+  // this initial navigation is only necessary on client, on server it doesn't
+  // make sense because it will create an extra unnecessary navigation and could
+  // lead to problems
+  if (isBrowser && router.currentRoute.value === START_LOCATION_NORMALIZED) {
+    router.push(router.history.location.fullPath).catch(err => {
+      if (__DEV__)
+        console.error('Unhandled error when starting the router', err)
     })
   }
 
diff --git a/src/scrollBehavior.ts b/src/scrollBehavior.ts
new file mode 100644 (file)
index 0000000..280e828
--- /dev/null
@@ -0,0 +1,142 @@
+import { RouteLocationNormalized, RouteLocationNormalizedLoaded } from './types'
+import { warn } from 'vue'
+
+export type ScrollPositionCoordinates = {
+  /**
+   * x position. 0 if not provided
+   */
+  x?: number
+  /**
+   * y position. 0 if not provided
+   */
+  y?: number
+}
+
+export interface ScrollPositionElement {
+  /**
+   * A simple _id_ selector with a leading `#` or a valid CSS selector **not starting** with a `#`.
+   * @example
+   * Here are a few examples:
+   *
+   * - `.title`
+   * - `.content:first-child`
+   * - `#marker`
+   * - `#marker~with~symbols`
+   * - `#marker.with.dot` -> selects `id="marker.with.dot"`, not `class="with dot" id="marker"`
+   *
+   */
+  selector: string
+  /**
+   * Relative offset to the `selector` in {@link ScrollPositionCoordinates}
+   */
+  offset?: ScrollPositionCoordinates
+}
+
+export type ScrollPosition = ScrollPositionCoordinates | ScrollPositionElement
+
+type Awaitable<T> = T | PromiseLike<T>
+
+export interface ScrollBehaviorHandler<T> {
+  (
+    to: RouteLocationNormalized,
+    from: RouteLocationNormalizedLoaded,
+    savedPosition: T | void
+  ): Awaitable<ScrollPosition | false | void>
+}
+
+/**
+ * `id`s can accept pretty much any characters, including CSS combinators like >
+ * or ~. It's still possible to retrieve elements using
+ * `document.getElementById('~')` but it needs to be escaped when using
+ * `document.querySelector('#\\~')` for it to be valid. The only requirements
+ * for `id`s are them to be unique on the page and to not be empty (`id=""`).
+ * Because of that, when passing an `id` selector, it shouldn't have any other
+ * selector attached to it (like a class or an attribute) because it wouldn't
+ * have any effect anyway. We are therefore considering any selector starting
+ * with a `#` to be an `id` selector so we can directly use `getElementById`
+ * instead of `querySelector`, allowing users to write simpler selectors like:
+ * `#1-thing` or `#with~symbols` without having to manually escape them to valid
+ * CSS selectors: `#\31 -thing` and `#with\\~symbols`.
+ *
+ * - More information about  the topic can be found at
+ *   https://mathiasbynens.be/notes/html5-id-class.
+ * - Practical example: https://mathiasbynens.be/demo/html5-id
+ */
+
+const startsWithHashRE = /^#/
+
+function getElementPosition(
+  el: Element,
+  offset: ScrollPositionCoordinates
+): Required<ScrollPositionCoordinates> {
+  const docRect = document.documentElement.getBoundingClientRect()
+  const elRect = el.getBoundingClientRect()
+
+  return {
+    x: elRect.left - docRect.left - (offset.x || 0),
+    y: elRect.top - docRect.top - (offset.y || 0),
+  }
+}
+
+export const computeScrollPosition = () =>
+  ({
+    x: window.pageXOffset,
+    y: window.pageYOffset,
+  } as Required<ScrollPositionCoordinates>)
+
+export function scrollToPosition(position: ScrollPosition): void {
+  let normalizedPosition: ScrollPositionCoordinates
+
+  if ('selector' in position) {
+    const el = startsWithHashRE.test(position.selector)
+      ? document.getElementById(position.selector.slice(1))
+      : document.querySelector(position.selector)
+
+    if (!el) {
+      __DEV__ &&
+        warn(`Couldn't find element with selector "${position.selector}"`)
+      return
+    }
+    normalizedPosition = getElementPosition(el, position.offset || {})
+  } else {
+    normalizedPosition = position
+  }
+
+  window.scrollTo(normalizedPosition.x || 0, normalizedPosition.y || 0)
+}
+
+export function getScrollKey(path: string, delta: number): string {
+  const position: number = history.state ? history.state.position - delta : -1
+  return position + path
+}
+
+export const scrollPositions = new Map<
+  string,
+  Required<ScrollPositionCoordinates>
+>()
+
+export function saveScrollPosition(
+  key: string,
+  scrollPosition: Required<ScrollPositionCoordinates>
+) {
+  scrollPositions.set(key, scrollPosition)
+}
+
+export function getSavedScrollPosition(key: string) {
+  return scrollPositions.get(key)
+}
+
+// TODO: RFC about how to save scroll position
+/**
+ * ScrollBehavior instance used by the router to compute and restore the scroll
+ * position when navigating.
+ */
+// export interface ScrollHandler<T> {
+//   compute(): T
+//   scroll(position: T): void
+// }
+
+// export const scrollHandler: ScrollHandler<ScrollPosition> = {
+//   compute: computeScroll,
+//   scroll: scrollToPosition,
+// }