@tanstack/react-router 1.28.7 → 1.28.9

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.
@@ -1,4 +1,5 @@
1
1
  import * as React from 'react'
2
+ import { flushSync } from 'react-dom'
2
3
  import { Matches } from './Matches'
3
4
  import { pick, useLayoutEffect } from './utils'
4
5
  import { useRouter } from './useRouter'
@@ -17,19 +18,13 @@ import type {
17
18
 
18
19
  import type { RouteMatch } from './Matches'
19
20
 
20
- const useTransition =
21
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
22
- React.useTransition ||
23
- (() => [
24
- false,
25
- (cb) => {
26
- cb()
27
- },
28
- ])
29
-
30
21
  export interface CommitLocationOptions {
31
22
  replace?: boolean
32
23
  resetScroll?: boolean
24
+ viewTransition?: boolean
25
+ /**
26
+ * @deprecated All navigations use React transitions under the hood now
27
+ **/
33
28
  startTransition?: boolean
34
29
  }
35
30
 
@@ -111,37 +106,30 @@ function Transitioner() {
111
106
  pick(s, ['isLoading', 'location', 'resolvedLocation', 'isTransitioning']),
112
107
  })
113
108
 
114
- const [isTransitioning, startReactTransition] = useTransition()
109
+ const [isTransitioning, startReactTransition_] = React.useTransition()
110
+ // Track pending state changes
111
+ const hasPendingMatches = useRouterState({
112
+ select: (s) => s.matches.some((d) => d.status === 'pending'),
113
+ })
115
114
 
116
- router.startReactTransition = startReactTransition
115
+ const previousIsLoading = usePrevious(routerState.isLoading)
117
116
 
118
- React.useEffect(() => {
119
- if (isTransitioning) {
120
- router.__store.setState((s) => ({
121
- ...s,
122
- isTransitioning,
123
- }))
124
- }
125
- }, [isTransitioning, router])
117
+ const isAnyPending =
118
+ routerState.isLoading || isTransitioning || hasPendingMatches
119
+ const previousIsAnyPending = usePrevious(isAnyPending)
120
+
121
+ router.startReactTransition = startReactTransition_
126
122
 
