Skip to content

Commit 8aa2f03

Browse files
committed
Lift auto-transition to Link/Form
1 parent 0615b45 commit 8aa2f03

File tree

3 files changed

+84
-136
lines changed

3 files changed

+84
-136
lines changed

packages/react-router/lib/dom/lib.tsx

Lines changed: 45 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,7 +1381,8 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
13811381
},
13821382
forwardedRef,
13831383
) {
1384-
let { basename } = React.useContext(NavigationContext);
1384+
let { basename, unstable_transitions } =
1385+
React.useContext(NavigationContext);
13851386
let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to);
13861387

13871388
// Rendered into <a href> for absolute URLs
@@ -1432,6 +1433,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
14321433
preventScrollReset,
14331434
relative,
14341435
viewTransition,
1436+
unstable_transitions,
14351437
});
14361438
function handleClick(
14371439
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
@@ -1975,6 +1977,7 @@ export const Form = React.forwardRef<HTMLFormElement, FormProps>(
19751977
},
19761978
forwardedRef,
19771979
) => {
1980+
let { unstable_transitions } = React.useContext(NavigationContext);
19781981
let submit = useSubmit();
19791982
let formAction = useFormAction(action, { relative });
19801983
let formMethod: HTMLFormMethod =
@@ -1994,16 +1997,24 @@ export const Form = React.forwardRef<HTMLFormElement, FormProps>(
19941997
(submitter?.getAttribute("formmethod") as HTMLFormMethod | undefined) ||
19951998
method;
19961999

1997-
submit(submitter || event.currentTarget, {
1998-
fetcherKey,
1999-
method: submitMethod,
2000-
navigate,
2001-
replace,
2002-
state,
2003-
relative,
2004-
preventScrollReset,
2005-
viewTransition,
2006-
});
2000+
let doSubmit = () =>
2001+
submit(submitter || event.currentTarget, {
2002+
fetcherKey,
2003+
method: submitMethod,
2004+
navigate,
2005+
replace,
2006+
state,
2007+
relative,
2008+
preventScrollReset,
2009+
viewTransition,
2010+
});
2011+
2012+
if (unstable_transitions && navigate === true) {
2013+
// @ts-expect-error Needs React 19 types
2014+
React.startTransition(() => doSubmit());
2015+
} else {
2016+
doSubmit();
2017+
}
20072018
};
20082019

20092020
return (
@@ -2218,6 +2229,9 @@ function useDataRouterState(hookName: DataRouterStateHook) {
22182229
* @param options.viewTransition Enables a [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API)
22192230
* for this navigation. To apply specific styles during the transition, see
22202231
* {@link useViewTransitionState}. Defaults to `false`.
2232+
* @param options.unstable_transitions Wraps the navigation in
2233+
* [`React.startTransition`](https://react.dev/reference/react/startTransition)
2234+
* for concurrent rendering. Defaults to `false`.
22212235
* @returns A click handler function that can be used in a custom {@link Link} component.
22222236
*/
22232237
export function useLinkClickHandler<E extends Element = HTMLAnchorElement>(
@@ -2229,13 +2243,15 @@ export function useLinkClickHandler<E extends Element = HTMLAnchorElement>(
22292243
preventScrollReset,
22302244
relative,
22312245
viewTransition,
2246+
unstable_transitions,
22322247
}: {
22332248
target?: React.HTMLAttributeAnchorTarget;
22342249
replace?: boolean;
22352250
state?: any;
22362251
preventScrollReset?: boolean;
22372252
relative?: RelativeRoutingType;
22382253
viewTransition?: boolean;
2254+
unstable_transitions?: boolean;
22392255
} = {},
22402256
): (event: React.MouseEvent<E, MouseEvent>) => void {
22412257
let navigate = useNavigate();
@@ -2254,13 +2270,21 @@ export function useLinkClickHandler<E extends Element = HTMLAnchorElement>(
22542270
? replaceProp
22552271
: createPath(location) === createPath(path);
22562272

2257-
navigate(to, {
2258-
replace,
2259-
state,
2260-
preventScrollReset,
2261-
relative,
2262-
viewTransition,
2263-
});
2273+
let doNavigate = () =>
2274+
navigate(to, {
2275+
replace,
2276+
state,
2277+
preventScrollReset,
2278+
relative,
2279+
viewTransition,
2280+
});
2281+
2282+
if (unstable_transitions) {
2283+
// @ts-expect-error Needs React 19 types
2284+
React.startTransition(() => doNavigate());
2285+
} else {
2286+
doNavigate();
2287+
}
22642288
}
22652289
},
22662290
[
@@ -2274,6 +2298,7 @@ export function useLinkClickHandler<E extends Element = HTMLAnchorElement>(
22742298
preventScrollReset,
22752299
relative,
22762300
viewTransition,
2301+
unstable_transitions,
22772302
],
22782303
);
22792304
}
@@ -2579,7 +2604,7 @@ let getUniqueFetcherId = () => `__${String(++fetcherId)}__`;
25792604
*/
25802605
export function useSubmit(): SubmitFunction {
25812606
let { router } = useDataRouterContext(DataRouterHook.UseSubmit);
2582-
let { basename, unstable_transitions } = React.useContext(NavigationContext);
2607+
let { basename } = React.useContext(NavigationContext);
25832608
let currentRouteId = useRouteId();
25842609

25852610
return React.useCallback<SubmitFunction>(
@@ -2599,29 +2624,6 @@ export function useSubmit(): SubmitFunction {
25992624
formEncType: options.encType || (encType as FormEncType),
26002625
flushSync: options.flushSync,
26012626
});
2602-
} else if (unstable_transitions) {
2603-
await new Promise<void>((resolve, reject) => {
2604-
// @ts-expect-error Needs React 19 types
2605-
React.startTransition(async () => {
2606-
try {
2607-
await router.navigate(options.action || action, {
2608-
preventScrollReset: options.preventScrollReset,
2609-
formData,
2610-
body,
2611-
formMethod: options.method || (method as HTMLFormMethod),
2612-
formEncType: options.encType || (encType as FormEncType),
2613-
replace: options.replace,
2614-
state: options.state,
2615-
fromRouteId: currentRouteId,
2616-
flushSync: options.flushSync,
2617-
viewTransition: options.viewTransition,
2618-
});
2619-
resolve();
2620-
} catch (e) {
2621-
reject(e);
2622-
}
2623-
});
2624-
});
26252627
} else {
26262628
await router.navigate(options.action || action, {
26272629
preventScrollReset: options.preventScrollReset,
@@ -2637,7 +2639,7 @@ export function useSubmit(): SubmitFunction {
26372639
});
26382640
}
26392641
},
2640-
[router, basename, currentRouteId, unstable_transitions],
2642+
[router, basename, currentRouteId],
26412643
);
26422644
}
26432645

packages/react-router/lib/hooks.tsx

Lines changed: 11 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -382,8 +382,7 @@ function useNavigateUnstable(): NavigateFunction {
382382
);
383383

384384
let dataRouterContext = React.useContext(DataRouterContext);
385-
let { basename, navigator, unstable_transitions } =
386-
React.useContext(NavigationContext);
385+
let { basename, navigator } = React.useContext(NavigationContext);
387386
let { matches } = React.useContext(RouteContext);
388387
let { pathname: locationPathname } = useLocation();
389388

@@ -403,11 +402,7 @@ function useNavigateUnstable(): NavigateFunction {
403402
if (!activeRef.current) return;
404403

405404
if (typeof to === "number") {
406-
if (unstable_transitions && !options.flushSync) {
407-
React.startTransition(() => navigator.go(to));
408-
} else {
409-
navigator.go(to);
410-
}
405+
navigator.go(to);
411406
return;
412407
}
413408

@@ -431,29 +426,18 @@ function useNavigateUnstable(): NavigateFunction {
431426
: joinPaths([basename, path.pathname]);
432427
}
433428

434-
if (unstable_transitions && !options.flushSync) {
435-
React.startTransition(() => {
436-
if (options.replace === true) {
437-
navigator.replace(path, options.state, options);
438-
} else {
439-
navigator.push(path, options.state, options);
440-
}
441-
});
442-
} else {
443-
if (options.replace === true) {
444-
navigator.replace(path, options.state, options);
445-
} else {
446-
navigator.push(path, options.state, options);
447-
}
448-
}
429+
(!!options.replace ? navigator.replace : navigator.push)(
430+
path,
431+
options.state,
432+
options,
433+
);
449434
},
450435
[
451436
basename,
452437
navigator,
453438
routePathnamesJson,
454439
locationPathname,
455440
dataRouterContext,
456-
unstable_transitions,
457441
],
458442
);
459443

