+4
.vscode/settings.json
+4
.vscode/settings.json
+40
.zed/settings.json
+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
+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
+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
+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
+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
+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
+
};