127
123
  const tryLoad = () => {
128
- const apply = (cb: () => void) => {
129
- if (!routerState.isTransitioning) {
130
- startReactTransition(() => cb())
131
- } else {
132
- cb()
133
- }
124
+ try {
125
+ router.load()
126
+ } catch (err) {
127
+ console.error(err)
134
128
  }
135
-
136
- apply(() => {
137
- try {
138
- router.load()
139
- } catch (err) {
140
- console.error(err)
141
- }
142
- })
143
129
  }
144
130
 
131
+ // Subscribe to location changes
132
+ // and try to load the new location
145
133
  useLayoutEffect(() => {
146
134
  const unsub = router.history.subscribe(() => {
147
135
  router.latestLocation = router.parseLocation(router.latestLocation)
@@ -168,57 +156,72 @@ function Transitioner() {
168
156
  // eslint-disable-next-line react-hooks/exhaustive-deps
169
157
  }, [router, router.history])
170
158
 
159
+ // Try to load the initial location
171
160
  useLayoutEffect(() => {
172
161
  if (
173
- (React.useTransition as any)
174
- ? routerState.isTransitioning && !isTransitioning
175
- : !routerState.isLoading &&
176
- routerState.resolvedLocation !== routerState.location
162
+ window.__TSR_DEHYDRATED__ ||
163
+ (mountLoadForRouter.current.router === router &&
164
+ mountLoadForRouter.current.mounted)
177
165
  ) {
166
+ return
167
+ }
168
+ mountLoadForRouter.current = { router, mounted: true }
169
+ tryLoad()
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ }, [router])
172
+
173
+ useLayoutEffect(() => {
174
+ // The router was loading and now it's not
175
+ if (previousIsLoading && !routerState.isLoading) {
176
+ const toLocation = router.state.location
177
+ const fromLocation = router.state.resolvedLocation
178
+ const pathChanged = fromLocation.href !== toLocation.href
179
+
180
+ router.emit({
181
+ type: 'onLoad',
182
+ fromLocation,
183
+ toLocation,
184
+ pathChanged,
185
+ })
186
+
187
+ // if (router.viewTransitionPromise) {
188
+ // console.log('resolving view transition promise')
189
+ // }
190
+
191
+ // router.viewTransitionPromise?.resolve(true)
192
+ }
193
+ }, [previousIsLoading, router, routerState.isLoading])
194
+
195
+ useLayoutEffect(() => {
196
+ // The router was pending and now it's not
197
+ if (previousIsAnyPending && !isAnyPending) {
198
+ const toLocation = router.state.location
199
+ const fromLocation = router.state.resolvedLocation
200
+ const pathChanged = fromLocation.href !== toLocation.href
201
+
178
202
  router.emit({
179
203
  type: 'onResolved',
180
- fromLocation: routerState.resolvedLocation,
181
- toLocation: routerState.location,
182
- pathChanged:
183
- routerState.location.href !== routerState.resolvedLocation.href,
204
+ fromLocation,
205
+ toLocation,
206
+ pathChanged,
184
207
  })
185
208
 
209
+ router.__store.setState((s) => ({
210
+ ...s,
211
+ status: 'idle',
212
+ resolvedLocation: s.location,
213
+ }))
214
+
186
215
  if ((document as any).querySelector) {
187
- if (routerState.location.hash !== '') {
188
- const el = document.getElementById(routerState.location.hash)
216
+ if (router.state.location.hash !== '') {
217
+ const el = document.getElementById(router.state.location.hash)
189
218
  if (el) {
190
219
  el.scrollIntoView()
191
220
  }
192
221
  }
193
222
  }
194
-
195
- router.__store.setState((s) => ({
196
- ...s,
197
- isTransitioning: false,
198
- resolvedLocation: s.location,
199
- }))
200
- }
201
- }, [
202
- routerState.isTransitioning,
203
- isTransitioning,
204
- routerState.isLoading,
205
- routerState.resolvedLocation,
206
- routerState.location,
207
- router,
208
- ])
209
-
210
- useLayoutEffect(() => {
211
- if (
212
- window.__TSR_DEHYDRATED__ ||
213
- (mountLoadForRouter.current.router === router &&
214
- mountLoadForRouter.current.mounted)
215
- ) {
216
- return
217
223
  }
218
- mountLoadForRouter.current = { router, mounted: true }
219
- tryLoad()
220
- // eslint-disable-next-line react-hooks/exhaustive-deps
221
- }, [router])
224
+ }, [isAnyPending, previousIsAnyPending, router])
222
225
 
223
226
  return null
224
227
  }
@@ -241,3 +244,11 @@ export type RouterProps<
241
244
  router: Router<TRouteTree>
242
245
  context?: Partial<RouterOptions<TRouteTree, TDehydrated>['context']>
243
246
  }
247
+
248
+ function usePrevious<T>(value: T) {
249
+ const ref = React.useRef<T>(value)
250
+ React.useEffect(() => {
251
+ ref.current = value
252
+ })
253
+ return ref.current
254
+ }
package/src/link.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as React from 'react'
2
+ import { flushSync } from 'react-dom'
2
3
  import { useMatch } from './Matches'
3
4
  import { useRouterState } from './useRouterState'
4
5
  import { useRouter } from './useRouter'
@@ -168,8 +169,10 @@ export type NavigateOptions<
168
169
  // `replace` is a boolean that determines whether the navigation should replace the current history entry or push a new one.
169
170
  replace?: boolean
170
171
  resetScroll?: boolean
171
- // If set to `true`, the link's underlying navigate() call will be wrapped in a `React.startTransition` call. Defaults to `true`.
172
+ /** @deprecated All navigations now use startTransition under the hood */
172
173
  startTransition?: boolean
174
+ // if set to `true`, the router will wrap the resulting navigation in a document.startViewTransition() call.
175
+ viewTransition?: boolean
173
176
  }
174
177
 
175
178
  export type ToOptions<
@@ -494,6 +497,7 @@ export function useLinkProps<
494
497
  strict: false,
495
498
  select: (s) => s.pathname,
496
499
  })
500
+ const [isTransitioning, setIsTransitioning] = React.useState(false)
497
501
 
498
502
  const {
499
503
  // custom props
@@ -511,6 +515,7 @@ export function useLinkProps<
511
515
  replace,
512
516
  startTransition,
513
517
  resetScroll,
518
+ viewTransition,
514
519
  // element props
515
520
  children,
516
521
  target,
@@ -602,17 +607,30 @@ export function useLinkProps<
602
607
  ) {
603
608
  e.preventDefault()
604
609
 
610
+ flushSync(() => {
611
+ setIsTransitioning(true)
612
+ })
613
+
614
+ const unsub = router.subscribe('onResolved', () => {
615
+ unsub()
616
+ setIsTransitioning(false)
617
+ })
618
+
605
619
  // All is well? Navigate!
606
- router.commitLocation({ ...next, replace, resetScroll, startTransition })
620
+ router.commitLocation({
621
+ ...next,
622
+ replace,
623
+ resetScroll,
624
+ startTransition,
625
+ viewTransition,
626
+ })
607
627
  }
608
628
  }
609
629
 
