manages browsing session history jsr.io/@mary/history
typescript jsr
at trunk 11 kB view raw
1// fork of `history` npm package 2// repository: github.com/remix-run/history 3// commit: 3e9dab413f4eda8d6bce565388c5ddb7aeff9f7e 4 5// most of the changes are just trimming it down to only include the browser 6// history implementation. 7 8import { DEV } from 'esm-env'; 9 10import { EventEmitter } from '@mary/events'; 11 12/** 13 * kind of navigation action performed on the history stack. 14 */ 15export type Action = 'traverse' | 'push' | 'replace' | 'update'; 16 17/** 18 * components of a URL path. 19 */ 20export interface Path { 21 /** a URL pathname, beginning with a /. */ 22 pathname: string; 23 /** a URL search string, beginning with a ?. */ 24 search: string; 25 /** a URL fragment identifier, beginning with a #. */ 26 hash: string; 27} 28 29/** 30 * location entry stored by the history, including position, state and key. 31 */ 32export interface Location extends Path { 33 /** position of this history */ 34 index: number; 35 /** a value of arbitrary data associated with this location. */ 36 state: unknown; 37 /** a unique identifier for the specific history entry */ 38 id: string; 39 /** a unique identifier for the history entry's slot in the entries list */ 40 key: string; 41} 42 43/** 44 * update dispatched to listeners when the history changes. 45 */ 46export interface Update { 47 action: Action; 48 location: Location; 49} 50 51/** 52 * listener callback for history updates. 53 * @param update update information for the navigation 54 */ 55export type Listener = (update: Update) => void; 56 57/** 58 * transition passed to blockers to allow retrying or inspecting the update. 59 */ 60export interface Transition extends Update { 61 retry(): void; 62} 63 64/** 65 * blocker callback to intercept a transition and optionally defer it. 66 * @param tx transition information and retry capability 67 */ 68export type Blocker = (tx: Transition) => void; 69 70/** 71 * destination for navigation, either a full string path or parts of a path. 72 */ 73export type To = string | Partial<Path>; 74 75/** 76 * interface for interacting with the browser history. 77 */ 78export interface BrowserHistory { 79 readonly location: Location; 80 81 /** 82 * navigates to a new location. 83 * @param to destination to navigate to 84 * @param options navigation options 85 */ 86 navigate(to: To, options?: NavigateOptions): void; 87 /** 88 * updates the state of the current location without changing the URL. 89 * @param state arbitrary state to associate with the location 90 */ 91 update(state: unknown): void; 92 93 /** 94 * moves the history pointer by the given delta. 95 * @param delta positive or negative number of entries to move 96 */ 97 go(delta: number): void; 98 /** 99 * navigates one entry back. 100 */ 101 back(): void; 102 /** 103 * navigates one entry forward. 104 */ 105 forward(): void; 106 107 /** 108 * subscribes to history updates. 109 * @param listener callback to invoke on updates 110 * @returns unsubscribe function 111 */ 112 listen(listener: Listener): () => void; 113 /** 114 * registers a blocker to intercept transitions. 115 * @param blocker callback to invoke with transition info 116 * @returns unblock function 117 */ 118 block(blocker: Blocker): () => void; 119} 120 121/** 122 * options for navigation. 123 */ 124export interface NavigateOptions { 125 replace?: boolean; 126 state?: unknown; 127} 128 129const warning = (cond: unknown, message: string) => { 130 if (DEV && !cond) { 131 console.warn(message); 132 } 133}; 134 135interface HistoryState { 136 usr: unknown; 137 id?: string; 138 key?: string; 139 idx: number; 140} 141 142const BeforeUnloadEventType = 'beforeunload'; 143const PopStateEventType = 'popstate'; 144 145/** 146 * options for creating a browser history instance. 147 */ 148export interface BrowserHistoryOptions { 149 window?: Window; 150} 151 152/** 153 * browser history stores the location in regular URLs. this is the standard for 154 * most web apps, but it requires configuration on the server to serve the same 155 * app at multiple URLs. 156 * @param options options for the browser history 157 * @returns a browser history instance 158 */ 159export const createBrowserHistory = (options: BrowserHistoryOptions = {}): BrowserHistory => { 160 const { window = document.defaultView! } = options; 161 const globalHistory = window.history; 162 163 const getCurrentLocation = (): Location => { 164 const { pathname, search, hash } = window.location; 165 const state = globalHistory.state || {}; 166 return { 167 pathname, 168 search, 169 hash, 170 index: state.idx, 171 state: state.usr || null, 172 id: state.id || 'default', 173 key: state.key || 'default', 174 }; 175 }; 176 177 let blockedPopTx: Transition | null = null; 178 const handlePop = () => { 179 if (blockedPopTx) { 180 emitter.emit('block', blockedPopTx); 181 blockedPopTx = null; 182 } else { 183 const nextAction: Action = 'traverse'; 184 const nextLocation = getCurrentLocation(); 185 const nextIndex = nextLocation.index; 186 187 if (emitter.has('block')) { 188 if (nextIndex != null) { 189 const delta = location.index - nextIndex; 190 if (delta) { 191 // Revert the POP 192 blockedPopTx = { 193 action: nextAction, 194 location: nextLocation, 195 retry() { 196 go(delta * -1); 197 }, 198 }; 199 200 go(delta); 201 } 202 } else { 203 // Trying to POP to a location with no index. We did not create 204 // this location, so we can't effectively block the navigation. 205 warning( 206 false, 207 // TODO: Write up a doc that explains our blocking strategy in 208 // detail and link to it here so people can understand better what 209 // is going on and how to avoid it. 210 `You are trying to block a POP navigation to a location that was not ` + 211 `created by the history library. The block will fail silently in ` + 212 `production, but in general you should do all navigation with the ` + 213 `history library (instead of using window.history.pushState directly) ` + 214 `to avoid this situation.`, 215 ); 216 } 217 } else { 218 applyTx(nextAction); 219 } 220 } 221 }; 222 223 const emitter = new EventEmitter<{ 224 update: [evt: Update]; 225 block: [tx: Transition]; 226 }>(); 227 228 let location = getCurrentLocation(); 229 230 window.addEventListener(PopStateEventType, handlePop); 231 232 if (location.index == null) { 233 globalHistory.replaceState({ ...globalHistory.state, idx: (location.index = 0) }, ''); 234 } 235 236 const getNextLocation = (replace: boolean, to: To, index: number, state: unknown): Location => { 237 return { 238 pathname: location.pathname, 239 hash: '', 240 search: '', 241 ...(typeof to === 'string' ? parsePath(to) : to), 242 index, 243 state, 244 id: randomId(), 245 key: replace ? location.key : randomId(), 246 }; 247 }; 248 249 const getHistoryStateAndUrl = (nextLocation: Location): [HistoryState, string] => { 250 return [ 251 { 252 usr: nextLocation.state, 253 id: nextLocation.id, 254 key: nextLocation.key, 255 idx: nextLocation.index, 256 }, 257 createHref(nextLocation), 258 ]; 259 }; 260 261 const allowTx = (action: Action, location: Location, retry: () => void): boolean => { 262 return !emitter.emit('block', { action, location, retry }); 263 }; 264 265 const applyTx = (nextAction: Action): void => { 266 location = getCurrentLocation(); 267 emitter.emit('update', { action: nextAction, location }); 268 }; 269 270 const navigate = (to: To, { replace = false, state = null }: NavigateOptions = {}): void => { 271 const nextAction: Action = !replace ? 'push' : 'replace'; 272 const nextIndex = location.index + (!replace ? 1 : 0); 273 const nextLocation = getNextLocation(replace, to, nextIndex, state); 274 275 const retry = () => { 276 navigate(to, { replace, state }); 277 }; 278 279 if (allowTx(nextAction, nextLocation, retry)) { 280 const [historyState, url] = getHistoryStateAndUrl(nextLocation); 281 282 // TODO: Support forced reloading 283 if (!replace) { 284 // try...catch because iOS limits us to 100 pushState calls :/ 285 try { 286 globalHistory.pushState(historyState, '', url); 287 } catch { 288 // They are going to lose state here, but there is no real 289 // way to warn them about it since the page will refresh... 290 window.location.assign(url); 291 } 292 } else { 293 globalHistory.replaceState(historyState, '', url); 294 } 295 296 applyTx(nextAction); 297 } 298 }; 299 300 const update = (state: unknown): void => { 301 const nextAction: Action = 'update'; 302 const nextLocation = { ...location, state }; 303 304 const [historyState, url] = getHistoryStateAndUrl(nextLocation); 305 306 // TODO: Support forced reloading 307 globalHistory.replaceState(historyState, '', url); 308 309 applyTx(nextAction); 310 }; 311 312 const go = (delta: number): void => { 313 globalHistory.go(delta); 314 }; 315 316 const history: BrowserHistory = { 317 get location() { 318 return location; 319 }, 320 navigate, 321 update, 322 go, 323 back: () => { 324 return go(-1); 325 }, 326 forward: () => { 327 return go(1); 328 }, 329 listen: (listener) => { 330 return emitter.on('update', listener); 331 }, 332 block: (blocker) => { 333 if (!emitter.has('block')) { 334 window.addEventListener(BeforeUnloadEventType, promptBeforeUnload); 335 } 336 337 const unblock = emitter.on('block', blocker); 338 339 return () => { 340 unblock(); 341 342 // Remove the beforeunload listener so the document may 343 // still be salvageable in the pagehide event. 344 // See https://html.spec.whatwg.org/#unloading-documents 345 if (!emitter.has('block')) { 346 window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload); 347 } 348 }; 349 }, 350 }; 351 352 return history; 353}; 354 355const promptBeforeUnload = (event: BeforeUnloadEvent): void => { 356 // Cancel the event. 357 event.preventDefault(); 358}; 359 360const randomId = () => { 361 return crypto.randomUUID(); 362}; 363 364/** 365 * creates an href string for a destination. strings are returned as-is while 366 * partial paths are composed into a string via createPath. 367 * @param to destination to convert to href 368 * @returns href string 369 */ 370export const createHref = (to: To): string => { 371 return typeof to === 'string' ? to : createPath(to); 372}; 373 374/** 375 * creates a string URL path from the given pathname, search, and hash components. 376 * @param pathname path component beginning with '/' 377 * @param search search component beginning with '?' 378 * @param hash hash component beginning with '#' 379 * @returns the composed path string 380 */ 381export const createPath = ({ pathname = '/', search = '', hash = '' }: Partial<Path>): string => { 382 if (search && search !== '?') { 383 pathname += search.charAt(0) === '?' ? search : '?' + search; 384 } 385 if (hash && hash !== '#') { 386 pathname += hash.charAt(0) === '#' ? hash : '#' + hash; 387 } 388 return pathname; 389}; 390 391/** 392 * parses a string URL path into its pathname, search, and hash components. 393 * @param path input path string to parse 394 * @returns the parsed path components 395 */ 396export const parsePath = (path: string): Partial<Path> => { 397 const parsedPath: Partial<Path> = {}; 398 399 if (path) { 400 const hashIndex = path.indexOf('#'); 401 if (hashIndex >= 0) { 402 parsedPath.hash = path.slice(hashIndex); 403 path = path.slice(0, hashIndex); 404 } 405 406 const searchIndex = path.indexOf('?'); 407 if (searchIndex >= 0) { 408 parsedPath.search = path.slice(searchIndex); 409 path = path.slice(0, searchIndex); 410 } 411 412 if (path) { 413 parsedPath.pathname = path; 414 } 415 } 416 417 return parsedPath; 418};