[d565449] | 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
|
---|