610
630
  const doPreload = () => {
611
- React.startTransition(() => {
612
- router.preloadRoute(dest as any).catch((err) => {
613
- console.warn(err)
614
- console.warn(preloadWarning)
615
- })
631
+ router.preloadRoute(dest as any).catch((err) => {
632
+ console.warn(err)
633
+ console.warn(preloadWarning)
616
634
  })
617
635
  }
618
636
 
@@ -707,6 +725,7 @@ export function useLinkProps<
707
725
  'aria-disabled': true,
708
726
  }),
709
727
  ...(isActive && { 'data-status': 'active', 'aria-current': 'page' }),
728
+ ...(isTransitioning && { 'data-transitioning': 'transitioning' }),
710
729
  }
711
730
  }
712
731
 
@@ -746,7 +765,10 @@ export type LinkProps<
746
765
  // If a function is passed as a child, it will be given the `isActive` boolean to aid in further styling on the element it returns
747
766
  children?:
748
767
  | React.ReactNode
749
- | ((state: { isActive: boolean }) => React.ReactNode)
768
+ | ((state: {
769
+ isActive: boolean
770
+ isTransitioning: boolean
771
+ }) => React.ReactNode)
750
772
  }
751
773
 
752
774
  type LinkComponentProps<TComp> = React.PropsWithoutRef<
package/src/router.ts CHANGED
@@ -53,6 +53,7 @@ import type {
53
53
  RoutesByPath,
54
54
  } from './routeInfo'
