@remix-run/router 1.3.0 → 1.3.1

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/dist/utils.d.ts CHANGED
@@ -383,7 +383,7 @@ export declare class ErrorResponse {
383
383
  }
384
384
  /**
385
385
  * Check if the given error is an ErrorResponse generated from a 4xx/5xx
386
- * Response throw from an action/loader
386
+ * Response thrown from an action/loader
387
387
  */
388
- export declare function isRouteErrorResponse(e: any): e is ErrorResponse;
388
+ export declare function isRouteErrorResponse(error: any): error is ErrorResponse;
389
389
  export {};
package/history.ts CHANGED
@@ -85,7 +85,7 @@ export interface Update {
85
85
  /**
86
86
  * The delta between this location and the former location in the history stack
87
87
  */
88
- delta: number;
88
+ delta: number | null;
89
89
  }
90
90
 
91
91
  /**
@@ -612,28 +612,12 @@ function getUrlBasedHistory(
612
612
  }
613
613
 
614
614
  function handlePop() {
615
- let nextAction = Action.Pop;
615
+ action = Action.Pop;
616
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
- );
617
+ let delta = nextIndex == null ? null : nextIndex - index;
618
+ index = nextIndex;
619
+ if (listener) {
620
+ listener({ action, location: history.location, delta });
637
621
  }
638
622
  }
639
623
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remix-run/router",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Nested/Data-driven/Framework-agnostic Routing",
5
5
  "keywords": [
6
6
  "remix",
package/router.ts CHANGED
@@ -21,6 +21,7 @@ import type {
21
21
  SuccessResult,
22
22
  AgnosticRouteMatch,
23
23
  MutationFormMethod,
24
+ ShouldRevalidateFunction,
24
25
  } from "./utils";
25
26
  import {
26
27
  DeferredData,
@@ -549,25 +550,22 @@ interface HandleLoadersResult extends ShortCircuitable {
549
550
  }
550
551
 
551
552
  /**
552
- * Tuple of [key, href, DataRouteMatch, DataRouteMatch[]] for a revalidating
553
- * fetcher.load()
553
+ * Cached info for active fetcher.load() instances so they can participate
554
+ * in revalidation
554
555
  */
555
- type RevalidatingFetcher = [
556
- string,
557
- string,
558
- AgnosticDataRouteMatch,
559
- AgnosticDataRouteMatch[]
560
- ];
556
+ interface FetchLoadMatch {
557
+ routeId: string;
558
+ path: string;
559
+ match: AgnosticDataRouteMatch;
560
+ matches: AgnosticDataRouteMatch[];
561
+ }
561
562
 
562
563
  /**
563
- * Tuple of [href, DataRouteMatch, DataRouteMatch[]] for an active
564
- * fetcher.load()
564
+ * Identified fetcher.load() calls that need to be revalidated
565
565
  */
566
- type FetchLoadMatch = [
567
- string,
568
- AgnosticDataRouteMatch,
569
- AgnosticDataRouteMatch[]
570
- ];
566
+ interface RevalidatingFetcher extends FetchLoadMatch {
567
+ key: string;
568
+ }
571
569
 
