Skip to content

Instantly share code, notes, and snippets.

@mfbx9da4
Last active June 3, 2021 08:58
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mfbx9da4/f7ac743fe630bfe9acb8486b3caa22b9 to your computer and use it in GitHub Desktop.
Save mfbx9da4/f7ac743fe630bfe9acb8486b3caa22b9 to your computer and use it in GitHub Desktop.
import isNil from 'lodash/isNil'
import { createPubSub } from 'https://gist.githubusercontent.com/mfbx9da4/896c0fa0439da9f87aab1883ec586b30/raw/808023dd8a1c014ebe81ff4d17d2748fddf8dc62/createPubSub.ts'
import { uuid } from 'short-uuid'
import { isMainThread } from 'https://gist.githubusercontent.com/mfbx9da4/de9e6a398d1847a0d11e5a20256bd0d9/raw/f8979aa8b241e778996cfda71398b42af0994202/isMainThread.ts'
import { createDeferredPromise } from 'https://gist.githubusercontent.com/mfbx9da4/76baa3051bbd044de18d1c1ac0c254dd/raw/853e5169f69a0c1cefe12816ff1ba25b36c1d366/createDeferredPromise.ts'
/**
* The goal of this module is to keep track of the navigation history
* in a single page app. It monitors `push()`, `back()`, `forward()` and
* `replace()` navigation events. It offers a number of features that
* the native history API does not support:
* - View the current stack of navigation entries. (`History.values()`)
* - Subscribe to back and or forward events separately. (`History.onBack()`)
* - back() and forward() promisified.
* - Query the stack for a given URL and navigate to it at any depth.
* (`History.goBackTo('/')`)
* The history will be preserved between reloads by virtue of SessionStorage.
* e.g. `location.reload()`
*
* Out of scope:
* Navigation events which will cause a full page reload will wipe the history stack.
* In this way tracking multi-page-app navigation is not supported. For example,
* use of `location.href = '/'` will result in the session stack being wiped.
*/
export interface HistoryEntry {
state: any
title: string
url: string | null | undefined
}
export interface HistoryState {
stack: HistoryEntry[]
index: number
}
export interface PopEvent extends HistoryEntry {
event: PopStateEvent
}
const getCurrentUrl = () =>
isMainThread() ? `${location.pathname}${location.search}${location.hash}` : ''
const wasReloaded = isMainThread()
? (window.performance.navigation && window.performance.navigation.type === 1) ||
window.performance
.getEntriesByType('navigation')
// @ts-ignore
.map(nav => nav.type)
.includes('reload')
: false
const historyId = '__historyId__'
const storageKey = '__history__'
const restore = (): HistoryState => {
try {
const data = sessionStorage.getItem(storageKey)
const { stack, index } = JSON.parse(data || '')
if (wasReloaded && !isNil(stack) && Array.isArray(stack) && !isNil(index)) {
// If it was just a reload we can preserve the history.
// For now, there isn't a simple and reliable way of working out if we were navigated to
// from a url on the same origin. It would require intercepting anchor tags,
// forms, location.href and window.open.
return { stack, index }
}
} catch {} // eslint-disable-line
return { stack: [], index: -1 }
}
const persist = (state: HistoryState) => sessionStorage.setItem(storageKey, JSON.stringify(state))
const clear = () => sessionStorage.removeItem(storageKey)
let historyState = restore()
const backPubSub = createPubSub<PopEvent>()
const forwardPubSub = createPubSub<PopEvent>()
const onPush = (state: any, title: string, url: string | null | undefined) => {
historyState.stack.push(Object.freeze({ state, title, url }))
historyState.index++
persist(historyState)
}
const onReplace = (state: any, title: string, url: string | null | undefined) => {
historyState.stack[historyState.index] = Object.freeze({ state, title, url })
persist(historyState)
}
const onPopState = (event: PopStateEvent) => {
const poppedId = event.state && event.state[historyId]
if (!poppedId) {
clear()
historyState = { stack: [], index: -1 }
initCurrentEntry()
return
}
// TODO: Enhancement: We can preserve the session history between
// back and forward navigations which trigger reloads by setting
// a flag in the SessionStorage: "expectedNextUrl" and reconciling
// on the other side.
const prev = historyState.stack[historyState.index - 1]
const prevId = prev?.state?.[historyId]
const next = historyState.stack[historyState.index + 1]
const nextId = next?.state?.[historyId]
if (poppedId === prevId) {
historyState.index--
backPubSub.pub({ ...historyState.stack[historyState.index], event })
} else if (poppedId === nextId) {
historyState.index++
forwardPubSub.pub({ ...historyState.stack[historyState.index], event })
}
persist(historyState)
}
const initCurrentEntry = () => {
const dummyEntry = { state: {}, title: '', url: '' }
historyState.index = 0
historyState.stack.push(dummyEntry)
history.replaceState(history.state, '', getCurrentUrl())
}
const init = () => {
if (!window.history) return
// wrap history.pushState and monitor invocations
const pushStateOriginal = history.pushState.bind(history)
history.pushState = (...args) => {
const [state, ...rest] = args
const newState = { [historyId]: uuid(), ...state }
const newArgs = [newState, ...rest] as const
onPush(...newArgs)
pushStateOriginal(...newArgs)
}
// wrap history.replaceState and monitor invocations
const replaceStateOriginal = history.replaceState.bind(history)
history.replaceState = (...args) => {
const [state, ...rest] = args
const newState = { [historyId]: uuid(), ...state }
const newArgs = [newState, ...rest] as const
onReplace(...newArgs)
replaceStateOriginal(...newArgs)
}
window.addEventListener('popstate', onPopState)
if (historyState.index === -1) initCurrentEntry()
}
const initializedFlag = Symbol('history_initialized')
// @ts-ignore
const isInitialized = () => Boolean(window[initializedFlag])
// @ts-ignore
const setInitialized = () => (window[initializedFlag] = true)
if (isMainThread() && !isInitialized()) {
// singleton style
init()
setInitialized()
}
const backIndexOf = (path: string) => {
for (let i = historyState.index; i > -1; i--) {
const entry = historyState.stack[i]
if (entry.url === path) return i
}
return -1
}
const forwardIndexOf = (path: string) => {
for (let i = 0; i < historyState.stack.length; i++) {
const entry = historyState.stack[i]
if (entry.url === path) return i
}
return -1
}
const canGoBackTo = (path: string) => backIndexOf(path) > -1
const canGoForwardTo = (path: string) => forwardIndexOf(path) > -1
const backForward = async (backOrForward: 'back' | 'forward') => {
const deferred = createDeferredPromise<0>()
const timeout = setTimeout(() => deferred.reject(new Error('Timeout')), 10000)
window.addEventListener('popstate', () => {
clearTimeout(timeout)
deferred.resolve(0)
})
if (backOrForward === 'back') {
history.back()
} else {
history.forward()
}
await deferred.promise
}
const back = () => backForward('back')
const forward = () => backForward('forward')
const goBackTo = async (path: string) => {
const targetIndex = backIndexOf(path)
for (let i = historyState.index; i > targetIndex; i--) {
await back()
}
}
const goForwardTo = async (path: string) => {
const targetIndex = forwardIndexOf(path)
for (let i = historyState.index; i < targetIndex; i++) {
await forward()
}
}
const onBack = (fn: (x: PopEvent) => void) => backPubSub.sub(fn)
const onForward = (fn: (x: PopEvent) => void) => forwardPubSub.sub(fn)
const History = {
backIndexOf,
forwardIndexOf,
canGoBackTo,
canGoForwardTo,
goBackTo,
goForwardTo,
onBack,
onForward,
back,
forward,
get index() {
return historyState.index
},
values: () => Object.freeze([...historyState.stack]),
}
export { History }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment