manages browsing session history jsr.io/@mary/history
typescript jsr

initial commit

mary.my.id 0e723a2d

+4
.vscode/settings.json
··· 1 + { 2 + "editor.defaultFormatter": "denoland.vscode-deno", 3 + "deno.enable": true 4 + }
+40
.zed/settings.json
··· 1 + { 2 + "lsp": { 3 + "deno": { 4 + "settings": { 5 + "deno": { 6 + "enable": true 7 + } 8 + } 9 + } 10 + }, 11 + "languages": { 12 + "JavaScript": { 13 + "language_servers": [ 14 + "deno", 15 + "!typescript-language-server", 16 + "!vtsls", 17 + "!eslint" 18 + ], 19 + "formatter": "language_server" 20 + }, 21 + "TypeScript": { 22 + "language_servers": [ 23 + "deno", 24 + "!typescript-language-server", 25 + "!vtsls", 26 + "!eslint" 27 + ], 28 + "formatter": "language_server" 29 + }, 30 + "TSX": { 31 + "language_servers": [ 32 + "deno", 33 + "!typescript-language-server", 34 + "!vtsls", 35 + "!eslint" 36 + ], 37 + "formatter": "language_server" 38 + } 39 + } 40 + }
+14
LICENSE
··· 1 + BSD Zero Clause License 2 + 3 + Copyright (c) 2025 Mary 4 + 5 + Permission to use, copy, modify, and/or distribute this software for any 6 + purpose with or without fee is hereby granted. 7 + 8 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 10 + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 12 + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 13 + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 14 + PERFORMANCE OF THIS SOFTWARE.
+19
README.md
··· 1 + # history 2 + 3 + Manages browsing session history 4 + 5 + ```ts 6 + const history = createBrowserHistory(); 7 + 8 + history.listen(({ action, location }) => { 9 + console.log(action, location.pathname); 10 + }); 11 + 12 + history.navigate('/dashboard?tab=stats#top'); 13 + // action: "push", location.pathname: "/dashboard" 14 + 15 + history.update({ theme: 'dark' }); 16 + // action: "update" 17 + 18 + history.back(); 19 + ```
+24
deno.json
··· 1 + { 2 + "name": "@mary/history", 3 + "version": "0.1.0", 4 + "license": "0BSD", 5 + "exports": "./lib/mod.ts", 6 + "fmt": { 7 + "useTabs": true, 8 + "indentWidth": 2, 9 + "lineWidth": 110, 10 + "semiColons": true, 11 + "singleQuote": true 12 + }, 13 + "publish": { 14 + "include": ["lib/", "LICENSE", "README.md", "deno.json"] 15 + }, 16 + "imports": { 17 + "@mary/events": "jsr:@mary/events@^0.2.0", 18 + "esm-env": "npm:esm-env@^1.2.2", 19 + "nanoid": "npm:nanoid@^5.1.5" 20 + }, 21 + "compilerOptions": { 22 + "lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"] 23 + } 24 + }
+29
deno.lock
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@mary/events@0.2": "0.2.0", 5 + "npm:esm-env@^1.2.2": "1.2.2", 6 + "npm:nanoid@^5.1.5": "5.1.5" 7 + }, 8 + "jsr": { 9 + "@mary/events@0.2.0": { 10 + "integrity": "39fd9f4022bfb3bda038655de1d95c7e4b3eee0f99128e822ab42afefb12c4f1" 11 + } 12 + }, 13 + "npm": { 14 + "esm-env@1.2.2": { 15 + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" 16 + }, 17 + "nanoid@5.1.5": { 18 + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", 19 + "bin": true 20 + } 21 + }, 22 + "workspace": { 23 + "dependencies": [ 24 + "jsr:@mary/events@0.2", 25 + "npm:esm-env@^1.2.2", 26 + "npm:nanoid@^5.1.5" 27 + ] 28 + } 29 + }
+421
lib/mod.ts
··· 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 + 8 + import { DEV } from 'esm-env'; 9 + import { nanoid } from 'nanoid/non-secure'; 10 + 11 + import { EventEmitter } from '@mary/events'; 12 + 13 + /** 14 + * kind of navigation action performed on the history stack. 15 + */ 16 + export type Action = 'traverse' | 'push' | 'replace' | 'update'; 17 + 18 + /** 19 + * components of a URL path. 20 + */ 21 + export interface Path { 22 + /** A URL pathname, beginning with a /. */ 23 + pathname: string; 24 + /** A URL search string, beginning with a ?. */ 25 + search: string; 26 + /** A URL fragment identifier, beginning with a #. */ 27 + hash: string; 28 + } 29 + 30 + /** 31 + * location entry stored by the history, including position, state and key. 32 + */ 33 + export interface Location extends Path { 34 + /** Position of this history */ 35 + index: number; 36 + /** A value of arbitrary data associated with this location. */ 37 + state: unknown; 38 + /** A unique string associated with this location */ 39 + key: string; 40 + } 41 + 42 + /** 43 + * update dispatched to listeners when the history changes. 44 + */ 45 + export interface Update { 46 + action: Action; 47 + location: Location; 48 + } 49 + 50 + /** 51 + * listener callback for history updates. 52 + * @param update update information for the navigation 53 + */ 54 + export type Listener = (update: Update) => void; 55 + 56 + /** 57 + * transition passed to blockers to allow retrying or inspecting the update. 58 + */ 59 + export interface Transition extends Update { 60 + retry(): void; 61 + } 62 + 63 + /** 64 + * blocker callback to intercept a transition and optionally defer it. 65 + * @param tx transition information and retry capability 66 + */ 67 + export type Blocker = (tx: Transition) => void; 68 + 69 + /** 70 + * destination for navigation, either a full string path or parts of a path. 71 + */ 72 + export type To = string | Partial<Path>; 73 + 74 + /** 75 + * public interface for interacting with browser-like history. 76 + */ 77 + export interface History { 78 + readonly location: Location; 79 + 80 + /** 81 + * navigates to a new location. 82 + * @param to destination to navigate to 83 + * @param options navigation options 84 + */ 85 + navigate(to: To, options?: NavigateOptions): void; 86 + /** 87 + * updates the state of the current location without changing the URL. 88 + * @param state arbitrary state to associate with the location 89 + */ 90 + update(state: unknown): void; 91 + 92 + /** 93 + * moves the history pointer by the given delta. 94 + * @param delta positive or negative number of entries to move 95 + */ 96 + go(delta: number): void; 97 + /** 98 + * navigates one entry back. 99 + */ 100 + back(): void; 101 + /** 102 + * navigates one entry forward. 103 + */ 104 + forward(): void; 105 + 106 + /** 107 + * subscribes to history updates. 108 + * @param listener callback to invoke on updates 109 + * @returns unsubscribe function 110 + */ 111 + listen(listener: Listener): () => void; 112 + /** 113 + * registers a blocker to intercept transitions. 114 + * @param blocker callback to invoke with transition info 115 + * @returns unblock function 116 + */ 117 + block(blocker: Blocker): () => void; 118 + } 119 + 120 + /** 121 + * options for navigation. 122 + */ 123 + export interface NavigateOptions { 124 + replace?: boolean; 125 + state?: unknown; 126 + } 127 + 128 + /** 129 + * A browser history stores the current location in regular URLs in a web 130 + * browser environment. This is the standard for most web apps and provides the 131 + * cleanest URLs the browser's address bar. 132 + */ 133 + export interface BrowserHistory extends History {} 134 + 135 + const warning = (cond: unknown, message: string) => { 136 + if (DEV && !cond) { 137 + console.warn(message); 138 + } 139 + }; 140 + 141 + interface HistoryState { 142 + usr: unknown; 143 + key?: string; 144 + idx: number; 145 + } 146 + 147 + const BeforeUnloadEventType = 'beforeunload'; 148 + const PopStateEventType = 'popstate'; 149 + 150 + /** 151 + * options for creating a browser history instance. 152 + */ 153 + export interface BrowserHistoryOptions { 154 + window?: Window; 155 + } 156 + 157 + /** 158 + * browser history stores the location in regular URLs. this is the standard for 159 + * most web apps, but it requires configuration on the server to serve the same 160 + * app at multiple URLs. 161 + * @param options options for the browser history 162 + * @returns a browser history instance 163 + */ 164 + export const createBrowserHistory = (options: BrowserHistoryOptions = {}): BrowserHistory => { 165 + const { window = document.defaultView! } = options; 166 + const globalHistory = window.history; 167 + 168 + const getCurrentLocation = (): Location => { 169 + const { pathname, search, hash } = window.location; 170 + const state = globalHistory.state || {}; 171 + return { 172 + pathname, 173 + search, 174 + hash, 175 + index: state.idx, 176 + state: state.usr || null, 177 + key: state.key || 'default', 178 + }; 179 + }; 180 + 181 + let blockedPopTx: Transition | null = null; 182 + const handlePop = () => { 183 + if (blockedPopTx) { 184 + emitter.emit('block', blockedPopTx); 185 + blockedPopTx = null; 186 + } else { 187 + const nextAction: Action = 'traverse'; 188 + const nextLocation = getCurrentLocation(); 189 + const nextIndex = nextLocation.index; 190 + 191 + if (emitter.has('block')) { 192 + if (nextIndex != null) { 193 + const delta = location.index - nextIndex; 194 + if (delta) { 195 + // Revert the POP 196 + blockedPopTx = { 197 + action: nextAction, 198 + location: nextLocation, 199 + retry() { 200 + go(delta * -1); 201 + }, 202 + }; 203 + 204 + go(delta); 205 + } 206 + } else { 207 + // Trying to POP to a location with no index. We did not create 208 + // this location, so we can't effectively block the navigation. 209 + warning( 210 + false, 211 + // TODO: Write up a doc that explains our blocking strategy in 212 + // detail and link to it here so people can understand better what 213 + // is going on and how to avoid it. 214 + `You are trying to block a POP navigation to a location that was not ` + 215 + `created by the history library. The block will fail silently in ` + 216 + `production, but in general you should do all navigation with the ` + 217 + `history library (instead of using window.history.pushState directly) ` + 218 + `to avoid this situation.`, 219 + ); 220 + } 221 + } else { 222 + applyTx(nextAction); 223 + } 224 + } 225 + }; 226 + 227 + const emitter = new EventEmitter<{ 228 + update: [evt: Update]; 229 + block: [tx: Transition]; 230 + }>(); 231 + 232 + let location = getCurrentLocation(); 233 + 234 + window.addEventListener(PopStateEventType, handlePop); 235 + 236 + if (location.index == null) { 237 + globalHistory.replaceState({ ...globalHistory.state, idx: (location.index = 0) }, ''); 238 + } 239 + 240 + // state defaults to `null` because `window.history.state` does 241 + const getNextLocation = (to: To, index: number, state: unknown = null): Location => { 242 + return { 243 + pathname: location.pathname, 244 + hash: '', 245 + search: '', 246 + ...(typeof to === 'string' ? parsePath(to) : to), 247 + index, 248 + state, 249 + key: createKey(), 250 + }; 251 + }; 252 + 253 + const getHistoryStateAndUrl = (nextLocation: Location): [HistoryState, string] => { 254 + return [ 255 + { 256 + usr: nextLocation.state, 257 + key: nextLocation.key, 258 + idx: nextLocation.index, 259 + }, 260 + createHref(nextLocation), 261 + ]; 262 + }; 263 + 264 + const allowTx = (action: Action, location: Location, retry: () => void): boolean => { 265 + return !emitter.emit('block', { action, location, retry }); 266 + }; 267 + 268 + const applyTx = (nextAction: Action): void => { 269 + location = getCurrentLocation(); 270 + emitter.emit('update', { action: nextAction, location }); 271 + }; 272 + 273 + const navigate = (to: To, { replace, state }: NavigateOptions = {}): void => { 274 + const nextAction: Action = !replace ? 'push' : 'replace'; 275 + const nextIndex = location.index + (!replace ? 1 : 0); 276 + const nextLocation = getNextLocation(to, nextIndex, state); 277 + 278 + const retry = () => { 279 + navigate(to, { replace, state }); 280 + }; 281 + 282 + if (allowTx(nextAction, nextLocation, retry)) { 283 + const [historyState, url] = getHistoryStateAndUrl(nextLocation); 284 + 285 + // TODO: Support forced reloading 286 + if (!replace) { 287 + // try...catch because iOS limits us to 100 pushState calls :/ 288 + try { 289 + globalHistory.pushState(historyState, '', url); 290 + } catch { 291 + // They are going to lose state here, but there is no real 292 + // way to warn them about it since the page will refresh... 293 + window.location.assign(url); 294 + } 295 + } else { 296 + globalHistory.replaceState(historyState, '', url); 297 + } 298 + 299 + applyTx(nextAction); 300 + } 301 + }; 302 + 303 + const update = (state: unknown): void => { 304 + const nextAction: Action = 'update'; 305 + const nextLocation = { ...location, state }; 306 + 307 + const [historyState, url] = getHistoryStateAndUrl(nextLocation); 308 + 309 + // TODO: Support forced reloading 310 + globalHistory.replaceState(historyState, '', url); 311 + 312 + applyTx(nextAction); 313 + }; 314 + 315 + const go = (delta: number): void => { 316 + globalHistory.go(delta); 317 + }; 318 + 319 + const history: BrowserHistory = { 320 + get location() { 321 + return location; 322 + }, 323 + navigate, 324 + update, 325 + go, 326 + back: () => { 327 + return go(-1); 328 + }, 329 + forward: () => { 330 + return go(1); 331 + }, 332 + listen: (listener) => { 333 + return emitter.on('update', listener); 334 + }, 335 + block: (blocker) => { 336 + if (!emitter.has('block')) { 337 + window.addEventListener(BeforeUnloadEventType, promptBeforeUnload); 338 + } 339 + 340 + const unblock = emitter.on('block', blocker); 341 + 342 + return () => { 343 + unblock(); 344 + 345 + // Remove the beforeunload listener so the document may 346 + // still be salvageable in the pagehide event. 347 + // See https://html.spec.whatwg.org/#unloading-documents 348 + if (!emitter.has('block')) { 349 + window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload); 350 + } 351 + }; 352 + }, 353 + }; 354 + 355 + return history; 356 + }; 357 + 358 + const promptBeforeUnload = (event: BeforeUnloadEvent): void => { 359 + // Cancel the event. 360 + event.preventDefault(); 361 + }; 362 + 363 + const createKey = () => { 364 + return nanoid(); 365 + }; 366 + 367 + /** 368 + * creates an href string for a destination. strings are returned as-is while 369 + * partial paths are composed into a string via createPath. 370 + * @param to destination to convert to href 371 + * @returns href string 372 + */ 373 + export const createHref = (to: To): string => { 374 + return typeof to === 'string' ? to : createPath(to); 375 + }; 376 + 377 + /** 378 + * creates a string URL path from the given pathname, search, and hash components. 379 + * @param pathname path component beginning with '/' 380 + * @param search search component beginning with '?' 381 + * @param hash hash component beginning with '#' 382 + * @returns the composed path string 383 + */ 384 + export const createPath = ({ pathname = '/', search = '', hash = '' }: Partial<Path>): string => { 385 + if (search && search !== '?') { 386 + pathname += search.charAt(0) === '?' ? search : '?' + search; 387 + } 388 + if (hash && hash !== '#') { 389 + pathname += hash.charAt(0) === '#' ? hash : '#' + hash; 390 + } 391 + return pathname; 392 + }; 393 + 394 + /** 395 + * parses a string URL path into its pathname, search, and hash components. 396 + * @param path input path string to parse 397 + * @returns the parsed path components 398 + */ 399 + export const parsePath = (path: string): Partial<Path> => { 400 + const parsedPath: Partial<Path> = {}; 401 + 402 + if (path) { 403 + const hashIndex = path.indexOf('#'); 404 + if (hashIndex >= 0) { 405 + parsedPath.hash = path.slice(hashIndex); 406 + path = path.slice(0, hashIndex); 407 + } 408 + 409 + const searchIndex = path.indexOf('?'); 410 + if (searchIndex >= 0) { 411 + parsedPath.search = path.slice(searchIndex); 412 + path = path.slice(0, searchIndex); 413 + } 414 + 415 + if (path) { 416 + parsedPath.pathname = path; 417 + } 418 + } 419 + 420 + return parsedPath; 421 + };