@tanstack/react-router 0.0.1-beta.273 → 0.0.1-beta.275

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/src/router.ts CHANGED
@@ -110,6 +110,7 @@ export interface RouterOptions<
110
110
  defaultPendingComponent?: RouteComponent
111
111
  defaultPendingMs?: number
112
112
  defaultPendingMinMs?: number
113
+ defaultPreloadMaxAge?: number
113
114
  caseSensitive?: boolean
114
115
  routeTree?: TRouteTree
115
116
  basepath?: string
@@ -129,6 +130,7 @@ export interface RouterState<TRouteTree extends AnyRoute = AnyRoute> {
129
130
  isTransitioning: boolean
130
131
  matches: RouteMatch<TRouteTree>[]
131
132
  pendingMatches?: RouteMatch<TRouteTree>[]
133
+ preloadMatches: RouteMatch<TRouteTree>[]
132
134
  location: ParsedLocation<FullSearchSchema<TRouteTree>>
133
135
  resolvedLocation: ParsedLocation<FullSearchSchema<TRouteTree>>
134
136
  lastUpdated: number
@@ -159,7 +161,7 @@ export interface DehydratedRouterState {
159
161
 
160
162
  export type DehydratedRouteMatch = Pick<
161
163
  RouteMatch,
162
- 'fetchedAt' | 'id' | 'status' | 'updatedAt'
164
+ 'id' | 'status' | 'updatedAt'
163
165
  >
164
166
 
165
167
  export interface DehydratedRouter {
@@ -615,13 +617,21 @@ export class Router<
615
617
  }
616
618
  })()
617
619
 
620
+ // This is where we need to call route.options.loaderDeps() to get any additional
621
+ // deps that the route's loader function might need to run. We need to do this
622
+ // before we create the match so that we can pass the deps to the route's
623
+ // potential key function which is used to uniquely identify the route match in state
624
+
625
+ const loaderDeps =
626
+ route.options.loaderDeps?.({
627
+ search: preMatchSearch,
628
+ }) ?? ''
629
+
630
+ const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : ''
631
+
618
632
  const interpolatedPath = interpolatePath(route.fullPath, routeParams)
619
633
  const matchId =
620
- interpolatePath(route.id, routeParams, true) +
621
- (route.options.key?.({
622
- search: preMatchSearch,
623
- location: this.state.location,
624
- }) ?? '')
634
+ interpolatePath(route.id, routeParams, true) + loaderDepsHash
625
635
 
626
636
  // Waste not, want not. If we already have a match for this route,
627
637
  // reuse it. This is important for layout routes, which might stick
@@ -658,8 +668,9 @@ export class Router<
658
668
  context: undefined!,
659
669
  abortController: new AbortController(),
660
670
  shouldReloadDeps: undefined,
661
- fetchedAt: 0,
671
+ fetchCount: 0,
662
672
  cause,
673
+ loaderDeps,
663
674
  }
664
675
 
665
676
  // Regardless of whether we're reusing an existing match or creating
@@ -976,10 +987,20 @@ export class Router<
976
987
  let latestPromise
977
988
  let firstBadMatchIndex: number | undefined
978
989
 
979
- const updatePendingMatch = (match: AnyRouteMatch) => {
990
+ const updateMatch = (match: AnyRouteMatch) => {
991
+ const isPreload = this.state.preloadMatches.find((d) => d.id === match.id)
992
+ const isPending = this.state.pendingMatches?.find(
993
+ (d) => d.id === match.id,
994
+ )
995
+ const matchesKey = isPreload
996
+ ? 'preloadMatches'
997
+ : isPending
998
+ ? 'pendingMatches'
999
+ : 'matches'
1000
+
980
1001
  this.__store.setState((s) => ({
981
1002
  ...s,
982
- pendingMatches: s.pendingMatches?.map((d) =>
1003
+ [matchesKey]: s[matchesKey]?.map((d) =>
983
1004
  d.id === match.id ? match : d,
984
1005
  ),
985
1006
  }))
@@ -1043,7 +1064,7 @@ export class Router<
1043
1064
  navigate: (opts) =>
1044
1065
  this.navigate({ ...opts, from: match.pathname } as any),
1045
1066
  buildLocation: this.buildLocation,
1046
- cause: match.cause,
1067
+ cause: preload ? 'preload' : match.cause,
1047
1068
  })) ?? ({} as any)
1048
1069
 
