history.ts 20 KB

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