Skip to content

Commit

Permalink
refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
brillout committed Oct 14, 2024
1 parent 8b14f2e commit b90ac66
Show file tree
Hide file tree
Showing 2 changed files with 86 additions and 58 deletions.
133 changes: 82 additions & 51 deletions vike/client/client-routing-runtime/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,83 @@ export {
initHistoryState,
getHistoryState,
pushHistory,
ScrollPosition,
type ScrollPosition,
saveScrollPosition,
monkeyPatchHistoryPushState
}

import { assert, assertUsage, hasProp, isObject } from './utils.js'

type HistoryState = {
timestamp?: number
scrollPosition?: null | ScrollPosition
triggeredBy?: 'user' | 'vike' | 'browser'
type StateVikeEnhanced = {
timestamp: number
scrollPosition: null | ScrollPosition
triggeredBy: 'user' | 'vike' | 'browser'
_isVikeEnhanced: true
}
type ScrollPosition = { x: number; y: number }

// Fill missing state information:
// - `history.state` can uninitialized (i.e. `null`):
// - The very first render
// - The user's code runs `location.hash = '#section'`
// - The user clicks on an anchor link `<a href="#section">Section</a>` (Vike's `initOnLinkClick()` handler skips hash links).
// - State information may be incomplete if `history.state` is set by an old Vike version. (E.g. `state.timestamp` was introduced for `pageContext.isBackwardNavigation` in `0.4.19`.)
type StateNotInitialized =
// Uninitialized => `null` (https://developer.mozilla.org/en-US/docs/Web/API/History/state#value)
| null
// Maybe there is a browser that sets the uninitialized value to be `undefined` instead of `null`
| undefined
// State may be incomplete if `window.history.state` is set by an old Vike version. (E.g. `state.timestamp` was introduced for `pageContext.isBackwardNavigation` in `0.4.19`.)
| Partial<StateVikeEnhanced>
// Already enhanced
| StateVikeEnhanced

// `window.history.state` can be uninitialized (i.e. `null`):
// - The very first render
// - The user's code runs `location.hash = '#section'`
// - The user clicks on an anchor link `<a href="#section">Section</a>` (Vike's `initOnLinkClick()` handler skips hash links).
function initHistoryState() {
// No way found to add TypeScript types to `window.history.state`: https://github.com/microsoft/TypeScript/issues/36178
let state: HistoryState = window.history.state
if (!state) {
state = { _isVikeEnhanced: true }
}
let hasModifications = false
if (!('timestamp' in state)) {
hasModifications = true
state.timestamp = getTimestamp()
}
if (!('scrollPosition' in state)) {
hasModifications = true
state.scrollPosition = getScrollPosition()
}
if (!('triggeredBy' in state)) {
state.triggeredBy = 'browser'
const stateNotInitialized: StateNotInitialized = window.history.state

// Already enhanced
if (isVikeEnhanced(stateNotInitialized)) {
assertState(stateNotInitialized)
return
}
assertState(state)
if (hasModifications) {
replaceHistoryState(state)

const timestamp = getTimestamp()
const scrollPosition = getScrollPosition()
const triggeredBy = 'browser'

let stateVikeEnhanced: StateVikeEnhanced
if (!stateNotInitialized) {
stateVikeEnhanced = {
timestamp,
scrollPosition,
triggeredBy,
_isVikeEnhanced: true
}
assertState(stateVikeEnhanced)
return
} else {
// State information may be incomplete if `window.history.state` is set by an old Vike version. (E.g. `state.timestamp` was introduced for `pageContext.isBackwardNavigation` in `0.4.19`.)
stateVikeEnhanced = {
timestamp: stateNotInitialized.timestamp ?? timestamp,
scrollPosition: stateNotInitialized.scrollPosition ?? scrollPosition,
triggeredBy: stateNotInitialized.triggeredBy ?? triggeredBy,
_isVikeEnhanced: true
}
assertState(stateVikeEnhanced)
}

replaceHistoryState(stateVikeEnhanced)
}

function getHistoryState(): HistoryState {
const state: unknown = window.history.state || {}
function getState(): StateVikeEnhanced {
const state = getHistoryState()
assertState(state)
return state
}

function getHistoryState(): StateNotInitialized {
const state: StateNotInitialized = window.history.state
return state
}

function getScrollPosition(): ScrollPosition {
const scrollPosition = { x: window.scrollX, y: window.scrollY }
return scrollPosition
Expand All @@ -63,39 +89,43 @@ function getTimestamp() {

function saveScrollPosition() {
const scrollPosition = getScrollPosition()
const state = getHistoryState()
const state = getState()
replaceHistoryState({ ...state, scrollPosition })
}

function pushHistory(url: string, overwriteLastHistoryEntry: boolean) {
if (!overwriteLastHistoryEntry) {
const timestamp = getTimestamp()
pushHistoryState({ timestamp, scrollPosition: null, triggeredBy: 'vike', _isVikeEnhanced: true }, url)
pushHistoryState(
{
timestamp,
// I don't remember why I set it to `null`, maybe because setting it now would be too early? (Maybe there is a delay between renderPageClientSide() is finished and the browser updating the scroll position.) Anyways, it seems like autoSaveScrollPosition() is enough.
scrollPosition: null,
triggeredBy: 'vike',
_isVikeEnhanced: true
},
url
)
} else {
replaceHistoryState(getHistoryState(), url)
replaceHistoryState(getState(), url)
}
}

function assertState(state: unknown): asserts state is HistoryState {
function assertState(state: unknown): asserts state is StateVikeEnhanced {
assert(isObject(state))

if ('timestamp' in state) {
const { timestamp } = state
assert(typeof timestamp === 'number')
}

if ('scrollPosition' in state) {
const { scrollPosition } = state
if (scrollPosition !== null) {
assert(hasProp(scrollPosition, 'x', 'number') && hasProp(scrollPosition, 'y', 'number'))
}
assert(hasProp(state, '_isVikeEnhanced', 'true'))
assert(hasProp(state, 'timestamp', 'number'))
assert(hasProp(state, 'scrollPosition'))
if (state.scrollPosition !== null) {
assert(hasProp(state, 'scrollPosition', 'object'))
assert(hasProp(state.scrollPosition, 'x', 'number') && hasProp(state.scrollPosition, 'y', 'number'))
}
}
function replaceHistoryState(state: HistoryState, url?: string) {
function replaceHistoryState(state: StateVikeEnhanced, url?: string) {
const url_ = url ?? null // Passing `undefined` chokes older Edge versions.
window.history.replaceState(state, '', url_)
}
function pushHistoryState(state: HistoryState, url: string) {
function pushHistoryState(state: StateVikeEnhanced, url: string) {
// Vike should call window.history.pushState() (and not the orignal `pushStateOriginal()`) so that other tools (e.g. user tracking) can listen to Vike's pushState() calls, see https://github.com/vikejs/vike/issues/1582.
window.history.pushState(state, '', url)
}
Expand All @@ -107,7 +137,7 @@ function monkeyPatchHistoryPushState() {
stateOriginal === undefined || stateOriginal === null || isObject(stateOriginal),
'history.pushState(state) argument state must be an object'
)
const stateEnhanced: HistoryState = isVikeEnhanced(stateOriginal)
const stateEnhanced: StateVikeEnhanced = isVikeEnhanced(stateOriginal)
? stateOriginal
: {
_isVikeEnhanced: true,
Expand All @@ -116,10 +146,11 @@ function monkeyPatchHistoryPushState() {
triggeredBy: 'user',
...stateOriginal
}
assertState(stateEnhanced)
return pushStateOriginal!(stateEnhanced, ...rest)
}
}

function isVikeEnhanced(state: unknown): state is HistoryState {
function isVikeEnhanced(state: unknown): state is StateVikeEnhanced {
return isObject(state) && '_isVikeEnhanced' in state
}
11 changes: 4 additions & 7 deletions vike/client/client-routing-runtime/onBrowserHistoryNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ import { initHistoryState, getHistoryState } from './history.js'
import { renderPageClientSide } from './renderPageClientSide.js'
import { type ScrollTarget, setScrollPosition } from './setScrollPosition.js'

const globalObject = getGlobalObject<{
previousState: ReturnType<typeof getState>
}>('onBrowserHistoryNavigation.ts', { previousState: getState() })
const globalObject = getGlobalObject('onBrowserHistoryNavigation.ts', { previousState: getState() })

function onBrowserHistoryNavigation() {
// - The popstate event is trigged upon:
Expand All @@ -23,14 +21,14 @@ function onBrowserHistoryNavigation() {
window.addEventListener('popstate', async (): Promise<undefined> => {
const currentState = getState()

const scrollTarget: ScrollTarget = currentState.historyState.scrollPosition || undefined
const scrollTarget: ScrollTarget = currentState.historyState?.scrollPosition || undefined

const isUserLandPushStateNavigation = currentState.historyState.triggeredBy === 'user'
const isUserLandPushStateNavigation = currentState.historyState?.triggeredBy === 'user'

const isHashNavigation = currentState.urlWithoutHash === globalObject.previousState.urlWithoutHash

const isBackwardNavigation =
!currentState.historyState.timestamp || !globalObject.previousState.historyState.timestamp
!currentState.historyState?.timestamp || !globalObject.previousState.historyState?.timestamp
? null
: currentState.historyState.timestamp < globalObject.previousState.historyState.timestamp

Expand All @@ -40,7 +38,6 @@ function onBrowserHistoryNavigation() {
// - `history.state` is uninitialized (`null`) when:
// - The user's code runs `window.location.hash = '#section'`.
// - The user clicks on an anchor link `<a href="#section">Section</a>` (because Vike's `initOnLinkClick()` handler skips hash links).
// - `history.state` is `null` when uninitialized: https://developer.mozilla.org/en-US/docs/Web/API/History/state
// - Alternatively, we completely take over hash navigation and reproduce the browser's native behavior upon hash navigation.
// - Problem: we cannot intercept `window.location.hash = '#section'`. (Or maybe we can with the `hashchange` event?)
// - Other potential problem: would there be a conflict when the user wants to override the browser's default behavior? E.g. for smooth scrolling, or when using hashes for saving states of some fancy animations.
Expand Down

0 comments on commit b90ac66

Please sign in to comment.