@@ -1836,7 +1820,6 @@ export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker {
18361820
// a RouterProvider.
18371821
function useNavigateStable(): NavigateFunction {
18381822
let { router } = useDataRouterContext(DataRouterHook.UseNavigateStable);
1839-
let { unstable_transitions } = React.useContext(NavigationContext);
18401823
let id = useCurrentRouteId(DataRouterStateHook.UseNavigateStable);
18411824

18421825
let activeRef = React.useRef(false);
@@ -1852,32 +1835,13 @@ function useNavigateStable(): NavigateFunction {
18521835
// is useless because we haven't wired up our router subscriber yet
18531836
if (!activeRef.current) return;
18541837

1855-
if (unstable_transitions && !options.flushSync) {
1856-
await new Promise<void>((resolve, reject) => {
1857-
// @ts-expect-error Needs React 19 types
1858-
React.startTransition(async () => {
1859-
try {
1860-
if (typeof to === "number") {
1861-
// TODO: I don't think go navigations are promise-aware currently
1862-
await router.navigate(to);
1863-
} else {
1864-
await router.navigate(to, { fromRouteId: id, ...options });
1865-
}
1866-
resolve();
1867-
} catch (e) {
1868-
reject(e);
1869-
}
1870-
});
1871-
});
1838+
if (typeof to === "number") {
1839+
router.navigate(to);
18721840
} else {
1873-
if (typeof to === "number") {
1874-
router.navigate(to);
1875-
} else {
1876-
await router.navigate(to, { fromRouteId: id, ...options });
1877-
}
1841+
await router.navigate(to, { fromRouteId: id, ...options });
18781842
}
18791843
},
1880-
[router, id, unstable_transitions],
1844+
[router, id],
18811845
);
18821846

18831847
return navigate;

playground/react-transitions/app/routes/transitions.tsx

Lines changed: 28 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -75,81 +75,63 @@ export default function Transitions() {
7575

7676
<ul style={{ maxWidth: "600px" }}>
7777
<li>
78-
<button onClick={() => navigate("/transitions/slow")}>
79-
<code>navigate("/transitions/slow")</code>
80-
</button>
78+
<Link to="/transitions/slow">
79+
&lt;Link to="/transitions/slow" /&gt;
80+
</Link>
8181
<ul>
8282
<li>
83-
In the current state, <code>useNavigate</code> doesn't wrap
84-
the navigation in <code>startTransition</code>, so they don't
85-
play nice with other transition-aware state updates (try
86-
updating the transition-aware counter mid-navigation)
83+
In the current state, <code>&lt;Link&gt;</code> navigations
84+
are not wrapped in <code>startTransition</code>, so they don't
85+
play nice with other transition-aware state updates
86+
</li>
87+
<li>
88+
Navigate and increment the transition-enabled counter during
89+
the navigation. We should not see the counter updates
90+
reflected until the navigation ends in a "suspense enabled"
91+
router
8792
</li>
8893
<li>
8994
With the new flag, they are wrapped in{" "}
90-
<code>startTransition</code>
95+
<code>startTransition</code> and the count syncs up properly
9196
</li>
9297
</ul>
9398
</li>
9499

95100
<li>
96-
<button
97-
onClick={() =>
98-
// @ts-expect-error Needs React 19 types
99-
startTransition(() => navigate("/transitions/slow"))
100-
}
101-
>
102-
<code>
103-
startTransition(() =&gt; navigate("/transitions/slow")
104-
</code>
101+
<button onClick={() => navigate("/transitions/slow")}>
102+
<code>navigate("/transitions/slow")</code>
105103
</button>
106104
<ul>
107105
<li>
108-
If you wrap them in <code>startTransition</code> manually,
109-
they play nicely with those updates but they prevent our
110-
internal mid-navigation state updates from surfacing
111-
</li>
112-
<li>
113-
That can be fixed by enabling <code>useOptimistic</code>{" "}
114-
inside <code>&lt;RouterProvider&gt;</code>
106+
<code>useNavigate</code> is is not wrapped in startTransitoion
107+
with or without the enw flag, so it should never sync with the
108+
transition-enabled counter
115109
</li>
116110
</ul>
117111
</li>
118112

119113
<li>
120114
<button
121115
onClick={() =>
122-
navigate("/transitions/slow", { flushSync: true })
116+
// @ts-expect-error Needs React 19 types
117+
startTransition(() => navigate("/transitions/slow"))
123118
}
124119
>
125120
<code>
126-
navigate("/transitions/slow", {"{"} flushSync: true {"}"})
121+
startTransition(() =&gt; navigate("/transitions/slow")
127122
</code>
128123
</button>
129124
<ul>
130125
<li>
131-
Once <code>useNavigate</code> is wrapped automatically,
132-
passing the
133-
<code>flushSync</code> option will opt out of{" "}
134-
<code>startTransition</code> and apply
135-
<code>React.flushSync</code> to the underlying state update
136-
</li>
137-
</ul>
138-
</li>
139-
140-
<li>
141-
<Link to="/transitions/slow">
142-
&lt;Link to="/transitions/slow" /&gt;
143-
</Link>
144-
<ul>
145-
<li>
146-
In the current state, <code>&lt;Link&gt;</code> navigations
147-
are not wrapped in <code>startTransition</code>, so they don't
148-
play nice with other transition-aware state updates
126+
If you wrap <code>useNavigate</code> in{" "}
127+
<code>startTransition</code> manually, then it syncs with the
128+
counter.
149129
</li>
150130
<li>
151-
With the new flag, they are wrapped in{" "}
152-
<code>startTransition</code>
131+
Without the flag, our router state updates don't surface
132+
during the navigation. Enabling the flag wraps out internal
133+
updates with <code>useOptimistic</code> to allow them to
134+
surface
153135
</li>
154136
</ul>
155137
</li>

0 commit comments

Comments
 (0)