manages browsing session history
jsr.io/@mary/history
typescript
jsr
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};