{ path: '/bar', component: Bar, meta: { scrollToTop: true } },
],
})
+
+scrollWaiter.add()
+
const app = createApp({
setup() {
return {
}
},
+ // 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>
})
app.use(router)
-window.vm = app.mount('#app')
+router.isReady().then(() => (window.vm = app.mount('#app')))
...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
'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)
null,
'restore scroll position on forward'
)
- .execute(function() {
- history.scrollRestoration = 'manual'
- })
.execute(function() {
window.history.back()
.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,
.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()
},
}
// 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,
} 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,
(
to: RouteLocationNormalized,
from: RouteLocationNormalizedLoaded,
- savedPosition: ScrollToPosition | null
+ savedPosition: Required<ScrollPositionCoordinates> | null
): // TODO: implement false nad refactor promise based type
Awaitable<ScrollPosition | false | void>
}
// 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 =
// 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)
}
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()
}
pendingLocation = toLocation
const from = currentRoute.value
- saveScrollOnLeave(getScrollKey(from.fullPath, info.distance))
+ saveScrollPosition(
+ getScrollKey(from.fullPath, info.distance),
+ computeScrollPosition()
+ )
let failure: NavigationFailure | void
async function handleScroll(
to: RouteLocationNormalizedLoaded,
from: RouteLocationNormalizedLoaded,
- scrollPosition?: ScrollToPosition
+ scrollPosition?: Required<ScrollPositionCoordinates>
) {
if (!scrollBehavior) return
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)
})
}
--- /dev/null
+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,
+// }