@remix-run/router 1.9.0 → 1.10.0

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
@@ -54,8 +54,8 @@ export type DataResult = SuccessResult | DeferredResult | RedirectResult | Error
54
54
  type LowerCaseFormMethod = "get" | "post" | "put" | "patch" | "delete";
55
55
  type UpperCaseFormMethod = Uppercase<LowerCaseFormMethod>;
56
56
  /**
57
- * Users can specify either lowercase or uppercase form methods on <Form>,
58
- * useSubmit(), <fetcher.Form>, etc.
57
+ * Users can specify either lowercase or uppercase form methods on `<Form>`,
58
+ * useSubmit(), `<fetcher.Form>`, etc.
59
59
  */
60
60
  export type HTMLFormMethod = LowerCaseFormMethod | UpperCaseFormMethod;
61
61
  /**
@@ -470,11 +470,20 @@ export declare const redirect: RedirectFunction;
470
470
  * Defaults to "302 Found".
471
471
  */
472
472
  export declare const redirectDocument: RedirectFunction;
473
+ export type ErrorResponse = {
474
+ status: number;
475
+ statusText: string;
476
+ data: any;
477
+ };
473
478
  /**
474
479
  * @private
475
480
  * Utility class we use to hold auto-unwrapped 4xx/5xx Response bodies
481
+ *
482
+ * We don't export the class for public use since it's an implementation
483
+ * detail, but we export the interface above so folks can build their own
484
+ * abstractions around instances via isRouteErrorResponse()
476
485
  */