572
570
  /**
573
571
  * Wrapper object to allow us to throw any response out from callLoaderOrAction
@@ -783,12 +781,23 @@ export function createRouter(init: RouterInit): Router {
783
781
  return;
784
782
  }
785
783
 
784
+ warning(
785
+ activeBlocker != null && delta === null,
786
+ "You are trying to use a blocker on a POP navigation to a location " +
787
+ "that was not created by @remix-run/router. This will fail silently in " +
788
+ "production. This can happen if you are navigating outside the router " +
789
+ "via `window.history.pushState`/`window.location.hash` instead of using " +
790
+ "router navigation APIs. This can also happen if you are using " +
791
+ "createHashRouter and the user manually changes the URL."
792
+ );
793
+
786
794
  let blockerKey = shouldBlockNavigation({
787
795
  currentLocation: state.location,
788
796
  nextLocation: location,
789
797
  historyAction,
790
798
  });
791
- if (blockerKey) {
799
+
800
+ if (blockerKey && delta != null) {
792
801
  // Restore the URL to match the current UI, but don't update router state
793
802
  ignoreNextHistoryUpdate = true;
794
803
  init.history.go(delta * -1);
@@ -1121,8 +1130,13 @@ export function createRouter(init: RouterInit): Router {
1121
1130
  return;
1122
1131
  }
1123
1132
 
1124
- // Short circuit if it's only a hash change
1125
- if (isHashChangeOnly(state.location, location)) {
1133
+ // Short circuit if it's only a hash change and not a mutation submission
1134
+ // For example, on /page#hash and submit a <Form method="post"> which will
1135
+ // default to a navigation to /page
1136
+ if (
1137
+ isHashChangeOnly(state.location, location) &&
1138
+ !(opts && opts.submission && isMutationMethod(opts.submission.formMethod))
1139
+ ) {
1126
1140
  completeNavigation(location, { matches });
1127
1141
  return;
1128
1142
  }
@@ -1380,8 +1394,8 @@ export function createRouter(init: RouterInit): Router {
1380
1394
  // preserving any new action data or existing action data (in the case of
1381
1395
  // a revalidation interrupting an actionReload)
1382
1396
  if (!isUninterruptedRevalidation) {
1383
- revalidatingFetchers.forEach(([key]) => {
1384
- let fetcher = state.fetchers.get(key);
1397
+ revalidatingFetchers.forEach((rf) => {
1398
+ let fetcher = state.fetchers.get(rf.key);
1385
1399
  let revalidatingFetcher: FetcherStates["Loading"] = {
1386
1400
  state: "loading",
1387
1401
  data: fetcher && fetcher.data,
@@ -1391,7 +1405,7 @@ export function createRouter(init: RouterInit): Router {
1391
1405
  formData: undefined,
1392
1406
  " _hasFetcherDoneAnything ": true,
1393
1407
  };
1394
- state.fetchers.set(key, revalidatingFetcher);
1408
+ state.fetchers.set(rf.key, revalidatingFetcher);
1395
1409
  });
1396
1410
  let actionData = pendingActionData || state.actionData;
1397
1411
  updateState({
@@ -1408,8 +1422,8 @@ export function createRouter(init: RouterInit): Router {
1408
1422
  }
1409
1423
 
1410
1424
  pendingNavigationLoadId = ++incrementingLoadId;
1411
- revalidatingFetchers.forEach(([key]) =>
1412
- fetchControllers.set(key, pendingNavigationController!)
1425
+ revalidatingFetchers.forEach((rf) =>
1426
+ fetchControllers.set(rf.key, pendingNavigationController!)
1413
1427
  );
1414
1428
 
1415
1429
  let { results, loaderResults, fetcherResults } =
@@ -1428,7 +1442,7 @@ export function createRouter(init: RouterInit): Router {
1428
1442
  // Clean up _after_ loaders have completed. Don't clean up if we short
1429
1443
  // circuited because fetchControllers would have been aborted and
1430
1444
  // reassigned to new controllers for the next navigation
1431
- revalidatingFetchers.forEach(([key]) => fetchControllers.delete(key));
1445
+ revalidatingFetchers.forEach((rf) => fetchControllers.delete(rf.key));
1432
1446
 
1433
1447
  // If any loaders returned a redirect Response, start a new REPLACE navigation
1434
1448
  let redirect = findRedirect(results);
@@ -1507,6 +1521,8 @@ export function createRouter(init: RouterInit): Router {
1507
1521
  let { path, submission } = normalizeNavigateOptions(href, opts, true);
1508
1522
  let match = getTargetMatch(matches, path);
1509
1523
 
1524
+ pendingPreventScrollReset = (opts && opts.preventScrollReset) === true;
1525
+
1510
1526
  if (submission && isMutationMethod(submission.formMethod)) {
1511
1527
  handleFetcherAction(key, routeId, path, match, matches, submission);
1512
1528
  return;
@@ -1514,7 +1530,7 @@ export function createRouter(init: RouterInit): Router {
1514
1530
 
1515
1531
  // Store off the match so we can call it's shouldRevalidate on subsequent
1516
1532
  // revalidations
1517
- fetchLoadMatches.set(key, [path, match, matches]);
1533
+ fetchLoadMatches.set(key, { routeId, path, match, matches });
1518
1534
  handleFetcherLoader(key, routeId, path, match, matches, submission);
1519
1535
  }
1520
1536
 
@@ -1651,8 +1667,9 @@ export function createRouter(init: RouterInit): Router {
1651
1667
  // current fetcher which we want to keep in it's current loading state which
1652
1668
  // contains it's action submission info + action data
1653
1669
  revalidatingFetchers
1654
- .filter(([staleKey]) => staleKey !== key)
1655
- .forEach(([staleKey]) => {
1670
+ .filter((rf) => rf.key !== key)
1671
+ .forEach((rf) => {
1672
+ let staleKey = rf.key;
1656
1673
  let existingFetcher = state.fetchers.get(staleKey);
1657
1674
  let revalidatingFetcher: FetcherStates["Loading"] = {
1658
1675
  state: "loading",
@@ -1684,9 +1701,7 @@ export function createRouter(init: RouterInit): Router {
1684
1701
 
1685
1702
  fetchReloadIds.delete(key);
1686
1703
  fetchControllers.delete(key);
1687
- revalidatingFetchers.forEach(([staleKey]) =>
1688
- fetchControllers.delete(staleKey)
1689
- );
1704
+ revalidatingFetchers.forEach((r) => fetchControllers.delete(r.key));
1690
1705
 
1691
1706
  let redirect = findRedirect(results);
1692
1707
  if (redirect) {
@@ -1980,12 +1995,12 @@ export function createRouter(init: RouterInit): Router {
1980
1995
  ...matchesToLoad.map((match) =>
1981
1996
  callLoaderOrAction("loader", request, match, matches, router.basename)
1982
1997
  ),
1983
- ...fetchersToLoad.map(([, href, match, fetchMatches]) =>
1998
+ ...fetchersToLoad.map((f) =>
1984
1999
  callLoaderOrAction(
1985
2000
  "loader",
1986
- createClientSideRequest(init.history, href, request.signal),
1987
- match,
1988
- fetchMatches,
2001
+ createClientSideRequest(init.history, f.path, request.signal),
2002
+ f.match,
2003
+ f.matches,
1989
2004
  router.basename
1990
2005
  )
1991
2006
  ),
@@ -2004,7 +2019,7 @@ export function createRouter(init: RouterInit): Router {
2004
2019
  ),
2005
2020
  resolveDeferredResults(
2006
2021
  currentMatches,
2007
- fetchersToLoad.map(([, , match]) => match),
2022
+ fetchersToLoad.map((f) => f.match),
2008
2023
  fetcherResults,
2009
2024
  request.signal,
2010
2025
  true
@@ -2845,25 +2860,14 @@ function normalizeNavigateOptions(
2845
2860
 
2846
2861
  // Flatten submission onto URLSearchParams for GET submissions
2847
2862
  let parsedPath = parsePath(path);
2848
- try {
2849
- let searchParams = convertFormDataToSearchParams(opts.formData);
2850
- // Since fetcher GET submissions only run a single loader (as opposed to
2851
- // navigation GET submissions which run all loaders), we need to preserve
2852
- // any incoming ?index params
2853
- if (
2854
- isFetcher &&
2855
- parsedPath.search &&
2856
- hasNakedIndexQuery(parsedPath.search)
2857
- ) {
2858
- searchParams.append("index", "");
2859
- }
2860
- parsedPath.search = `?${searchParams}`;
2861
- } catch (e) {
2862
- return {
2863
- path,
2864
- error: getInternalRouterError(400),
2865
- };
2863
+ let searchParams = convertFormDataToSearchParams(opts.formData);
2864
+ // Since fetcher GET submissions only run a single loader (as opposed to
2865
+ // navigation GET submissions which run all loaders), we need to preserve
2866
+ // any incoming ?index params
2867
+ if (isFetcher && parsedPath.search && hasNakedIndexQuery(parsedPath.search)) {
2868
+ searchParams.append("index", "");
2866
2869
  }
2870
+ parsedPath.search = `?${searchParams}`;
2867
2871
 
2868
2872
  return { path: createPath(parsedPath), submission };
2869
2873
  }
@@ -2903,47 +2907,81 @@ function getMatchesToLoad(
2903
2907
  ? Object.values(pendingActionData)[0]
2904
2908
  : undefined;
2905
2909
 
2910
+ let currentUrl = history.createURL(state.location);
2911
+ let nextUrl = history.createURL(location);
2912
+
2913
+ let defaultShouldRevalidate =
2914
+ // Forced revalidation due to submission, useRevalidate, or X-Remix-Revalidate
2915
+ isRevalidationRequired ||
2916
+ // Clicked the same link, resubmitted a GET form
2917
+ currentUrl.toString() === nextUrl.toString() ||
2918
+ // Search params affect all loaders
2919
+ currentUrl.search !== nextUrl.search;
2920
+
2906
2921
  // Pick navigation matches that are net-new or qualify for revalidation
2907
2922
  let boundaryId = pendingError ? Object.keys(pendingError)[0] : undefined;
2908
2923
  let boundaryMatches = getLoaderMatchesUntilBoundary(matches, boundaryId);
2909
- let navigationMatches = boundaryMatches.filter(
2910
- (match, index) =>
2911
- match.route.loader != null &&
2912
- (isNewLoader(state.loaderData, state.matches[index], match) ||
2913
- // If this route had a pending deferred cancelled it must be revalidated
2914
- cancelledDeferredRoutes.some((id) => id === match.route.id) ||
2915
- shouldRevalidateLoader(
2916
- history,
2917
- state.location,
2918
- state.matches[index],
2919
- submission,
2920
- location,
2921
- match,
2922
- isRevalidationRequired,
2923
- actionResult
2924
- ))
2925
- );
2924
+
2925
+ let navigationMatches = boundaryMatches.filter((match, index) => {
2926
+ if (match.route.loader == null) {
2927
+ return false;
2928
+ }
2929
+
2930
+ // Always call the loader on new route instances and pending defer cancellations
2931
+ if (
2932
+ isNewLoader(state.loaderData, state.matches[index], match) ||
2933
+ cancelledDeferredRoutes.some((id) => id === match.route.id)
2934
+ ) {
2935
+ return true;
2936
+ }
2937
+
2938
+ // This is the default implementation for when we revalidate. If the route
2939
+ // provides it's own implementation, then we give them full control but
2940
+ // provide this value so they can leverage it if needed after they check
2941
+ // their own specific use cases
2942
+ let currentRouteMatch = state.matches[index];
2943
+ let nextRouteMatch = match;
2944
+
2945
+ return shouldRevalidateLoader(match, {
2946
+ currentUrl,
2947
+ currentParams: currentRouteMatch.params,
2948
+ nextUrl,
2949
+ nextParams: nextRouteMatch.params,
2950
+ ...submission,
2951
+ actionResult,
2952
+ defaultShouldRevalidate:
2953
+ defaultShouldRevalidate ||
2954
+ isNewRouteInstance(currentRouteMatch, nextRouteMatch),
2955
+ });
2956
+ });
2926
2957
 
2927
2958
  // Pick fetcher.loads that need to be revalidated
2928
2959
  let revalidatingFetchers: RevalidatingFetcher[] = [];
2929
2960
  fetchLoadMatches &&
2930
- fetchLoadMatches.forEach(([href, match, fetchMatches], key) => {
2931
- // This fetcher was cancelled from a prior action submission - force reload
2932
- if (cancelledFetcherLoads.includes(key)) {
2933
- revalidatingFetchers.push([key, href, match, fetchMatches]);
2934
- } else if (isRevalidationRequired) {
2935
- let shouldRevalidate = shouldRevalidateLoader(
2936
- history,
2937
- href,
2938
- match,
2939
- submission,
2940
- href,
2941
- match,
2942
- isRevalidationRequired,
2943
- actionResult
2944
- );
2961
+ fetchLoadMatches.forEach((f, key) => {
2962
+ if (!matches.some((m) => m.route.id === f.routeId)) {
2963
+ // This fetcher is not going to be present in the subsequent render so
2964
+ // there's no need to revalidate it
2965
+ return;
2966
+ } else if (cancelledFetcherLoads.includes(key)) {
2967
+ // This fetcher was cancelled from a prior action submission - force reload
2968
+ revalidatingFetchers.push({ key, ...f });
2969
+ } else {
2970
+ // Revalidating fetchers are decoupled from the route matches since they
2971
+ // hit a static href, so they _always_ check shouldRevalidate and the
2972
+ // default is strictly if a revalidation is explicitly required (action
2973
+ // submissions, useRevalidator, X-Remix-Revalidate).
2974
+ let shouldRevalidate = shouldRevalidateLoader(f.match, {
2975
+ currentUrl,
2976
+ currentParams: state.matches[state.matches.length - 1].params,
2977
+ nextUrl,
2978
+ nextParams: matches[matches.length - 1].params,
2979
+ ...submission,
2980
+ actionResult,
2981
+ defaultShouldRevalidate,
2982
+ });
2945
2983
  if (shouldRevalidate) {
2946
- revalidatingFetchers.push([key, href, match, fetchMatches]);
2984
+ revalidatingFetchers.push({ key, ...f });
2947
2985
  }
2948
2986
  }
2949
2987
  });
@@ -2980,58 +3018,24 @@ function isNewRouteInstance(
2980
3018
  currentMatch.pathname !== match.pathname ||
2981
3019
  // splat param changed, which is not present in match.path
2982
3020
  // e.g. /files/images/avatar.jpg -> files/finances.xls
2983
- (currentPath &&
3021
+ (currentPath != null &&
2984
3022
  currentPath.endsWith("*") &&
2985
3023
  currentMatch.params["*"] !== match.params["*"])
2986
3024
  );
2987
3025
  }
2988
3026
 
2989
3027
  function shouldRevalidateLoader(
2990
- history: History,
2991
- currentLocation: string | Location,
2992
- currentMatch: AgnosticDataRouteMatch,
2993
- submission: Submission | undefined,
2994
- location: string | Location,
2995
- match: AgnosticDataRouteMatch,
2996
- isRevalidationRequired: boolean,
2997
- actionResult: DataResult | undefined
3028
+ loaderMatch: AgnosticDataRouteMatch,
3029
+ arg: Parameters<ShouldRevalidateFunction>[0]
2998
3030
  ) {
2999
- let currentUrl = history.createURL(currentLocation);
3000
- let currentParams = currentMatch.params;
3001
- let nextUrl = history.createURL(location);
3002
- let nextParams = match.params;
3003
-
3004
- // This is the default implementation as to when we revalidate. If the route
3005
- // provides it's own implementation, then we give them full control but
3006
- // provide this value so they can leverage it if needed after they check
3007
- // their own specific use cases
3008
- // Note that fetchers always provide the same current/next locations so the
3009
- // URL-based checks here don't apply to fetcher shouldRevalidate calls
3010
- let defaultShouldRevalidate =
3011
- isNewRouteInstance(currentMatch, match) ||
3012
- // Clicked the same link, resubmitted a GET form
3013
- currentUrl.toString() === nextUrl.toString() ||
3014
- // Search params affect all loaders
3015
- currentUrl.search !== nextUrl.search ||
3016
- // Forced revalidation due to submission, useRevalidate, or X-Remix-Revalidate
3017
- isRevalidationRequired;
3018
-
3019
- if (match.route.shouldRevalidate) {
3020
- let routeChoice = match.route.shouldRevalidate({
3021
- currentUrl,
3022
- currentParams,
3023
- nextUrl,
3024
- nextParams,
3025
- ...submission,
3026
- actionResult,
3027
- defaultShouldRevalidate,
3028
- });
3031
+ if (loaderMatch.route.shouldRevalidate) {
3032
+ let routeChoice = loaderMatch.route.shouldRevalidate(arg);
3029
3033
  if (typeof routeChoice === "boolean") {
3030
3034
  return routeChoice;
3031
3035
  }
3032
3036
  }
3033
3037
 
3034
- return defaultShouldRevalidate;
3038
+ return arg.defaultShouldRevalidate;
3035
3039
  }
3036
3040
 
3037
3041
  async function callLoaderOrAction(
@@ -3222,12 +3226,8 @@ function convertFormDataToSearchParams(formData: FormData): URLSearchParams {
3222
3226
  let searchParams = new URLSearchParams();
3223
3227
 
3224
3228
  for (let [key, value] of formData.entries()) {
3225
- invariant(
3226
- typeof value === "string",
3227
- 'File inputs are not supported with encType "application/x-www-form-urlencoded", ' +
3228
- 'please use "multipart/form-data" instead.'
3229
- );
3230
- searchParams.append(key, value);
3229
+ // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#converting-an-entry-list-to-a-list-of-name-value-pairs
3230
+ searchParams.append(key, value instanceof File ? value.name : value);
3231
3231
  }
3232
3232
 
3233
3233
  return searchParams;
@@ -3355,7 +3355,7 @@ function processLoaderData(
3355
3355
 
3356
3356
  // Process results from our revalidating fetchers
3357
3357
  for (let index = 0; index < revalidatingFetchers.length; index++) {
3358
- let [key, , match] = revalidatingFetchers[index];
3358
+ let { key, match } = revalidatingFetchers[index];
3359
3359
  invariant(
3360
3360
  fetcherResults !== undefined && fetcherResults[index] !== undefined,
3361
3361
  "Did not find corresponding fetcher result"
@@ -3490,8 +3490,6 @@ function getInternalRouterError(
3490
3490
  `so there is no way to handle the request.`;
3491
3491
  } else if (type === "defer-action") {
3492
3492
  errorMessage = "defer() is not supported in actions";
3493
- } else {
3494
- errorMessage = "Cannot submit binary form data using GET";
3495
3493
  }
3496
3494
  } else if (status === 403) {
3497
3495
  statusText = "Forbidden";
package/utils.ts CHANGED
@@ -1192,6 +1192,11 @@ export class DeferredData {
1192
1192
  {}
1193
1193
  );
1194
1194
 
1195
+ if (this.done) {
1196
+ // All incoming values were resolved
1197
+ this.unlistenAbortSignal();
1198
+ }
1199
+
1195
1200
  this.init = responseInit;
1196
1201
  }
1197
1202
 
@@ -1395,8 +1400,14 @@ export class ErrorResponse {
1395
1400
 
1396
1401
  /**
1397
1402
  * Check if the given error is an ErrorResponse generated from a 4xx/5xx
1398
- * Response throw from an action/loader
1403
+ * Response thrown from an action/loader
1399
1404
  */
1400
- export function isRouteErrorResponse(e: any): e is ErrorResponse {
1401
- return e instanceof ErrorResponse;
1405
+ export function isRouteErrorResponse(error: any): error is ErrorResponse {
1406
+ return (
1407
+ error != null &&
1408
+ typeof error.status === "number" &&
1409
+ typeof error.statusText === "string" &&
1410
+ typeof error.internal === "boolean" &&
1411
+ "data" in error
1412
+ );
1402
1413
  }