@remix-run/router 1.3.0-pre.1 → 1.3.0-pre.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/history.ts CHANGED
@@ -81,6 +81,11 @@ export interface Update {
81
81
  * The new location.
82
82
  */
83
83
  location: Location;
84
+
85
+ /**
86
+ * The delta between this location and the former location in the history stack
87
+ */
88
+ delta: number;
84
89
  }
85
90
 
86
91
  /**
@@ -181,6 +186,7 @@ export interface History {
181
186
  type HistoryState = {
182
187
  usr: any;
183
188
  key?: string;
189
+ idx: number;
184
190
  };
185
191
 
186
192
  const PopStateEventType = "popstate";
@@ -294,7 +300,7 @@ export function createMemoryHistory(
294
300
  index += 1;
295
301
  entries.splice(index, entries.length, nextLocation);
296
302
  if (v5Compat && listener) {
297
- listener({ action, location: nextLocation });
303
+ listener({ action, location: nextLocation, delta: 1 });
298
304
  }
299
305
  },
300
306
  replace(to, state) {
@@ -302,14 +308,16 @@ export function createMemoryHistory(
302
308
  let nextLocation = createMemoryLocation(to, state);
303
309
  entries[index] = nextLocation;
304
310
  if (v5Compat && listener) {
305
- listener({ action, location: nextLocation });
311
+ listener({ action, location: nextLocation, delta: 0 });
306
312
  }
307
313
  },
308
314
  go(delta) {
309
315
  action = Action.Pop;
310
- index = clampIndex(index + delta);
316
+ let nextIndex = clampIndex(index + delta);
317
+ let nextLocation = entries[nextIndex];
318
+ index = nextIndex;
311
319
  if (listener) {
312
- listener({ action, location: getCurrentLocation() });
320
+ listener({ action, location: nextLocation, delta });
313
321
  }
314
322
  },
315
323
  listen(fn: Listener) {
@@ -497,10 +505,11 @@ function createKey() {
497
505
  /**
498
506
  * For browser-based histories, we combine the state and key into an object
499
507
  */
