1 | ////////////////////////////////////////////////////////////////////////////////
|
---|
2 | //#region Types and Constants
|
---|
3 | ////////////////////////////////////////////////////////////////////////////////
|
---|
4 |
|
---|
5 | /**
|
---|
6 | * Actions represent the type of change to a location value.
|
---|
7 | */
|
---|
8 | export enum Action {
|
---|
9 | /**
|
---|
10 | * A POP indicates a change to an arbitrary index in the history stack, such
|
---|
11 | * as a back or forward navigation. It does not describe the direction of the
|
---|
12 | * navigation, only that the current index changed.
|
---|
13 | *
|
---|
14 | * Note: This is the default action for newly created history objects.
|
---|
15 | */
|
---|
16 | Pop = "POP",
|
---|
17 |
|
---|
18 | /**
|
---|
19 | * A PUSH indicates a new entry being added to the history stack, such as when
|
---|
20 | * a link is clicked and a new page loads. When this happens, all subsequent
|
---|
21 | * entries in the stack are lost.
|
---|
22 | */
|
---|
23 | Push = "PUSH",
|
---|
24 |
|
---|
25 | /**
|
---|
26 | * A REPLACE indicates the entry at the current index in the history stack
|
---|
27 | * being replaced by a new one.
|
---|
28 | */
|
---|
29 | Replace = "REPLACE",
|
---|
30 | }
|
---|
31 |
|
---|
32 | /**
|
---|
33 | * The pathname, search, and hash values of a URL.
|
---|
34 | */
|
---|
35 | export interface Path {
|
---|
36 | /**
|
---|
37 | * A URL pathname, beginning with a /.
|
---|
38 | */
|
---|
39 | pathname: string;
|
---|
40 |
|
---|
41 | /**
|
---|
42 | * A URL search string, beginning with a ?.
|
---|
43 | */
|
---|
44 | search: string;
|
---|
45 |
|
---|
46 | /**
|
---|
47 | * A URL fragment identifier, beginning with a #.
|
---|
48 | */
|
---|
49 | hash: string;
|
---|
50 | }
|
---|
51 |
|
---|
52 | // TODO: (v7) Change the Location generic default from `any` to `unknown` and
|
---|
53 | // remove Remix `useLocation` wrapper.
|
---|
54 |
|
---|
55 | /**
|
---|
56 | * An entry in a history stack. A location contains information about the
|
---|
57 | * URL path, as well as possibly some arbitrary state and a key.
|
---|
58 | */
|
---|
59 | export interface Location<State = any> extends Path {
|
---|
60 | /**
|
---|
61 | * A value of arbitrary data associated with this location.
|
---|
62 | */
|
---|
63 | state: State;
|
---|
64 |
|
---|
65 | /**
|
---|
66 | * A unique string associated with this location. May be used to safely store
|
---|
67 | * and retrieve data in some other storage API, like `localStorage`.
|
---|
68 | *
|
---|
69 | * Note: This value is always "default" on the initial location.
|
---|
70 | */
|
---|
71 | key: string;
|
---|
72 | }
|
---|
73 |
|
---|
74 | /**
|
---|
75 | * A change to the current location.
|
---|
76 | */
|
---|
77 | export interface Update {
|
---|
78 | /**
|
---|
79 | * The action that triggered the change.
|
---|
80 | */
|
---|
81 | action: Action;
|
---|
82 |
|
---|
83 | /**
|
---|
84 | * The new location.
|
---|
85 | */
|
---|
86 | location: Location;
|
---|
87 |
|
---|
88 | /**
|
---|
89 | * The delta between this location and the former location in the history stack
|
---|
90 | */
|
---|
91 | delta: number | null;
|
---|
92 | }
|
---|
93 |
|
---|
94 | /**
|
---|
95 | * A function that receives notifications about location changes.
|
---|
96 | */
|
---|
97 | export interface Listener {
|
---|
98 | (update: Update): void;
|
---|
99 | }
|
---|
100 |
|
---|
101 | /**
|
---|
102 | * Describes a location that is the destination of some navigation, either via
|
---|
103 | * `history.push` or `history.replace`. This may be either a URL or the pieces
|
---|
104 | * of a URL path.
|
---|
105 | */
|
---|
106 | export type To = string | Partial<Path>;
|
---|
107 |
|
---|
108 | /**
|
---|
109 | * A history is an interface to the navigation stack. The history serves as the
|
---|
110 | * source of truth for the current location, as well as provides a set of
|
---|
111 | * methods that may be used to change it.
|
---|
112 | *
|
---|
113 | * It is similar to the DOM's `window.history` object, but with a smaller, more
|
---|
114 | * focused API.
|
---|
115 | */
|
---|
116 | export interface History {
|
---|
117 | /**
|
---|
118 | * The last action that modified the current location. This will always be
|
---|
119 | * Action.Pop when a history instance is first created. This value is mutable.
|
---|
120 | */
|
---|
121 | readonly action: Action;
|
---|
122 |
|
---|
123 | /**
|
---|
124 | * The current location. This value is mutable.
|
---|
125 | */
|
---|
126 | readonly location: Location;
|
---|
127 |
|
---|
128 | /**
|
---|
129 | * Returns a valid href for the given `to` value that may be used as
|
---|
130 | * the value of an <a href> attribute.
|
---|
131 | *
|
---|
132 | * @param to - The destination URL
|
---|
133 | */
|
---|
134 | createHref(to: To): string;
|
---|
135 |
|
---|
136 | /**
|
---|
137 | * Returns a URL for the given `to` value
|
---|
138 | *
|
---|
139 | * @param to - The destination URL
|
---|
140 | */
|
---|
141 | createURL(to: To): URL;
|
---|
142 |
|
---|
143 | /**
|
---|
144 | * Encode a location the same way window.history would do (no-op for memory
|
---|
145 | * history) so we ensure our PUSH/REPLACE navigations for data routers
|
---|
146 | * behave the same as POP
|
---|
147 | *
|
---|
148 | * @param to Unencoded path
|
---|
149 | */
|
---|
150 | encodeLocation(to: To): Path;
|
---|
151 |
|
---|
152 | /**
|
---|
153 | * Pushes a new location onto the history stack, increasing its length by one.
|
---|
154 | * If there were any entries in the stack after the current one, they are
|
---|
155 | * lost.
|
---|
156 | *
|
---|
157 | * @param to - The new URL
|
---|
158 | * @param state - Data to associate with the new location
|
---|
159 | */
|
---|
160 | push(to: To, state?: any): void;
|
---|
161 |
|
---|
162 | /**
|
---|
163 | * Replaces the current location in the history stack with a new one. The
|
---|
164 | * location that was replaced will no longer be available.
|
---|
165 | *
|
---|
166 | * @param to - The new URL
|
---|
167 | * @param state - Data to associate with the new location
|
---|
168 | */
|
---|
169 | replace(to: To, state?: any): void;
|
---|
170 |
|
---|
171 | /**
|
---|
172 | * Navigates `n` entries backward/forward in the history stack relative to the
|
---|
173 | * current index. For example, a "back" navigation would use go(-1).
|
---|
174 | *
|
---|
175 | * @param delta - The delta in the stack index
|
---|
176 | */
|
---|
177 | go(delta: number): void;
|
---|
178 |
|
---|
179 | /**
|
---|
180 | * Sets up a listener that will be called whenever the current location
|
---|
181 | * changes.
|
---|
182 | *
|
---|
183 | * @param listener - A function that will be called when the location changes
|
---|
184 | * @returns unlisten - A function that may be used to stop listening
|
---|
185 | */
|
---|
186 | listen(listener: Listener): () => void;
|
---|
187 | }
|
---|
188 |
|
---|
189 | type HistoryState = {
|
---|
190 | usr: any;
|
---|
191 | key?: string;
|
---|
192 | idx: number;
|
---|
193 | };
|
---|
194 |
|
---|
195 | const PopStateEventType = "popstate";
|
---|
196 | //#endregion
|
---|
197 |
|
---|
198 | ////////////////////////////////////////////////////////////////////////////////
|
---|
199 | //#region Memory History
|
---|
200 | ////////////////////////////////////////////////////////////////////////////////
|
---|
201 |
|
---|
202 | /**
|
---|
203 | * A user-supplied object that describes a location. Used when providing
|
---|
204 | * entries to `createMemoryHistory` via its `initialEntries` option.
|
---|
205 | */
|
---|
206 | export type InitialEntry = string | Partial<Location>;
|
---|
207 |
|
---|
208 | export type MemoryHistoryOptions = {
|
---|
209 | initialEntries?: InitialEntry[];
|
---|
210 | initialIndex?: number;
|
---|
211 | v5Compat?: boolean;
|
---|
212 | };
|
---|
213 |
|
---|
214 | /**
|
---|
215 | * A memory history stores locations in memory. This is useful in stateful
|
---|
216 | * environments where there is no web browser, such as node tests or React
|
---|
217 | * Native.
|
---|
218 | */
|
---|
219 | export interface MemoryHistory extends History {
|
---|
220 | /**
|
---|
221 | * The current index in the history stack.
|
---|
222 | */
|
---|
223 | readonly index: number;
|
---|
224 | }
|
---|
225 |
|
---|
226 | /**
|
---|
227 | * Memory history stores the current location in memory. It is designed for use
|
---|
228 | * in stateful non-browser environments like tests and React Native.
|
---|
229 | */
|
---|
230 | export function createMemoryHistory(
|
---|
231 | options: MemoryHistoryOptions = {}
|
---|
232 | ): MemoryHistory {
|
---|
233 | let { initialEntries = ["/"], initialIndex, v5Compat = false } = options;
|
---|
234 | let entries: Location[]; // Declare so we can access from createMemoryLocation
|
---|
235 | entries = initialEntries.map((entry, index) =>
|
---|
236 | createMemoryLocation(
|
---|
237 | entry,
|
---|
238 | typeof entry === "string" ? null : entry.state,
|
---|
239 | index === 0 ? "default" : undefined
|
---|
240 | )
|
---|
241 | );
|
---|
242 | let index = clampIndex(
|
---|
243 | initialIndex == null ? entries.length - 1 : initialIndex
|
---|
244 | );
|
---|
245 | let action = Action.Pop;
|
---|
246 | let listener: Listener | null = null;
|
---|
247 |
|
---|
248 | function clampIndex(n: number): number {
|
---|
249 | return Math.min(Math.max(n, 0), entries.length - 1);
|
---|
250 | }
|
---|
251 | function getCurrentLocation(): Location {
|
---|
252 | return entries[index];
|
---|
253 | }
|
---|
254 | function createMemoryLocation(
|
---|
255 | to: To,
|
---|
256 | state: any = null,
|
---|
257 | key?: string
|
---|
258 | ): Location {
|
---|
259 | let location = createLocation(
|
---|
260 | entries ? getCurrentLocation().pathname : "/",
|
---|
261 | to,
|
---|
262 | state,
|
---|
263 | key
|
---|
264 | );
|
---|
265 | warning(
|
---|
266 | location.pathname.charAt(0) === "/",
|
---|
267 | `relative pathnames are not supported in memory history: ${JSON.stringify(
|
---|
268 | to
|
---|
269 | )}`
|
---|
270 | );
|
---|
271 | return location;
|
---|
272 | }
|
---|
273 |
|
---|
274 | function createHref(to: To) {
|
---|
275 | return typeof to === "string" ? to : createPath(to);
|
---|
276 | }
|
---|
277 |
|
---|
278 | let history: MemoryHistory = {
|
---|
279 | get index() {
|
---|
280 | return index;
|
---|
281 | },
|
---|
282 | get action() {
|
---|
283 | return action;
|
---|
284 | },
|
---|
285 | get location() {
|
---|
286 | return getCurrentLocation();
|
---|
287 | },
|
---|
288 | createHref,
|
---|
289 | createURL(to) {
|
---|
290 | return new URL(createHref(to), "http://localhost");
|
---|
291 | },
|
---|
292 | encodeLocation(to: To) {
|
---|
293 | let path = typeof to === "string" ? parsePath(to) : to;
|
---|
294 | return {
|
---|
295 | pathname: path.pathname || "",
|
---|
296 | search: path.search || "",
|
---|
297 | hash: path.hash || "",
|
---|
298 | };
|
---|
299 | },
|
---|
300 | push(to, state) {
|
---|
301 | action = Action.Push;
|
---|
302 | let nextLocation = createMemoryLocation(to, state);
|
---|
303 | index += 1;
|
---|
304 | entries.splice(index, entries.length, nextLocation);
|
---|
305 | if (v5Compat && listener) {
|
---|
306 | listener({ action, location: nextLocation, delta: 1 });
|
---|
307 | }
|
---|
308 | },
|
---|
309 | replace(to, state) {
|
---|
310 | action = Action.Replace;
|
---|
311 | let nextLocation = createMemoryLocation(to, state);
|
---|
312 | entries[index] = nextLocation;
|
---|
313 | if (v5Compat && listener) {
|
---|
314 | listener({ action, location: nextLocation, delta: 0 });
|
---|
315 | }
|
---|
316 | },
|
---|
317 | go(delta) {
|
---|
318 | action = Action.Pop;
|
---|
319 | let nextIndex = clampIndex(index + delta);
|
---|
320 | let nextLocation = entries[nextIndex];
|
---|
321 | index = nextIndex;
|
---|
322 | if (listener) {
|
---|
323 | listener({ action, location: nextLocation, delta });
|
---|
324 | }
|
---|
325 | },
|
---|
326 | listen(fn: Listener) {
|
---|
327 | listener = fn;
|
---|
328 | return () => {
|
---|
329 | listener = null;
|
---|
330 | };
|
---|
331 | },
|
---|
332 | };
|
---|
333 |
|
---|
334 | return history;
|
---|
335 | }
|
---|
336 | //#endregion
|
---|
337 |
|
---|
338 | ////////////////////////////////////////////////////////////////////////////////
|
---|
339 | //#region Browser History
|
---|
340 | ////////////////////////////////////////////////////////////////////////////////
|
---|
341 |
|
---|
342 | /**
|
---|
343 | * A browser history stores the current location in regular URLs in a web
|
---|
344 | * browser environment. This is the standard for most web apps and provides the
|
---|
345 | * cleanest URLs the browser's address bar.
|
---|
346 | *
|
---|
347 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#browserhistory
|
---|
348 | */
|
---|
349 | export interface BrowserHistory extends UrlHistory {}
|
---|
350 |
|
---|
351 | export type BrowserHistoryOptions = UrlHistoryOptions;
|
---|
352 |
|
---|
353 | /**
|
---|
354 | * Browser history stores the location in regular URLs. This is the standard for
|
---|
355 | * most web apps, but it requires some configuration on the server to ensure you
|
---|
356 | * serve the same app at multiple URLs.
|
---|
357 | *
|
---|
358 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#createbrowserhistory
|
---|
359 | */
|
---|
360 | export function createBrowserHistory(
|
---|
361 | options: BrowserHistoryOptions = {}
|
---|
362 | ): BrowserHistory {
|
---|
363 | function createBrowserLocation(
|
---|
364 | window: Window,
|
---|
365 | globalHistory: Window["history"]
|
---|
366 | ) {
|
---|
367 | let { pathname, search, hash } = window.location;
|
---|
368 | return createLocation(
|
---|
369 | "",
|
---|
370 | { pathname, search, hash },
|
---|
371 | // state defaults to `null` because `window.history.state` does
|
---|
372 | (globalHistory.state && globalHistory.state.usr) || null,
|
---|
373 | (globalHistory.state && globalHistory.state.key) || "default"
|
---|
374 | );
|
---|
375 | }
|
---|
376 |
|
---|
377 | function createBrowserHref(window: Window, to: To) {
|
---|
378 | return typeof to === "string" ? to : createPath(to);
|
---|
379 | }
|
---|
380 |
|
---|
381 | return getUrlBasedHistory(
|
---|
382 | createBrowserLocation,
|
---|
383 | createBrowserHref,
|
---|
384 | null,
|
---|
385 | options
|
---|
386 | );
|
---|
387 | }
|
---|
388 | //#endregion
|
---|
389 |
|
---|
390 | ////////////////////////////////////////////////////////////////////////////////
|
---|
391 | //#region Hash History
|
---|
392 | ////////////////////////////////////////////////////////////////////////////////
|
---|
393 |
|
---|
394 | /**
|
---|
395 | * A hash history stores the current location in the fragment identifier portion
|
---|
396 | * of the URL in a web browser environment.
|
---|
397 | *
|
---|
398 | * This is ideal for apps that do not control the server for some reason
|
---|
399 | * (because the fragment identifier is never sent to the server), including some
|
---|
400 | * shared hosting environments that do not provide fine-grained controls over
|
---|
401 | * which pages are served at which URLs.
|
---|
402 | *
|
---|
403 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#hashhistory
|
---|
404 | */
|
---|
405 | export interface HashHistory extends UrlHistory {}
|
---|
406 |
|
---|
407 | export type HashHistoryOptions = UrlHistoryOptions;
|
---|
408 |
|
---|
409 | /**
|
---|
410 | * Hash history stores the location in window.location.hash. This makes it ideal
|
---|
411 | * for situations where you don't want to send the location to the server for
|
---|
412 | * some reason, either because you do cannot configure it or the URL space is
|
---|
413 | * reserved for something else.
|
---|
414 | *
|
---|
415 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#createhashhistory
|
---|
416 | */
|
---|
417 | export function createHashHistory(
|
---|
418 | options: HashHistoryOptions = {}
|
---|
419 | ): HashHistory {
|
---|
420 | function createHashLocation(
|
---|
421 | window: Window,
|
---|
422 | globalHistory: Window["history"]
|
---|
423 | ) {
|
---|
424 | let {
|
---|
425 | pathname = "/",
|
---|
426 | search = "",
|
---|
427 | hash = "",
|
---|
428 | } = parsePath(window.location.hash.substr(1));
|
---|
429 |
|
---|
430 | // Hash URL should always have a leading / just like window.location.pathname
|
---|
431 | // does, so if an app ends up at a route like /#something then we add a
|
---|
432 | // leading slash so all of our path-matching behaves the same as if it would
|
---|
433 | // in a browser router. This is particularly important when there exists a
|
---|
434 | // root splat route (<Route path="*">) since that matches internally against
|
---|
435 | // "/*" and we'd expect /#something to 404 in a hash router app.
|
---|
436 | if (!pathname.startsWith("/") && !pathname.startsWith(".")) {
|
---|
437 | pathname = "/" + pathname;
|
---|
438 | }
|
---|
439 |
|
---|
440 | return createLocation(
|
---|
441 | "",
|
---|
442 | { pathname, search, hash },
|
---|
443 | // state defaults to `null` because `window.history.state` does
|
---|
444 | (globalHistory.state && globalHistory.state.usr) || null,
|
---|
445 | (globalHistory.state && globalHistory.state.key) || "default"
|
---|
446 | );
|
---|
447 | }
|
---|
448 |
|
---|
449 | function createHashHref(window: Window, to: To) {
|
---|
450 | let base = window.document.querySelector("base");
|
---|
451 | let href = "";
|
---|
452 |
|
---|
453 | if (base && base.getAttribute("href")) {
|
---|
454 | let url = window.location.href;
|
---|
455 | let hashIndex = url.indexOf("#");
|
---|
456 | href = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
---|
457 | }
|
---|
458 |
|
---|
459 | return href + "#" + (typeof to === "string" ? to : createPath(to));
|
---|
460 | }
|
---|
461 |
|
---|
462 | function validateHashLocation(location: Location, to: To) {
|
---|
463 | warning(
|
---|
464 | location.pathname.charAt(0) === "/",
|
---|
465 | `relative pathnames are not supported in hash history.push(${JSON.stringify(
|
---|
466 | to
|
---|
467 | )})`
|
---|
468 | );
|
---|
469 | }
|
---|
470 |
|
---|
471 | return getUrlBasedHistory(
|
---|
472 | createHashLocation,
|
---|
473 | createHashHref,
|
---|
474 | validateHashLocation,
|
---|
475 | options
|
---|
476 | );
|
---|
477 | }
|
---|
478 | //#endregion
|
---|
479 |
|
---|
480 | ////////////////////////////////////////////////////////////////////////////////
|
---|
481 | //#region UTILS
|
---|
482 | ////////////////////////////////////////////////////////////////////////////////
|
---|
483 |
|
---|
484 | /**
|
---|
485 | * @private
|
---|
486 | */
|
---|
487 | export function invariant(value: boolean, message?: string): asserts value;
|
---|
488 | export function invariant<T>(
|
---|
489 | value: T | null | undefined,
|
---|
490 | message?: string
|
---|
491 | ): asserts value is T;
|
---|
492 | export function invariant(value: any, message?: string) {
|
---|
493 | if (value === false || value === null || typeof value === "undefined") {
|
---|
494 | throw new Error(message);
|
---|
495 | }
|
---|
496 | }
|
---|
497 |
|
---|
498 | export function warning(cond: any, message: string) {
|
---|
499 | if (!cond) {
|
---|
500 | // eslint-disable-next-line no-console
|
---|
501 | if (typeof console !== "undefined") console.warn(message);
|
---|
502 |
|
---|
503 | try {
|
---|
504 | // Welcome to debugging history!
|
---|
505 | //
|
---|
506 | // This error is thrown as a convenience, so you can more easily
|
---|
507 | // find the source for a warning that appears in the console by
|
---|
508 | // enabling "pause on exceptions" in your JavaScript debugger.
|
---|
509 | throw new Error(message);
|
---|
510 | // eslint-disable-next-line no-empty
|
---|
511 | } catch (e) {}
|
---|
512 | }
|
---|
513 | }
|
---|
514 |
|
---|
515 | function createKey() {
|
---|
516 | return Math.random().toString(36).substr(2, 8);
|
---|
517 | }
|
---|
518 |
|
---|
519 | /**
|
---|
520 | * For browser-based histories, we combine the state and key into an object
|
---|
521 | */
|
---|
522 | function getHistoryState(location: Location, index: number): HistoryState {
|
---|
523 | return {
|
---|
524 | usr: location.state,
|
---|
525 | key: location.key,
|
---|
526 | idx: index,
|
---|
527 | };
|
---|
528 | }
|
---|
529 |
|
---|
530 | /**
|
---|
531 | * Creates a Location object with a unique key from the given Path
|
---|
532 | */
|
---|
533 | export function createLocation(
|
---|
534 | current: string | Location,
|
---|
535 | to: To,
|
---|
536 | state: any = null,
|
---|
537 | key?: string
|
---|
538 | ): Readonly<Location> {
|
---|
539 | let location: Readonly<Location> = {
|
---|
540 | pathname: typeof current === "string" ? current : current.pathname,
|
---|
541 | search: "",
|
---|
542 | hash: "",
|
---|
543 | ...(typeof to === "string" ? parsePath(to) : to),
|
---|
544 | state,
|
---|
545 | // TODO: This could be cleaned up. push/replace should probably just take
|
---|
546 | // full Locations now and avoid the need to run through this flow at all
|
---|
547 | // But that's a pretty big refactor to the current test suite so going to
|
---|
548 | // keep as is for the time being and just let any incoming keys take precedence
|
---|
549 | key: (to && (to as Location).key) || key || createKey(),
|
---|
550 | };
|
---|
551 | return location;
|
---|
552 | }
|
---|
553 |
|
---|
554 | /**
|
---|
555 | * Creates a string URL path from the given pathname, search, and hash components.
|
---|
556 | */
|
---|
557 | export function createPath({
|
---|
558 | pathname = "/",
|
---|
559 | search = "",
|
---|
560 | hash = "",
|
---|
561 | }: Partial<Path>) {
|
---|
562 | if (search && search !== "?")
|
---|
563 | pathname += search.charAt(0) === "?" ? search : "?" + search;
|
---|
564 | if (hash && hash !== "#")
|
---|
565 | pathname += hash.charAt(0) === "#" ? hash : "#" + hash;
|
---|
566 | return pathname;
|
---|
567 | }
|
---|
568 |
|
---|
569 | /**
|
---|
570 | * Parses a string URL path into its separate pathname, search, and hash components.
|
---|
571 | */
|
---|
572 | export function parsePath(path: string): Partial<Path> {
|
---|
573 | let parsedPath: Partial<Path> = {};
|
---|
574 |
|
---|
575 | if (path) {
|
---|
576 | let hashIndex = path.indexOf("#");
|
---|
577 | if (hashIndex >= 0) {
|
---|
578 | parsedPath.hash = path.substr(hashIndex);
|
---|
579 | path = path.substr(0, hashIndex);
|
---|
580 | }
|
---|
581 |
|
---|
582 | let searchIndex = path.indexOf("?");
|
---|
583 | if (searchIndex >= 0) {
|
---|
584 | parsedPath.search = path.substr(searchIndex);
|
---|
585 | path = path.substr(0, searchIndex);
|
---|
586 | }
|
---|
587 |
|
---|
588 | if (path) {
|
---|
589 | parsedPath.pathname = path;
|
---|
590 | }
|
---|
591 | }
|
---|
592 |
|
---|
593 | return parsedPath;
|
---|
594 | }
|
---|
595 |
|
---|
596 | export interface UrlHistory extends History {}
|
---|
597 |
|
---|
598 | export type UrlHistoryOptions = {
|
---|
599 | window?: Window;
|
---|
600 | v5Compat?: boolean;
|
---|
601 | };
|
---|
602 |
|
---|
603 | function getUrlBasedHistory(
|
---|
604 | getLocation: (window: Window, globalHistory: Window["history"]) => Location,
|
---|
605 | createHref: (window: Window, to: To) => string,
|
---|
606 | validateLocation: ((location: Location, to: To) => void) | null,
|
---|
607 | options: UrlHistoryOptions = {}
|
---|
608 | ): UrlHistory {
|
---|
609 | let { window = document.defaultView!, v5Compat = false } = options;
|
---|
610 | let globalHistory = window.history;
|
---|
611 | let action = Action.Pop;
|
---|
612 | let listener: Listener | null = null;
|
---|
613 |
|
---|
614 | let index = getIndex()!;
|
---|
615 | // Index should only be null when we initialize. If not, it's because the
|
---|
616 | // user called history.pushState or history.replaceState directly, in which
|
---|
617 | // case we should log a warning as it will result in bugs.
|
---|
618 | if (index == null) {
|
---|
619 | index = 0;
|
---|
620 | globalHistory.replaceState({ ...globalHistory.state, idx: index }, "");
|
---|
621 | }
|
---|
622 |
|
---|
623 | function getIndex(): number {
|
---|
624 | let state = globalHistory.state || { idx: null };
|
---|
625 | return state.idx;
|
---|
626 | }
|
---|
627 |
|
---|
628 | function handlePop() {
|
---|
629 | action = Action.Pop;
|
---|
630 | let nextIndex = getIndex();
|
---|
631 | let delta = nextIndex == null ? null : nextIndex - index;
|
---|
632 | index = nextIndex;
|
---|
633 | if (listener) {
|
---|
634 | listener({ action, location: history.location, delta });
|
---|
635 | }
|
---|
636 | }
|
---|
637 |
|
---|
638 | function push(to: To, state?: any) {
|
---|
639 | action = Action.Push;
|
---|
640 | let location = createLocation(history.location, to, state);
|
---|
641 | if (validateLocation) validateLocation(location, to);
|
---|
642 |
|
---|
643 | index = getIndex() + 1;
|
---|
644 | let historyState = getHistoryState(location, index);
|
---|
645 | let url = history.createHref(location);
|
---|
646 |
|
---|
647 | // try...catch because iOS limits us to 100 pushState calls :/
|
---|
648 | try {
|
---|
649 | globalHistory.pushState(historyState, "", url);
|
---|
650 | } catch (error) {
|
---|
651 | // If the exception is because `state` can't be serialized, let that throw
|
---|
652 | // outwards just like a replace call would so the dev knows the cause
|
---|
653 | // https://html.spec.whatwg.org/multipage/nav-history-apis.html#shared-history-push/replace-state-steps
|
---|
654 | // https://html.spec.whatwg.org/multipage/structured-data.html#structuredserializeinternal
|
---|
655 | if (error instanceof DOMException && error.name === "DataCloneError") {
|
---|
656 | throw error;
|
---|
657 | }
|
---|
658 | // They are going to lose state here, but there is no real
|
---|
659 | // way to warn them about it since the page will refresh...
|
---|
660 | window.location.assign(url);
|
---|
661 | }
|
---|
662 |
|
---|
663 | if (v5Compat && listener) {
|
---|
664 | listener({ action, location: history.location, delta: 1 });
|
---|
665 | }
|
---|
666 | }
|
---|
667 |
|
---|
668 | function replace(to: To, state?: any) {
|
---|
669 | action = Action.Replace;
|
---|
670 | let location = createLocation(history.location, to, state);
|
---|
671 | if (validateLocation) validateLocation(location, to);
|
---|
672 |
|
---|
673 | index = getIndex();
|
---|
674 | let historyState = getHistoryState(location, index);
|
---|
675 | let url = history.createHref(location);
|
---|
676 | globalHistory.replaceState(historyState, "", url);
|
---|
677 |
|
---|
678 | if (v5Compat && listener) {
|
---|
679 | listener({ action, location: history.location, delta: 0 });
|
---|
680 | }
|
---|
681 | }
|
---|
682 |
|
---|
683 | function createURL(to: To): URL {
|
---|
684 | // window.location.origin is "null" (the literal string value) in Firefox
|
---|
685 | // under certain conditions, notably when serving from a local HTML file
|
---|
686 | // See https://bugzilla.mozilla.org/show_bug.cgi?id=878297
|
---|
687 | let base =
|
---|
688 | window.location.origin !== "null"
|
---|
689 | ? window.location.origin
|
---|
690 | : window.location.href;
|
---|
691 |
|
---|
692 | let href = typeof to === "string" ? to : createPath(to);
|
---|
693 | // Treating this as a full URL will strip any trailing spaces so we need to
|
---|
694 | // pre-encode them since they might be part of a matching splat param from
|
---|
695 | // an ancestor route
|
---|
696 | href = href.replace(/ $/, "%20");
|
---|
697 | invariant(
|
---|
698 | base,
|
---|
699 | `No window.location.(origin|href) available to create URL for href: ${href}`
|
---|
700 | );
|
---|
701 | return new URL(href, base);
|
---|
702 | }
|
---|
703 |
|
---|
704 | let history: History = {
|
---|
705 | get action() {
|
---|
706 | return action;
|
---|
707 | },
|
---|
708 | get location() {
|
---|
709 | return getLocation(window, globalHistory);
|
---|
710 | },
|
---|
711 | listen(fn: Listener) {
|
---|
712 | if (listener) {
|
---|
713 | throw new Error("A history only accepts one active listener");
|
---|
714 | }
|
---|
715 | window.addEventListener(PopStateEventType, handlePop);
|
---|
716 | listener = fn;
|
---|
717 |
|
---|
718 | return () => {
|
---|
719 | window.removeEventListener(PopStateEventType, handlePop);
|
---|
720 | listener = null;
|
---|
721 | };
|
---|
722 | },
|
---|
723 | createHref(to) {
|
---|
724 | return createHref(window, to);
|
---|
725 | },
|
---|
726 | createURL,
|
---|
727 | encodeLocation(to) {
|
---|
728 | // Encode a Location the same way window.location would
|
---|
729 | let url = createURL(to);
|
---|
730 | return {
|
---|
731 | pathname: url.pathname,
|
---|
732 | search: url.search,
|
---|
733 | hash: url.hash,
|
---|
734 | };
|
---|
735 | },
|
---|
736 | push,
|
---|
737 | replace,
|
---|
738 | go(n) {
|
---|
739 | return globalHistory.go(n);
|
---|
740 | },
|
---|
741 | };
|
---|
742 |
|
---|
743 | return history;
|
---|
744 | }
|
---|
745 |
|
---|
746 | //#endregion
|
---|