55
55
  import type {
56
+ ControlledPromise,
56
57
  NonNullableUpdater,
57
58
  PickAsRequired,
58
59
  Timeout,
@@ -129,6 +130,7 @@ export interface RouterOptions<
129
130
  defaultStaleTime?: number
130
131
  defaultPreloadStaleTime?: number
131
132
  defaultPreloadGcTime?: number
133
+ defaultViewTransition?: boolean
132
134
  notFoundMode?: 'root' | 'fuzzy'
133
135
  defaultGcTime?: number
134
136
  caseSensitive?: boolean
@@ -270,11 +272,13 @@ export class Router<
270
272
  Math.random() * 10000000,
271
273
  )}`
272
274
  resetNextScroll = true
275
+ shouldViewTransition?: true = undefined
273
276
  navigateTimeout: Timeout | null = null
274
277
  latestLoadPromise: Promise<void> = Promise.resolve()
275
278
  subscribers = new Set<RouterListener<RouterEvent>>()
276
279
  injectedHtml: Array<InjectedHtmlEntry> = []
277
280
  dehydratedData?: TDehydrated
281
+ viewTransitionPromise?: ControlledPromise<true>
278
282
 
279
283
  // Must build in constructor
280
284
  __store!: Store<RouterState<TRouteTree>>
@@ -388,10 +392,6 @@ export class Router<
388
392
  onUpdate: () => {
389
393
  this.__store.state = {
390
394
  ...this.state,
391
- status:
392
- this.state.isTransitioning || this.state.isLoading
393
- ? 'pending'
394
- : 'idle',
395
395
  cachedMatches: this.state.cachedMatches.filter(
396
396
  (d) => !['redirected'].includes(d.status),
397
397
  ),
@@ -1063,6 +1063,7 @@ export class Router<
1063
1063
 
1064
1064
  commitLocation = async ({
1065
1065
  startTransition,
1066
+ viewTransition,
1066
1067
  ...next
1067
1068
  }: ParsedLocation & CommitLocationOptions) => {
1068
1069
  if (this.navigateTimeout) clearTimeout(this.navigateTimeout)
@@ -1103,18 +1104,14 @@ export class Router<
1103
1104
  }
1104
1105
  }
1105
1106
 
1106
- const apply = () => {
1107
- this.history[next.replace ? 'replace' : 'push'](
1108
- nextHistory.href,
1109
- nextHistory.state,
1110
- )
1107
+ if (viewTransition) {
1108
+ this.shouldViewTransition = true
1111
1109
  }
1112
1110
 
1113
- if (startTransition ?? true) {
1114
- this.startReactTransition(apply)
1115
- } else {
1116
- apply()
1117
- }
1111
+ this.history[next.replace ? 'replace' : 'push'](
1112
+ nextHistory.href,
1113
+ nextHistory.state,
1114
+ )
1118
1115
  }
1119
1116
 
1120
1117
  this.resetNextScroll = next.resetScroll ?? true
@@ -1638,7 +1635,8 @@ export class Router<
1638
1635
  this.latestLoadPromise = promise
1639
1636
 
1640
1637
  let latestPromise: Promise<void> | undefined | null
1641
- ;(async () => {
1638
+
1639
+ this.startReactTransition(async () => {
1642
1640
  try {
1643
1641
  const next = this.latestLocation
1644
1642
  const prevLocation = this.state.resolvedLocation
@@ -1667,6 +1665,7 @@ export class Router<
1667
1665
  // If a cached moved to pendingMatches, remove it from cachedMatches
1668
1666
  this.__store.setState((s) => ({
1669
1667
  ...s,
1668
+ status: 'pending',
1670
1669
  isLoading: true,
1671
1670
  location: next,
1672
1671
  pendingMatches,
@@ -1717,50 +1716,65 @@ export class Router<
1717
1716
  pendingMatches.find((d) => d.id === match.id),
1718
1717
  )
1719
1718
 
1720
- // Commit the pending matches. If a previous match was
1721
- // removed, place it in the cachedMatches
1722
- this.__store.batch(() => {
1723
- this.__store.setState((s) => ({
1724
- ...s,
1725
- isLoading: false,
1726
- matches: s.pendingMatches!,
1727
- pendingMatches: undefined,
1728
- cachedMatches: [
1729
- ...s.cachedMatches,
1730
- ...exitingMatches.filter((d) => d.status !== 'error'),
1731
- ],
1732
- statusCode:
1733
- redirect?.statusCode || notFound
1734
- ? 404
1735
- : s.matches.some((d) => d.status === 'error')
1736
- ? 500
1737
- : 200,
1738
- redirect,
1739
- }))
1740
- this.cleanCache()
1741
- })
1719
+ // Determine if we should start a view transition from the navigation
1720
+ // or from the router default
1721
+ const shouldViewTransition =
1722
+ this.shouldViewTransition ?? this.options.defaultViewTransition
1723
+
1724
+ // Reset the view transition flag
1725
+ delete this.shouldViewTransition
1726
+
1727
+ const apply = () => {
1728
+ // this.viewTransitionPromise = createControlledPromise<true>()
1729
+
1730
+ // Commit the pending matches. If a previous match was
1731
+ // removed, place it in the cachedMatches
1732
+ this.__store.batch(() => {
1733
+ this.__store.setState((s) => ({
1734
+ ...s,
1735
+ isLoading: false,
1736
+ matches: s.pendingMatches!,
1737
+ pendingMatches: undefined,
1738
+ cachedMatches: [
1739
+ ...s.cachedMatches,
1740
+ ...exitingMatches.filter((d) => d.status !== 'error'),
1741
+ ],
1742
+ statusCode:
1743
+ redirect?.statusCode || notFound
1744
+ ? 404
1745
+ : s.matches.some((d) => d.status === 'error')
1746
+ ? 500
1747
+ : 200,
1748
+ redirect,
1749
+ }))
1750
+ this.cleanCache()
1751
+ })
1742
1752
 
1743
- //
1744
- ;(
1745
- [
1746
- [exitingMatches, 'onLeave'],
1747
- [enteringMatches, 'onEnter'],
1748
- [stayingMatches, 'onStay'],
1749
- ] as const
1750
- ).forEach(([matches, hook]) => {
1751
- matches.forEach((match) => {
1752
- this.looseRoutesById[match.routeId]!.options[hook]?.(match)
1753
+ //
1754
+ ;(
1755
+ [
1756
+ [exitingMatches, 'onLeave'],
1757
+ [enteringMatches, 'onEnter'],
1758
+ [stayingMatches, 'onStay'],
1759
+ ] as const
1760
+ ).forEach(([matches, hook]) => {
1761
+ matches.forEach((match) => {
1762
+ this.looseRoutesById[match.routeId]!.options[hook]?.(match)
1763
+ })
1753
1764
  })
1754
- })
1755
1765
 
1756
- this.emit({
1757
- type: 'onLoad',
1758
- fromLocation: prevLocation,
1759
- toLocation: next,
1760
- pathChanged: pathDidChange,
1761
- })
1766
+ resolveLoad()
1767
+
1768
+ // return this.viewTransitionPromise
1769
+ }
1762
1770
 
1763
- resolveLoad()
1771
+ // Attempt to start a view transition (or just apply the changes if we can't)
1772
+ ;(shouldViewTransition && typeof document !== 'undefined'
1773
+ ? document
1774
+ : undefined
1775
+ )
1776
+ // @ts-expect-error
1777
+ ?.startViewTransition?.(apply) || apply()
1764
1778
  } catch (err) {
1765
1779
  // Only apply the latest transition
1766
1780
  if ((latestPromise = this.checkLatest(promise))) {
@@ -1771,7 +1785,7 @@ export class Router<
1771
1785
 
1772
1786
  rejectLoad(err)
1773
1787
  }
1774
- })()
1788
+ })
1775
1789
 
1776
1790
  return this.latestLoadPromise
1777
1791
  }