500
- function getHistoryState(location: Location): HistoryState {
508
+ function getHistoryState(location: Location, index: number): HistoryState {
501
509
  return {
502
510
  usr: location.state,
503
511
  key: location.key,
512
+ idx: index,
504
513
  };
505
514
  }
506
515
 
@@ -588,10 +597,43 @@ function getUrlBasedHistory(
588
597
  let action = Action.Pop;
589
598
  let listener: Listener | null = null;
590
599
 
600
+ let index = getIndex()!;
601
+ // Index should only be null when we initialize. If not, it's because the
602
+ // user called history.pushState or history.replaceState directly, in which
603
+ // case we should log a warning as it will result in bugs.
604
+ if (index == null) {
605
+ index = 0;
606
+ globalHistory.replaceState({ ...globalHistory.state, idx: index }, "");
607
+ }
608
+
609
+ function getIndex(): number {
610
+ let state = globalHistory.state || { idx: null };
611
+ return state.idx;
612
+ }
613
+
591
614
  function handlePop() {
592
- action = Action.Pop;
593
- if (listener) {
594
- listener({ action, location: history.location });
615
+ let nextAction = Action.Pop;
616
+ let nextIndex = getIndex();
617
+
618
+ if (nextIndex != null) {
619
+ let delta = nextIndex - index;
620
+ action = nextAction;
621
+ index = nextIndex;
622
+ if (listener) {
623
+ listener({ action, location: history.location, delta });
624
+ }
625
+ } else {
626
+ warning(
627
+ false,
628
+ // TODO: Write up a doc that explains our blocking strategy in detail
629
+ // and link to it here so people can understand better what is going on
630
+ // and how to avoid it.
631
+ `You are trying to block a POP navigation to a location that was not ` +
632
+ `created by @remix-run/router. The block will fail silently in ` +
633
+ `production, but in general you should do all navigation with the ` +
634
+ `router (instead of using window.history.pushState directly) ` +
635
+ `to avoid this situation.`
636
+ );
595
637
  }
596
638
  }
597
639
 
@@ -600,7 +642,8 @@ function getUrlBasedHistory(
600
642
  let location = createLocation(history.location, to, state);
601
643
  if (validateLocation) validateLocation(location, to);
602
644
 
603
- let historyState = getHistoryState(location);
645
+ index = getIndex() + 1;
646
+ let historyState = getHistoryState(location, index);
604
647
  let url = history.createHref(location);
605
648
 
606
649
  // try...catch because iOS limits us to 100 pushState calls :/
@@ -613,7 +656,7 @@ function getUrlBasedHistory(
613
656
  }
614
657
 
615
658
  if (v5Compat && listener) {
616
- listener({ action, location: history.location });
659
+ listener({ action, location: history.location, delta: 1 });
617
660
  }
618
661
  }
619
662
 
@@ -622,12 +665,13 @@ function getUrlBasedHistory(
622
665
  let location = createLocation(history.location, to, state);
623
666
  if (validateLocation) validateLocation(location, to);
624
667
 
625
- let historyState = getHistoryState(location);
668
+ index = getIndex();
669
+ let historyState = getHistoryState(location, index);
626
670
  let url = history.createHref(location);
627
671
  globalHistory.replaceState(historyState, "", url);
628
672
 
629
673
  if (v5Compat && listener) {
630
- listener({ action, location: history.location });
674
+ listener({ action, location: history.location, delta: 0 });
631
675
  }
632
676
  }
633
677
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remix-run/router",
3
- "version": "1.3.0-pre.1",
3
+ "version": "1.3.0-pre.2",
4
4
  "description": "Nested/Data-driven/Framework-agnostic Routing",
5
5
  "keywords": [
6
6
  "remix",
package/router.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  joinPaths,
33
33
  matchRoutes,
34
34
  resolveTo,
35
+ warning,
35
36
  } from "./utils";
36
37
 
37
38
  ////////////////////////////////////////////////////////////////////////////////
@@ -110,14 +111,14 @@ export interface Router {
110
111
  * Navigate forward/backward in the history stack
111
112
  * @param to Delta to move in the history stack
112
113
  */
113
- navigate(to: number): void;
114
+ navigate(to: number): Promise<void>;
114
115
 
115
116
  /**
116
117
  * Navigate to the given path
117
118
  * @param to Path to navigate to
118
119
  * @param opts Navigation options (method, submission, etc.)
119
120
  */
120
- navigate(to: To, opts?: RouterNavigateOptions): void;
121
+ navigate(to: To, opts?: RouterNavigateOptions): Promise<void>;
121
122
 
122
123
  /**
123
124
  * @internal
@@ -190,6 +191,25 @@ export interface Router {
190
191
  */
191
192
  dispose(): void;
192
193
 
194
+ /**
195
+ * @internal
196
+ * PRIVATE - DO NOT USE
197
+ *
198
+ * Get a navigation blocker
199
+ * @param key The identifier for the blocker
200
+ * @param fn The blocker function implementation
201
+ */
202
+ getBlocker(key: string, fn: BlockerFunction): Blocker;
203
+
204
+ /**
205
+ * @internal
206
+ * PRIVATE - DO NOT USE
207
+ *
208
+ * Delete a navigation blocker
209
+ * @param key The identifier for the blocker
210
+ */
211
+ deleteBlocker(key: string): void;
212
+
193
213
  /**
194
214
  * @internal
195
215
  * PRIVATE - DO NOT USE
@@ -275,6 +295,11 @@ export interface RouterState {
275
295
  * Map of current fetchers
276
296
  */
277
297
  fetchers: Map<string, Fetcher>;
298
+
299
+ /**
300
+ * Map of current blockers
301
+ */
302
+ blockers: Map<string, Blocker>;
278
303
  }
279
304
 
280
305
  /**
@@ -461,6 +486,35 @@ type FetcherStates<TData = any> = {
461
486
  export type Fetcher<TData = any> =
462
487
  FetcherStates<TData>[keyof FetcherStates<TData>];
463
488
 
489
+ interface BlockerBlocked {
490
+ state: "blocked";
491
+ reset(): void;
492
+ proceed(): void;
493
+ location: Location;
494
+ }
495
+
496
+ interface BlockerUnblocked {
497
+ state: "unblocked";
498
+ reset: undefined;
499
+ proceed: undefined;
500
+ location: undefined;
501
+ }
502
+
503
+ interface BlockerProceeding {
504
+ state: "proceeding";
505
+ reset: undefined;
506
+ proceed: undefined;
507
+ location: Location;
508
+ }
509
+
510
+ export type Blocker = BlockerUnblocked | BlockerBlocked | BlockerProceeding;
511
+
512
+ export type BlockerFunction = (args: {
513
+ currentLocation: Location;
514
+ nextLocation: Location;
515
+ historyAction: HistoryAction;
516
+ }) => boolean;
517
+
464
518
  interface ShortCircuitable {
465
519
  /**
466
520
  * startNavigation does not need to complete the navigation because we
@@ -562,6 +616,13 @@ export const IDLE_FETCHER: FetcherStates["Idle"] = {
562
616
  formData: undefined,
563
617
  };
564
618
 
619
+ export const IDLE_BLOCKER: BlockerUnblocked = {
620
+ state: "unblocked",
621
+ proceed: undefined,
622
+ reset: undefined,
623
+ location: undefined,
624
+ };
625
+
565
626
  const isBrowser =
566
627
  typeof window !== "undefined" &&
567
628
  typeof window.document !== "undefined" &&
@@ -637,50 +698,76 @@ export function createRouter(init: RouterInit): Router {
637
698
  actionData: (init.hydrationData && init.hydrationData.actionData) || null,
638
699
  errors: (init.hydrationData && init.hydrationData.errors) || initialErrors,
639
700
  fetchers: new Map(),
701
+ blockers: new Map(),
640
702
  };
641
703
 
642
704
  // -- Stateful internal variables to manage navigations --
643
705
  // Current navigation in progress (to be committed in completeNavigation)
644
706
  let pendingAction: HistoryAction = HistoryAction.Pop;
707
+
645
708
  // Should the current navigation prevent the scroll reset if scroll cannot
646
709
  // be restored?
647
710
  let pendingPreventScrollReset = false;
711
+
648
712
  // AbortController for the active navigation
649
713
  let pendingNavigationController: AbortController | null;
714
+
650
715
  // We use this to avoid touching history in completeNavigation if a
651
716
  // revalidation is entirely uninterrupted
652
717
  let isUninterruptedRevalidation = false;
718
+
653
719
  // Use this internal flag to force revalidation of all loaders:
654
720
  // - submissions (completed or interrupted)
655
721
  // - useRevalidate()
656
722
  // - X-Remix-Revalidate (from redirect)
657
723
  let isRevalidationRequired = false;
724
+
658
725
  // Use this internal array to capture routes that require revalidation due
659
726
  // to a cancelled deferred on action submission
660
727
  let cancelledDeferredRoutes: string[] = [];
728
+
661
729
  // Use this internal array to capture fetcher loads that were cancelled by an
662
730
  // action navigation and require revalidation
663
731
  let cancelledFetcherLoads: string[] = [];
732
+
664
733
  // AbortControllers for any in-flight fetchers
665
734
  let fetchControllers = new Map<string, AbortController>();
735
+
666
736
  // Track loads based on the order in which they started
667
737
  let incrementingLoadId = 0;
738
+
668
739
  // Track the outstanding pending navigation data load to be compared against
669
740
  // the globally incrementing load when a fetcher load lands after a completed
670
741
  // navigation
671
742
  let pendingNavigationLoadId = -1;
743
+
672
744
  // Fetchers that triggered data reloads as a result of their actions
673
745
  let fetchReloadIds = new Map<string, number>();
746
+
674
747
  // Fetchers that triggered redirect navigations from their actions
675
748
  let fetchRedirectIds = new Set<string>();
749
+
676
750
  // Most recent href/match for fetcher.load calls for fetchers
677
751
  let fetchLoadMatches = new Map<string, FetchLoadMatch>();
752
+
678
753
  // Store DeferredData instances for active route matches. When a
679
754
  // route loader returns defer() we stick one in here. Then, when a nested
680
755
  // promise resolves we update loaderData. If a new navigation starts we
681
756
  // cancel active deferreds for eliminated routes.
682
757
  let activeDeferreds = new Map<string, DeferredData>();
683
758
 
759
+ // We ony support a single active blocker at the moment since we don't have
760
+ // any compelling use cases for multi-blocker yet
761
+ let activeBlocker: string | null = null;
762
+
763
+ // Store blocker functions in a separate Map outside of router state since
764
+ // we don't need to update UI state if they change
765
+ let blockerFunctions = new Map<string, BlockerFunction>();
766
+
767
+ // Flag to ignore the next history update, so we can revert the URL change on
768
+ // a POP navigation that was blocked by the user without touching router state
769
+ let ignoreNextHistoryUpdate = false;
770
+
684
771
  // Initialize the router, all side effects should be kicked off from here.
685
772
  // Implemented as a Fluent API for ease of:
686
773
  // let router = createRouter(init).initialize();
@@ -688,8 +775,48 @@ export function createRouter(init: RouterInit): Router {
688
775
  // If history informs us of a POP navigation, start the navigation but do not update
689
776
  // state. We'll update our own state once the navigation completes
690
777
  unlistenHistory = init.history.listen(
691
- ({ action: historyAction, location }) =>
692
- startNavigation(historyAction, location)
778
+ ({ action: historyAction, location, delta }) => {
779
+ // Ignore this event if it was just us resetting the URL from a
780
+ // blocked POP navigation
781
+ if (ignoreNextHistoryUpdate) {
782
+ ignoreNextHistoryUpdate = false;
783
+ return;
784
+ }
785
+
786
+ let blockerKey = shouldBlockNavigation({
787
+ currentLocation: state.location,
788
+ nextLocation: location,
789
+ historyAction,
790
+ });
791
+ if (blockerKey) {
792
+ // Restore the URL to match the current UI, but don't update router state
793
+ ignoreNextHistoryUpdate = true;
794
+ init.history.go(delta * -1);
795
+
796
+ // Put the blocker into a blocked state
797
+ updateBlocker(blockerKey, {
798
+ state: "blocked",
799
+ location,
800
+ proceed() {
801
+ updateBlocker(blockerKey!, {
802
+ state: "proceeding",
803
+ proceed: undefined,
804
+ reset: undefined,
805
+ location,
806
+ });
807
+ // Re-do the same POP navigation we just blocked
808
+ init.history.go(delta);
809
+ },
810
+ reset() {
811
+ deleteBlocker(blockerKey!);
812
+ updateState({ blockers: new Map(router.state.blockers) });
813
+ },
814
+ });
815
+ return;
816
+ }
817
+
818
+ return startNavigation(historyAction, location);
819
+ }
693
820
  );
694
821
 
695
822
  // Kick off initial data load if needed. Use Pop to avoid modifying history
@@ -708,6 +835,7 @@ export function createRouter(init: RouterInit): Router {
708
835
  subscribers.clear();
709
836
  pendingNavigationController && pendingNavigationController.abort();
710
837
  state.fetchers.forEach((_, key) => deleteFetcher(key));
838
+ state.blockers.forEach((_, key) => deleteBlocker(key));
711
839
  }
712
840
 
713
841
  // Subscribe to state updates for the router
@@ -772,6 +900,12 @@ export function createRouter(init: RouterInit): Router {
772
900
  )
773
901
  : state.loaderData;
774
902
 
903
+ // On a successful navigation we can assume we got through all blockers
904
+ // so we can start fresh
905
+ for (let [key] of blockerFunctions) {
906
+ deleteBlocker(key);
907
+ }
908
+
775
909
  // Always respect the user flag. Otherwise don't reset on mutation
776
910
  // submission navigations unless they redirect
777
911
  let preventScrollReset =
@@ -794,6 +928,7 @@ export function createRouter(init: RouterInit): Router {
794
928
  newState.matches || state.matches
795
929
  ),
796
930
  preventScrollReset,
931
+ blockers: new Map(state.blockers),
797
932
  });
798
933
 
799
934
  if (isUninterruptedRevalidation) {
@@ -828,16 +963,17 @@ export function createRouter(init: RouterInit): Router {
828
963
 
829
964
  let { path, submission, error } = normalizeNavigateOptions(to, opts);
830
965
 
831
- let location = createLocation(state.location, path, opts && opts.state);
966
+ let currentLocation = state.location;
967
+ let nextLocation = createLocation(state.location, path, opts && opts.state);
832
968
 
833
969
  // When using navigate as a PUSH/REPLACE we aren't reading an already-encoded
834
970
  // URL from window.location, so we need to encode it here so the behavior
835
971
  // remains the same as POP and non-data-router usages. new URL() does all
836
972
  // the same encoding we'd get from a history.pushState/window.location read
837
973
  // without having to touch history
838
- location = {
839
- ...location,
840
- ...init.history.encodeLocation(location),
974
+ nextLocation = {
975
+ ...nextLocation,
976
+ ...init.history.encodeLocation(nextLocation),
841
977
  };
842
978
 
843
979
  let userReplace = opts && opts.replace != null ? opts.replace : undefined;
@@ -865,7 +1001,35 @@ export function createRouter(init: RouterInit): Router {
865
1001
  ? opts.preventScrollReset === true
866
1002
  : undefined;
867
1003
 
868
- return await startNavigation(historyAction, location, {
1004
+ let blockerKey = shouldBlockNavigation({
1005
+ currentLocation,
1006
+ nextLocation,
1007
+ historyAction,
1008
+ });
1009
+ if (blockerKey) {
1010
+ // Put the blocker into a blocked state
1011
+ updateBlocker(blockerKey, {
1012
+ state: "blocked",
1013
+ location: nextLocation,
1014
+ proceed() {
1015
+ updateBlocker(blockerKey!, {
1016
+ state: "proceeding",
1017
+ proceed: undefined,
1018
+ reset: undefined,
1019
+ location: nextLocation,
1020
+ });
1021
+ // Send the same navigation through
1022
+ navigate(to, opts);
1023
+ },
1024
+ reset() {
1025
+ deleteBlocker(blockerKey!);
1026
+ updateState({ blockers: new Map(state.blockers) });
1027
+ },
1028
+ });
1029
+ return;
1030
+ }
1031
+
1032
+ return await startNavigation(historyAction, nextLocation, {
869
1033
  submission,
870
1034
  // Send through the formData serialization error if we have one so we can
871
1035
  // render at the right error boundary after we match routes
@@ -1939,6 +2103,84 @@ export function createRouter(init: RouterInit): Router {
1939
2103
  return yeetedKeys.length > 0;
1940
2104
  }
1941
2105
 
2106
+ function getBlocker(key: string, fn: BlockerFunction) {
2107
+ let blocker: Blocker = state.blockers.get(key) || IDLE_BLOCKER;
2108
+
2109
+ if (blockerFunctions.get(key) !== fn) {
2110
+ blockerFunctions.set(key, fn);
2111
+ if (activeBlocker == null) {
2112
+ // This is now the active blocker
2113
+ activeBlocker = key;
2114
+ } else if (key !== activeBlocker) {
2115
+ warning(false, "A router only supports one blocker at a time");
2116
+ }
2117
+ }
2118
+
2119
+ return blocker;
2120
+ }
2121
+
2122
+ function deleteBlocker(key: string) {
2123
+ state.blockers.delete(key);
2124
+ blockerFunctions.delete(key);
2125
+ if (activeBlocker === key) {
2126
+ activeBlocker = null;
2127
+ }
2128
+ }
2129
+
2130
+ // Utility function to update blockers, ensuring valid state transitions
2131
+ function updateBlocker(key: string, newBlocker: Blocker) {
2132
+ let blocker = state.blockers.get(key) || IDLE_BLOCKER;
2133
+
2134
+ // Poor mans state machine :)
2135
+ // https://mermaid.live/edit#pako:eNqVkc9OwzAMxl8l8nnjAYrEtDIOHEBIgwvKJTReGy3_lDpIqO27k6awMG0XcrLlnz87nwdonESogKXXBuE79rq75XZO3-yHds0RJVuv70YrPlUrCEe2HfrORS3rubqZfuhtpg5C9wk5tZ4VKcRUq88q9Z8RS0-48cE1iHJkL0ugbHuFLus9L6spZy8nX9MP2CNdomVaposqu3fGayT8T8-jJQwhepo_UtpgBQaDEUom04dZhAN1aJBDlUKJBxE1ceB2Smj0Mln-IBW5AFU2dwUiktt_2Qaq2dBfaKdEup85UV7Yd-dKjlnkabl2Pvr0DTkTreM
2136
+ invariant(
2137
+ (blocker.state === "unblocked" && newBlocker.state === "blocked") ||
2138
+ (blocker.state === "blocked" && newBlocker.state === "blocked") ||
2139
+ (blocker.state === "blocked" && newBlocker.state === "proceeding") ||
2140
+ (blocker.state === "blocked" && newBlocker.state === "unblocked") ||
2141
+ (blocker.state === "proceeding" && newBlocker.state === "unblocked"),
2142
+ `Invalid blocker state transition: ${blocker.state} -> ${newBlocker.state}`
2143
+ );
2144
+
2145
+ state.blockers.set(key, newBlocker);
2146
+ updateState({ blockers: new Map(state.blockers) });
2147
+ }
2148
+
2149
+ function shouldBlockNavigation({
2150
+ currentLocation,
2151
+ nextLocation,
2152
+ historyAction,
2153
+ }: {
2154
+ currentLocation: Location;
2155
+ nextLocation: Location;
2156
+ historyAction: HistoryAction;
2157
+ }): string | undefined {
2158
+ if (activeBlocker == null) {
2159
+ return;
2160
+ }
2161
+
2162
+ // We only allow a single blocker at the moment. This will need to be
2163
+ // updated if we enhance to support multiple blockers in the future
2164
+ let blockerFunction = blockerFunctions.get(activeBlocker);
2165
+ invariant(
2166
+ blockerFunction,
2167
+ "Could not find a function for the active blocker"
2168
+ );
2169
+ let blocker = state.blockers.get(activeBlocker);
2170
+
2171
+ if (blocker && blocker.state === "proceeding") {
2172
+ // If the blocker is currently proceeding, we don't need to re-check
2173
+ // it and can let this navigation continue
2174
+ return;
2175
+ }
2176
+
2177
+ // At this point, we know we're unblocked/blocked so we need to check the
2178
+ // user-provided blocker function
2179
+ if (blockerFunction({ currentLocation, nextLocation, historyAction })) {
2180
+ return activeBlocker;
2181
+ }
2182
+ }
2183
+
1942
2184
  function cancelActiveDeferreds(
1943
2185
  predicate?: (routeId: string) => boolean
1944
2186
  ): string[] {
@@ -2038,6 +2280,8 @@ export function createRouter(init: RouterInit): Router {
2038
2280
  getFetcher,
2039
2281
  deleteFetcher,
2040
2282
  dispose,
2283
+ getBlocker,
2284
+ deleteBlocker,
2041
2285
  _internalFetchControllers: fetchControllers,
2042
2286
  _internalActiveDeferreds: activeDeferreds,
2043
2287
  };
package/utils.ts CHANGED
@@ -900,7 +900,7 @@ export function warning(cond: any, message: string): void {
900
900
  if (typeof console !== "undefined") console.warn(message);
901
901
 
902
902
  try {
903
- // Welcome to debugging React Router!
903
+ // Welcome to debugging @remix-run/router!
904
904
  //
905
905
  // This error is thrown as a convenience so you can more easily
906
906
  // find the source for a warning that appears in the console by