477
- export declare class ErrorResponseImpl {
486
+ export declare class ErrorResponseImpl implements ErrorResponse {
478
487
  status: number;
479
488
  statusText: string;
480
489
  data: any;
@@ -482,7 +491,6 @@ export declare class ErrorResponseImpl {
482
491
  private internal;
483
492
  constructor(status: number, statusText: string | undefined, data: any, internal?: boolean);
484
493
  }
485
- export type ErrorResponse = InstanceType<typeof ErrorResponseImpl>;
486
494
  /**
487
495
  * Check if the given error is an ErrorResponse generated from a 4xx/5xx
488
496
  * Response thrown from an action/loader
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remix-run/router",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Nested/Data-driven/Framework-agnostic Routing",
5
5
  "keywords": [
6
6
  "remix",
package/router.ts CHANGED
@@ -80,6 +80,14 @@ export interface Router {
80
80
  */
81
81
  get routes(): AgnosticDataRouteObject[];
82
82
 
83
+ /**
84
+ * @internal
85
+ * PRIVATE - DO NOT USE
86
+ *
87
+ * Return the window associated with the router
88
+ */
89
+ get window(): RouterInit["window"];
90
+
83
91
  /**
84
92
  * @internal
85
93
  * PRIVATE - DO NOT USE
@@ -388,11 +396,21 @@ export interface StaticHandler {
388
396
  ): Promise<any>;
389
397
  }
390
398
 
399
+ type ViewTransitionOpts = {
400
+ currentLocation: Location;
401
+ nextLocation: Location;
402
+ };
403
+
391
404
  /**
392
405
  * Subscriber function signature for changes to router state
393
406
  */
394
407
  export interface RouterSubscriber {
395
- (state: RouterState): void;
408
+ (
409
+ state: RouterState,
410
+ opts: {
411
+ unstable_viewTransitionOpts?: ViewTransitionOpts;
412
+ }
413
+ ): void;
396
414
  }
397
415
 
398
416
  /**
@@ -423,6 +441,7 @@ type BaseNavigateOptions = BaseNavigateOrFetchOptions & {
423
441
  replace?: boolean;
424
442
  state?: any;
425
443
  fromRouteId?: string;
444
+ unstable_viewTransition?: boolean;
426
445
  };
427
446
 
428
447
  // Only allowed for submission navigations
@@ -690,6 +709,8 @@ const defaultMapRouteProperties: MapRoutePropertiesFunction = (route) => ({
690
709
  hasErrorBoundary: Boolean(route.hasErrorBoundary),
691
710
  });
692
711
 
712
+ const TRANSITIONS_STORAGE_KEY = "remix-router-transitions";
713
+
693
714
  //#endregion
694
715
 
695
716
  ////////////////////////////////////////////////////////////////////////////////
@@ -814,6 +835,18 @@ export function createRouter(init: RouterInit): Router {
814
835
  // AbortController for the active navigation
815
836
  let pendingNavigationController: AbortController | null;
816
837
 
838
+ // Should the current navigation enable document.startViewTransition?
839
+ let pendingViewTransitionEnabled = false;
840
+
841
+ // Store applied view transitions so we can apply them on POP
842
+ let appliedViewTransitions: Map<string, Set<string>> = new Map<
843
+ string,
844
+ Set<string>
845
+ >();
846
+
847
+ // Cleanup function for persisting applied transitions to sessionStorage
848
+ let removePageHideEventListener: (() => void) | null = null;
849
+
817
850
  // We use this to avoid touching history in completeNavigation if a
818
851
  // revalidation is entirely uninterrupted
819
852
  let isUninterruptedRevalidation = false;
@@ -929,6 +962,17 @@ export function createRouter(init: RouterInit): Router {
929
962
  }
930
963
  );
931
964
 
965
+ if (isBrowser) {
966
+ // FIXME: This feels gross. How can we cleanup the lines between
967
+ // scrollRestoration/appliedTransitions persistance?
968
+ restoreAppliedTransitions(routerWindow, appliedViewTransitions);
969
+ let _saveAppliedTransitions = () =>
970
+ persistAppliedTransitions(routerWindow, appliedViewTransitions);
971
+ routerWindow.addEventListener("pagehide", _saveAppliedTransitions);
972
+ removePageHideEventListener = () =>
973
+ routerWindow.removeEventListener("pagehide", _saveAppliedTransitions);
974
+ }
975
+
932
976
  // Kick off initial data load if needed. Use Pop to avoid modifying history
933
977
  // Note we don't do any handling of lazy here. For SPA's it'll get handled
934
978
  // in the normal navigation flow. For SSR it's expected that lazy modules are
@@ -946,6 +990,9 @@ export function createRouter(init: RouterInit): Router {
946
990
  if (unlistenHistory) {
947
991
  unlistenHistory();
948
992
  }
993
+ if (removePageHideEventListener) {
994
+ removePageHideEventListener();
995
+ }
949
996
  subscribers.clear();
950
997
  pendingNavigationController && pendingNavigationController.abort();
951
998
  state.fetchers.forEach((_, key) => deleteFetcher(key));
@@ -959,12 +1006,17 @@ export function createRouter(init: RouterInit): Router {
959
1006
  }
960
1007
 
961
1008
  // Update our state and notify the calling context of the change
962
- function updateState(newState: Partial<RouterState>): void {
1009
+ function updateState(
1010
+ newState: Partial<RouterState>,
1011
+ viewTransitionOpts?: ViewTransitionOpts
1012
+ ): void {
963
1013
  state = {
964
1014
  ...state,
965
1015
  ...newState,
966
1016
  };
967
- subscribers.forEach((subscriber) => subscriber(state));
1017
+ subscribers.forEach((subscriber) =>
1018
+ subscriber(state, { unstable_viewTransitionOpts: viewTransitionOpts })
1019
+ );
968
1020
  }
969
1021
 
970
1022
  // Complete a navigation returning the state.navigation back to the IDLE_NAVIGATION
@@ -1045,26 +1097,64 @@ export function createRouter(init: RouterInit): Router {
1045
1097
  init.history.replace(location, location.state);
1046
1098
  }
1047
1099
 
1048
- updateState({
1049
- ...newState, // matches, errors, fetchers go through as-is
1050
- actionData,
1051
- loaderData,
1052
- historyAction: pendingAction,
1053
- location,
1054
- initialized: true,
1055
- navigation: IDLE_NAVIGATION,
1056
- revalidation: "idle",
1057
- restoreScrollPosition: getSavedScrollPosition(
1100
+ let viewTransitionOpts: ViewTransitionOpts | undefined;
1101
+
1102
+ // On POP, enable transitions if they were enabled on the original navigation
1103
+ if (pendingAction === HistoryAction.Pop) {
1104
+ // Forward takes precedence so they behave like the original navigation
1105
+ let priorPaths = appliedViewTransitions.get(state.location.pathname);
1106
+ if (priorPaths && priorPaths.has(location.pathname)) {
1107
+ viewTransitionOpts = {
1108
+ currentLocation: state.location,
1109
+ nextLocation: location,
1110
+ };
1111
+ } else if (appliedViewTransitions.has(location.pathname)) {
1112
+ // If we don't have a previous forward nav, assume we're popping back to
1113
+ // the new location and enable if that location previously enabled
1114
+ viewTransitionOpts = {
1115
+ currentLocation: location,
1116
+ nextLocation: state.location,
1117
+ };
1118
+ }
1119
+ } else if (pendingViewTransitionEnabled) {
1120
+ // Store the applied transition on PUSH/REPLACE
1121
+ let toPaths = appliedViewTransitions.get(state.location.pathname);
1122
+ if (toPaths) {
1123
+ toPaths.add(location.pathname);
1124
+ } else {
1125
+ toPaths = new Set<string>([location.pathname]);
1126
+ appliedViewTransitions.set(state.location.pathname, toPaths);
1127
+ }
1128
+ viewTransitionOpts = {
1129
+ currentLocation: state.location,
1130
+ nextLocation: location,
1131
+ };
1132
+ }
1133
+
1134
+ updateState(
1135
+ {
1136
+ ...newState, // matches, errors, fetchers go through as-is
1137
+ actionData,
1138
+ loaderData,
1139
+ historyAction: pendingAction,
1058
1140
  location,
1059
- newState.matches || state.matches
1060
- ),
1061
- preventScrollReset,
1062
- blockers,
1063
- });
1141
+ initialized: true,
1142
+ navigation: IDLE_NAVIGATION,
1143
+ revalidation: "idle",
1144
+ restoreScrollPosition: getSavedScrollPosition(
1145
+ location,
1146
+ newState.matches || state.matches
1147
+ ),
1148
+ preventScrollReset,
1149
+ blockers,
1150
+ },
1151
+ viewTransitionOpts
1152
+ );
1064
1153
 
1065
1154
  // Reset stateful navigation vars
1066
1155
  pendingAction = HistoryAction.Pop;
1067
1156
  pendingPreventScrollReset = false;
1157
+ pendingViewTransitionEnabled = false;
1068
1158
  isUninterruptedRevalidation = false;
1069
1159
  isRevalidationRequired = false;
1070
1160
  cancelledDeferredRoutes = [];
@@ -1173,6 +1263,7 @@ export function createRouter(init: RouterInit): Router {
1173
1263
  pendingError: error,
1174
1264
  preventScrollReset,
1175
1265
  replace: opts && opts.replace,
1266
+ enableViewTransition: opts && opts.unstable_viewTransition,
1176
1267
  });
1177
1268
  }
1178
1269
 
@@ -1223,6 +1314,7 @@ export function createRouter(init: RouterInit): Router {
1223
1314
  startUninterruptedRevalidation?: boolean;
1224
1315
  preventScrollReset?: boolean;
1225
1316
  replace?: boolean;
1317
+ enableViewTransition?: boolean;
1226
1318
  }
1227
1319
  ): Promise<void> {
1228
1320
  // Abort any in-progress navigations and start a new one. Unset any ongoing
@@ -1239,6 +1331,8 @@ export function createRouter(init: RouterInit): Router {
1239
1331
  saveScrollPosition(state.location, state.matches);
1240
1332
  pendingPreventScrollReset = (opts && opts.preventScrollReset) === true;
1241
1333
 
1334
+ pendingViewTransitionEnabled = (opts && opts.enableViewTransition) === true;
1335
+
1242
1336
  let routesToUse = inFlightDataRoutes || dataRoutes;
1243
1337
  let loadingNavigation = opts && opts.overrideNavigation;
1244
1338
  let matches = matchRoutes(routesToUse, location, basename);
@@ -2505,6 +2599,9 @@ export function createRouter(init: RouterInit): Router {
2505
2599
  get routes() {
2506
2600
  return dataRoutes;
2507
2601
  },
2602
+ get window() {
2603
+ return routerWindow;
2604
+ },
2508
2605
  initialize,
2509
2606
  subscribe,
2510
2607
  enableScrollRestoration,
@@ -3074,7 +3171,7 @@ export function getStaticContextFromError(
3074
3171
  }
3075
3172
 
3076
3173
  function isSubmissionNavigation(
3077
- opts: RouterNavigateOptions
3174
+ opts: BaseNavigateOrFetchOptions
3078
3175
  ): opts is SubmissionNavigateOptions {
3079
3176
  return (
3080
3177
  opts != null &&
@@ -3158,7 +3255,7 @@ function normalizeNavigateOptions(
3158
3255
  normalizeFormMethod: boolean,
3159
3256
  isFetcher: boolean,
3160
3257
  path: string,
3161
- opts?: RouterNavigateOptions
3258
+ opts?: BaseNavigateOrFetchOptions
3162
3259
  ): {
3163
3260
  path: string;
3164
3261
  submission?: Submission;
@@ -4074,9 +4171,12 @@ function getShortCircuitMatches(routes: AgnosticDataRouteObject[]): {
4074
4171
  route: AgnosticDataRouteObject;
4075
4172
  } {
4076
4173
  // Prefer a root layout route if present, otherwise shim in a route object
4077
- let route = routes.find((r) => r.index || !r.path || r.path === "/") || {
4078
- id: `__shim-error-route__`,
4079
- };
4174
+ let route =
4175
+ routes.length === 1
4176
+ ? routes[0]
4177
+ : routes.find((r) => r.index || !r.path || r.path === "/") || {
4178
+ id: `__shim-error-route__`,
4179
+ };
4080
4180
 
4081
4181
  return {
4082
4182
  matches: [
@@ -4492,4 +4592,49 @@ function getDoneFetcher(data: Fetcher["data"]): FetcherStates["Idle"] {
4492
4592
  };
4493
4593
  return fetcher;
4494
4594
  }
4595
+
4596
+ function restoreAppliedTransitions(
4597
+ _window: Window,
4598
+ transitions: Map<string, Set<string>>
4599
+ ) {
4600
+ try {
4601
+ let sessionPositions = _window.sessionStorage.getItem(
4602
+ TRANSITIONS_STORAGE_KEY
4603
+ );
4604
+ if (sessionPositions) {
4605
+ let json = JSON.parse(sessionPositions);
4606
+ for (let [k, v] of Object.entries(json || {})) {
4607
+ if (v && Array.isArray(v)) {
4608
+ transitions.set(k, new Set(v || []));
4609
+ }
4610
+ }
4611
+ }
4612
+ } catch (e) {
4613
+ // no-op, use default empty object
4614
+ }
4615
+ }
4616
+
4617
+ function persistAppliedTransitions(
4618
+ _window: Window,
4619
+ transitions: Map<string, Set<string>>
4620
+ ) {
4621
+ if (transitions.size > 0) {
4622
+ let json: Record<string, string[]> = {};
4623
+ for (let [k, v] of transitions) {
4624
+ json[k] = [...v];
4625
+ }
4626
+ try {
4627
+ _window.sessionStorage.setItem(
4628
+ TRANSITIONS_STORAGE_KEY,
4629
+ JSON.stringify(json)
4630
+ );
4631
+ } catch (error) {
4632
+ warning(
4633
+ false,
4634
+ `Failed to save applied view transitions in sessionStorage (${error}).`
4635
+ );
4636
+ }
4637
+ }
4638
+ }
4639
+
4495
4640
  //#endregion
package/utils.ts CHANGED
@@ -68,8 +68,8 @@ type LowerCaseFormMethod = "get" | "post" | "put" | "patch" | "delete";
68
68
  type UpperCaseFormMethod = Uppercase<LowerCaseFormMethod>;
69
69
 
70
70
  /**
71
- * Users can specify either lowercase or uppercase form methods on <Form>,
72
- * useSubmit(), <fetcher.Form>, etc.
71
+ * Users can specify either lowercase or uppercase form methods on `<Form>`,
72
+ * useSubmit(), `<fetcher.Form>`, etc.
73
73
  */
74
74
  export type HTMLFormMethod = LowerCaseFormMethod | UpperCaseFormMethod;
75
75
 
@@ -1533,11 +1533,21 @@ export const redirectDocument: RedirectFunction = (url, init) => {
1533
1533
  return response;
1534
1534
  };
1535
1535
 
1536
+ export type ErrorResponse = {
1537
+ status: number;
1538
+ statusText: string;
1539
+ data: any;
1540
+ };
1541
+
1536
1542
  /**
1537
1543
  * @private
1538
1544
  * Utility class we use to hold auto-unwrapped 4xx/5xx Response bodies
1545
+ *
1546
+ * We don't export the class for public use since it's an implementation
1547
+ * detail, but we export the interface above so folks can build their own
1548
+ * abstractions around instances via isRouteErrorResponse()
1539
1549
  */
1540
- export class ErrorResponseImpl {
1550
+ export class ErrorResponseImpl implements ErrorResponse {
1541
1551
  status: number;
1542
1552
  statusText: string;
1543
1553
  data: any;
@@ -1562,11 +1572,6 @@ export class ErrorResponseImpl {
1562
1572
  }
1563
1573
  }
1564
1574
 
1565
- // We don't want the class exported since usage of it at runtime is an
1566
- // implementation detail, but we do want to export the shape so folks can
1567
- // build their own abstractions around instances via isRouteErrorResponse()
1568
- export type ErrorResponse = InstanceType<typeof ErrorResponseImpl>;
1569
-
1570
1575
  /**
1571
1576
  * Check if the given error is an ErrorResponse generated from a 4xx/5xx
1572
1577
  * Response thrown from an action/loader