1049
1070
  if (isRedirect(beforeLoadContext)) {
@@ -1083,7 +1104,7 @@ export class Router<
1083
1104
 
1084
1105
  validResolvedMatches.forEach((match, index) => {
1085
1106
  matchPromises.push(
1086
- (async () => {
1107
+ new Promise<void>(async (resolve) => {
1087
1108
  const parentMatchPromise = matchPromises[index - 1]
1088
1109
  const route = this.looseRoutesById[match.routeId]!
1089
1110
 
@@ -1101,126 +1122,129 @@ export class Router<
1101
1122
 
1102
1123
  matches[index] = match = {
1103
1124
  ...match,
1104
- fetchedAt: Date.now(),
1105
1125
  showPending: false,
1106
1126
  }
1107
1127
 
1128
+ let didShowPending = false
1108
1129
  const pendingMs =
1109
1130
  route.options.pendingMs ?? this.options.defaultPendingMs
1110
-
1111
- let pendingPromise: Promise<void> | undefined
1112
-
1113
- if (
1131
+ const pendingMinMs =
1132
+ route.options.pendingMinMs ?? this.options.defaultPendingMinMs
1133
+ const shouldPending =
1114
1134
  !preload &&
1115
1135
  pendingMs &&
1116
1136
  (route.options.pendingComponent ??
1117
1137
  this.options.defaultPendingComponent)
1118
- ) {
1119
- pendingPromise = new Promise((r) => setTimeout(r, pendingMs))
1120
- }
1121
-
1122
- if (match.isFetching) {
1123
- loadPromise = getRouteMatch(this.state, match.id)?.loadPromise
1124
- } else {
1125
- const loaderContext: LoaderFnContext = {
1126
- params: match.params,
1127
- search: match.search,
1128
- preload: !!preload,
1129
- parentMatchPromise,
1130
- abortController: match.abortController,
1131
- context: match.context,
1132
- location: this.state.location,
1133
- navigate: (opts) =>
1134
- this.navigate({ ...opts, from: match.pathname } as any),
1135
- cause: match.cause,
1136
- }
1137
-
1138
- // Default to reloading the route all the time
1139
- let shouldReload = true
1140
1138
 
1141
- let shouldReloadDeps =
1142
- typeof route.options.shouldReload === 'function'
1143
- ? route.options.shouldReload?.(loaderContext)
1144
- : !!(route.options.shouldReload ?? true)
1139
+ const fetch = async () => {
1140
+ if (match.isFetching) {
1141
+ loadPromise = getRouteMatch(this.state, match.id)?.loadPromise
1142
+ } else {
1143
+ const loaderContext: LoaderFnContext = {
1144
+ params: match.params,
1145
+ deps: match.loaderDeps,
1146
+ preload: !!preload,
1147
+ parentMatchPromise,
1148
+ abortController: match.abortController,
1149
+ context: match.context,
1150
+ location: this.state.location,
1151
+ navigate: (opts) =>
1152
+ this.navigate({ ...opts, from: match.pathname } as any),
1153
+ cause: preload ? 'preload' : match.cause,
1154
+ }
1145
1155
 
1146
- if (match.cause === 'enter' || invalidate) {
1147
- match.shouldReloadDeps = shouldReloadDeps
1148
- } else if (match.cause === 'stay') {
1149
- if (typeof shouldReloadDeps === 'object') {
1150
- // compare the deps to see if they've changed
1151
- shouldReload = !deepEqual(
1152
- shouldReloadDeps,
1153
- match.shouldReloadDeps,
1154
- )
1156
+ // Default to reloading the route all the time
1157
+ let shouldLoad = true
1158
+
1159
+ const shouldReloadFn = route.options.shouldReload
1160
+
1161
+ let shouldReloadDeps =
1162
+ typeof shouldReloadFn === 'function'
1163
+ ? shouldReloadFn(loaderContext)
1164
+ : !!(shouldReloadFn ?? true)
1165
+
1166
+ const compareDeps = () => {
1167
+ if (typeof shouldReloadDeps === 'object') {
1168
+ // compare the deps to see if they've changed
1169
+ shouldLoad = !deepEqual(
1170
+ shouldReloadDeps,
1171
+ match.shouldReloadDeps,
1172
+ )
1173
+ } else {
1174
+ shouldLoad = !!shouldReloadDeps
1175
+ }
1176
+ }
1155
1177
 
1156
- match.shouldReloadDeps = shouldReloadDeps
1178
+ // If it's the first preload, or the route is entering, or we're
1179
+ // invalidating, we definitely need to load the route
1180
+ if (invalidate) {
1181
+ // Change nothing, we need to load the route
1182
+ } else if (preload) {
1183
+ if (!match.fetchCount) {
1184
+ // Change nothing, we need to preload the route
1185
+ } else {
1186
+ compareDeps()
1187
+ }
1188
+ } else if (match.cause === 'enter') {
1189
+ if (!match.fetchCount) {
1190
+ // Change nothing, we 100% need to load the route
1191
+ } else {
1192
+ compareDeps()
1193
+ }
1157
1194
  } else {
1158
- shouldReload = !!shouldReloadDeps
1195
+ compareDeps()
1159
1196
  }
1160
- }
1161
1197
 
1162
- // If the user doesn't want the route to reload, just
1163
- // resolve with the existing loader data
1164
-
1165
- if (!shouldReload) {
1166
- loadPromise = Promise.resolve(match.loaderData)
1167
- } else {
1168
- // Otherwise, load the route
1169
- matches[index] = match = {
1170
- ...match,
1171
- isFetching: true,
1198
+ if (typeof shouldReloadDeps === 'object') {
1199
+ matches[index] = match = {
1200
+ ...match,
1201
+ shouldReloadDeps,
1202
+ }
1172
1203
  }
1173
1204
 
1174
- const componentsPromise = Promise.all(
1175
- componentTypes.map(async (type) => {
1176
- const component = route.options[type]
1177
-
1178
- if ((component as any)?.preload) {
1179
- await (component as any).preload()
1180
- }
1181
- }),
1182
- )
1183
-
1184
- const loaderPromise = route.options.loader?.(loaderContext)
1205
+ // If the user doesn't want the route to reload, just
1206
+ // resolve with the existing loader data
1185
1207
 
1186
- loadPromise = Promise.all([
1187
- componentsPromise,
1188
- loaderPromise,
1189
- ]).then((d) => d[1])
1190
- }
1191
- }
1208
+ if (!shouldLoad) {
1209
+ loadPromise = Promise.resolve(match.loaderData)
1210
+ } else {
1211
+ if (match.fetchCount && match.status === 'success') {
1212
+ resolve()
1213
+ }
1192
1214
 
1193
- matches[index] = match = {
1194
- ...match,
1195
- loadPromise,
1196
- }
1215
+ // Otherwise, load the route
1216
+ matches[index] = match = {
1217
+ ...match,
1218
+ isFetching: true,
1219
+ fetchCount: match.fetchCount + 1,
1220
+ }
1197
1221
 
1198
- if (!preload) {
1199
- updatePendingMatch(match)
1200
- }
1222
+ const componentsPromise = Promise.all(
1223
+ componentTypes.map(async (type) => {
1224
+ const component = route.options[type]
1201
1225
 
1202
- let didShowPending = false
1203
- const pendingMinMs =
1204
- route.options.pendingMinMs ?? this.options.defaultPendingMinMs
1226
+ if ((component as any)?.preload) {
1227
+ await (component as any).preload()
1228
+ }
1229
+ }),
1230
+ )
1205
1231
 
1206
- await new Promise<void>(async (resolve) => {
1207
- // If the route has a pending component and a pendingMs option,
1208
- // forcefully show the pending component
1209
- if (pendingPromise) {
1210
- pendingPromise.then(() => {
1211
- if ((latestPromise = checkLatest())) return
1232
+ const loaderPromise = route.options.loader?.(loaderContext)
1212
1233
 
1213
- didShowPending = true
1214
- matches[index] = match = {
1215
- ...match,
1216
- showPending: true,
1217
- }
1234
+ loadPromise = Promise.all([
1235
+ componentsPromise,
1236
+ loaderPromise,
1237
+ ]).then((d) => d[1])
1238
+ }
1239
+ }
1218
1240
 
1219
- updatePendingMatch(match)
1220
- resolve()
1221
- })
1241
+ matches[index] = match = {
1242
+ ...match,
1243
+ loadPromise,
1222
1244
  }
1223
1245
 
1246
+ updateMatch(match)
1247
+
1224
1248
  try {
1225
1249
  const loaderData = await loadPromise
1226
1250
  if ((latestPromise = checkLatest())) return await latestPromise
@@ -1267,22 +1291,44 @@ export class Router<
1267
1291
  // we already moved the pendingMatches to the matches
1268
1292
  // state, so we need to update that specific match
1269
1293
  if (didShowPending && pendingMinMs && match.showPending) {
1270
- this.__store.setState((s) => ({
1271
- ...s,
1272
- matches: s.matches?.map((d) =>
1273
- d.id === match.id ? match : d,
1274
- ),
1275
- }))
1294
+ updateMatch(match)
1276
1295
  }
1277
1296
  }
1278
1297
 
1279
- if (!preload) {
1280
- updatePendingMatch(match)
1298
+ updateMatch(match)
1299
+ }
1300
+
1301
+ if (match.fetchCount && match.status === 'success') {
1302
+ // Background Fetching
1303
+ fetch()
1304
+ } else {
1305
+ // Critical Fetching
1306
+
1307
+ // If we need to potentially show the pending component,
1308
+ // start a timer to show it after the pendingMs
1309
+ if (shouldPending) {
1310
+ new Promise((r) => setTimeout(r, pendingMs)).then(async () => {
1311
+ if ((latestPromise = checkLatest())) return latestPromise
1312
+
1313
+ didShowPending = true
1314
+ matches[index] = match = {
1315
+ ...match,
1316
+ showPending: true,
1317
+ }
1318
+
1319
+ updateMatch(match)
1320
+ resolve()
1321
+ })
1281
1322
  }
1282
1323
 
1283
- resolve()
1284
- })
1285
- })(),
1324
+ await fetch()
1325
+ }
1326
+
1327
+ resolve()
1328
+ // No Fetching
1329
+
1330
+ resolve()
1331
+ }),
1286
1332
  )
1287
1333
  })
1288
1334
 
@@ -1312,24 +1358,36 @@ export class Router<
1312
1358
  pathChanged: pathDidChange,
1313
1359
  })
1314
1360
 
1315
- // Match the routes
1316
- let pendingMatches: RouteMatch<any, any>[] = this.matchRoutes(
1317
- next.pathname,
1318
- next.search,
1319
- {
1320
- debug: true,
1321
- },
1322
- )
1323
-
1361
+ let pendingMatches!: RouteMatch<any, any>[]
1324
1362
  const previousMatches = this.state.matches
1325
1363
 
1326
- // Ingest the new matches
1327
- this.__store.setState((s) => ({
1328
- ...s,
1329
- isLoading: true,
1330
- location: next,
1331
- pendingMatches,
1332
- }))
1364
+ this.__store.batch(() => {
1365
+ this.__store.setState((s) => ({
1366
+ ...s,
1367
+ preloadMatches: s.preloadMatches.filter((d) => {
1368
+ return (
1369
+ Date.now() - d.updatedAt <
1370
+ (this.options.defaultPreloadMaxAge ?? 3000)
1371
+ )
1372
+ }),
1373
+ }))
1374
+
1375
+ // Match the routes
1376
+ pendingMatches = this.matchRoutes(next.pathname, next.search, {
1377
+ debug: true,
1378
+ })
1379
+
1380
+ // Ingest the new matches
1381
+ this.__store.setState((s) => ({
1382
+ ...s,
1383
+ isLoading: true,
1384
+ location: next,
1385
+ pendingMatches,
1386
+ preloadMatches: s.preloadMatches.filter((d) => {
1387
+ return !pendingMatches.find((e) => e.id === d.id)
1388
+ }),
1389
+ }))
1390
+ })
1333
1391
 
1334
1392
  try {
1335
1393
  try {
@@ -1411,6 +1469,25 @@ export class Router<
1411
1469
  throwOnError: true,
1412
1470
  })
1413
1471
 
1472
+ const loadedMatchIds = Object.fromEntries(
1473
+ [
1474
+ ...this.state.matches,
1475
+ ...(this.state.pendingMatches ?? []),
1476
+ ...this.state.preloadMatches,
1477
+ ]?.map((d) => [d.id, true]),
1478
+ )
1479
+
1480
+ this.__store.batch(() => {
1481
+ matches.forEach((match) => {
1482
+ if (!loadedMatchIds[match.id]) {
1483
+ this.__store.setState((s) => ({
1484
+ ...s,
1485
+ preloadMatches: [...(s.preloadMatches as any), match],
1486
+ }))
1487
+ }
1488
+ })
1489
+ })
1490
+
1414
1491
  matches = await this.loadMatches({
1415
1492
  matches,
1416
1493
  preload: true,
@@ -1500,7 +1577,7 @@ export class Router<
1500
1577
  return {
1501
1578
  state: {
1502
1579
  dehydratedMatches: this.state.matches.map((d) =>
1503
- pick(d, ['fetchedAt', 'id', 'status', 'updatedAt', 'loaderData']),
1580
+ pick(d, ['id', 'status', 'updatedAt', 'loaderData']),
1504
1581
  ),
1505
1582
  },
1506
1583
  }
@@ -1588,6 +1665,7 @@ export function getInitialRouterState(
1588
1665
  location,
1589
1666
  matches: [],
1590
1667
  pendingMatches: [],
1668
+ preloadMatches: [],
1591
1669
  lastUpdated: Date.now(),
1592
1670
  }
1593
1671
  }