Skip to content

Commit c15c0da

Browse files
committed
Wire up defensive useOptimistic access for React 18
1 parent 7961270 commit c15c0da

File tree

3 files changed

+219
-99
lines changed

3 files changed

+219
-99
lines changed

packages/react-router/lib/components.tsx

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,32 @@ import {
6969
} from "./hooks";
7070
import type { ViewTransition } from "./dom/global";
7171
import { warnOnce } from "./server-runtime/warnings";
72-
import type {
73-
unstable_ClientInstrumentation,
74-
unstable_InstrumentRouteFunction,
75-
unstable_InstrumentRouterFunction,
76-
} from "./router/instrumentation";
77-
import { instrumentClientSideRouter } from "./router/instrumentation";
72+
import type { unstable_ClientInstrumentation } from "./router/instrumentation";
73+
74+
/**
75+
Webpack can fail to compile on against react versions without this export
76+
complains that `startTransition` doesn't exist in `React`.
77+
78+
Using the string constant directly at runtime fixes the webpack build issue
79+
but can result in terser stripping the actual call at minification time.
80+
81+
Grabbing an exported reference once up front resolves that issue.
82+
83+
See https://github.com/remix-run/react-router/issues/10579
84+
*/
85+
const USE_OPTIMISTIC = "useOptimistic";
86+
// @ts-expect-error Needs React 19 types but we develop against 18
87+
const useOptimisticImpl = React[USE_OPTIMISTIC];
88+
89+
function useOptimisticSafe(val: unknown) {
90+
if (useOptimisticImpl) {
91+
// eslint-disable-next-line react-hooks/rules-of-hooks
92+
return useOptimisticImpl(val);
93+
} else {
94+
// eslint-disable-next-line react-hooks/rules-of-hooks
95+
return React.useState(val);
96+
}
97+
}
7898

7999
export function mapRouteProperties(route: RouteObject) {
80100
let updates: Partial<RouteObject> & { hasErrorBoundary: boolean } = {
@@ -548,8 +568,7 @@ export function RouterProvider({
548568
unstable_transitions,
549569
}: RouterProviderProps): React.ReactElement {
550570
let [_state, setStateImpl] = React.useState(router.state);
551-
// @ts-expect-error - Needs React 19 types
552-
let [state, setOptimisticState] = React.useOptimistic(_state);
571+
let [state, setOptimisticState] = useOptimisticSafe(_state);
553572
let [pendingState, setPendingState] = React.useState<RouterState>();
554573
let [vtContext, setVtContext] = React.useState<ViewTransitionContextObject>({
555574
isTransitioning: false,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export async function loader() {
2+
await new Promise((r) => setTimeout(r, 1000));
3+
return { randomNumber: Math.round(Math.random() * 100) };
4+
}

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

Lines changed: 188 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
11
import * as React from "react";
2-
import { Link, Outlet, useNavigate, useNavigation } from "react-router";
2+
import {
3+
Link,
4+
Outlet,
5+
useFetcher,
6+
useNavigate,
7+
useNavigation,
8+
} from "react-router";
9+
import type { loader as randomNumberLoader } from "./api.random";
310

411
export default function Transitions() {
512
let navigate = useNavigate();
13+
let fetcher = useFetcher<typeof randomNumberLoader>();
614
let navigation = useNavigation();
715
let [pending, startTransition] = React.useTransition();
816
let [count, setCount] = React.useState(0);
917
let [count2, setCount2] = React.useState(0);
18+
let [random, setRandom] = React.useState(0);
19+
20+
React.useEffect(() => {
21+
if (fetcher.data) {
22+
let { randomNumber } = fetcher.data;
23+
startTransition(() => setRandom(randomNumber));
24+
}
25+
}, [fetcher.data]);
26+
1027
return (
1128
<>
1229
<h1>Transitions</h1>
@@ -24,116 +41,196 @@ export default function Transitions() {
2441
<code>unstable_transitions=false</code>
2542
</a>
2643
</nav>
27-
<ul style={{ maxWidth: "600px" }}>
28-
<li>
29-
<button onClick={() => navigate("/transitions/slow")}>
30-
Slow navigation with <code>navigate</code>
31-
</button>
32-
<ul>
33-
<li>
34-
In the current state, <code>useNavigate</code> navigations are not
35-
wrapped in <code>startTransition</code>, so they don't play nice
36-
with other transition-aware state updates
37-
</li>
44+
45+
<div
46+
style={{
47+
display: "grid",
48+
gridTemplateColumns: "1fr 1fr",
49+
gap: 0,
50+
marginTop: "1rem",
51+
border: "1px solid #ccc",
52+
}}
53+
>
54+
<div style={{ padding: "1rem" }}>
55+
<h2>Navigations</h2>
56+
57+
<div>
58+
<p
59+
style={{
60+
color: pending ? "green" : "lightgrey",
61+
fontWeight: pending ? "bold" : "normal",
62+
}}
63+
>
64+
Local React Transition: {pending ? "Pending" : "Idle"}
65+
</p>
66+
<p
67+
style={{
68+
color: navigation.state !== "idle" ? "green" : "lightgrey",
69+
fontWeight: navigation.state !== "idle" ? "bold" : "normal",
70+
}}
71+
>
72+
React Router Navigation State: {navigation.state}
73+
</p>
74+
</div>
75+
76+
<ul style={{ maxWidth: "600px" }}>
3877
<li>
39-
Fixed by{" "}
40-
<code>
41-
&lt;HydrateRouter unstable_transitions={"{"}true{"}"} /&gt;
42-
</code>
78+
<button onClick={() => navigate("/transitions/slow")}>
79+
<code>navigate("/transitions/slow")</code>
80+
</button>
81+
<ul>
82+
<li>
83+
In the current state, <code>useNavigate</code> navigations are
84+
not wrapped in <code>startTransition</code>, so they don't
85+
play nice with other transition-aware state updates
86+
</li>
87+
<li>
88+
Fixed by{" "}
89+
<code>
90+
&lt;HydrateRouter unstable_transitions={"{"}true{"}"} /&gt;
91+
</code>
92+
</li>
93+
</ul>
4394
</li>
44-
</ul>
45-
</li>
4695

47-
<li>
48-
<button
49-
onClick={() => navigate("/transitions/slow", { flushSync: true })}
50-
>
51-
Slow navigation with <code>navigate + flushSync</code>
52-
</button>
53-
<ul>
5496
<li>
55-
With the new flag, useNavigate automatically wraps the navigation
56-
in <code>React.startTransition</code>. Passing the{" "}
57-
<code>flushSync</code> option will opt out of that and apply
58-
<code>React.flushSync</code> to the underlying state update
97+
<button
98+
onClick={() =>
99+
navigate("/transitions/slow", { flushSync: true })
100+
}
101+
>
102+
<code>
103+
navigate("/transitions/slow", {"{"} flushSync: true {"}"})
104+
</code>
105+
</button>
106+
<ul>
107+
<li>
108+
With the new flag, useNavigate automatically wraps the
109+
navigation in <code>React.startTransition</code>. Passing the{" "}
110+
<code>flushSync</code> option will opt out of that and apply
111+
<code>React.flushSync</code> to the underlying state update
112+
</li>
113+
</ul>
59114
</li>
60-
</ul>
61-
</li>
62115

63-
<li>
64-
<button
65-
onClick={() => startTransition(() => navigate("/transitions/slow"))}
66-
>
67-
Slow navigation with local <code>startTransition + navigate</code>
68-
</button>
69-
<ul>
70116
<li>
71-
Once you wrap them in <code>startTransition</code>, they play
72-
nicely with those updates but they prevent our internal
73-
mid-navigation state updates from surfacing
117+
<button
118+
onClick={() =>
119+
startTransition(() => navigate("/transitions/slow"))
120+
}
121+
>
122+
<code>
123+
startTransition(() =&gt; navigate("/transitions/slow")
124+
</code>
125+
</button>
126+
<ul>
127+
<li>
128+
Once you wrap them in <code>startTransition</code>, they play
129+
nicely with those updates but they prevent our internal
130+
mid-navigation state updates from surfacing
131+
</li>
132+
<li>
133+
Fixed by{" "}
134+
<code>
135+
&lt;HydrateRouter unstable_transitions={"{"}true{"}"} /&gt;
136+
</code>
137+
</li>
138+
</ul>
74139
</li>
140+
75141
<li>
76-
Fixed by{" "}
77-
<code>
78-
&lt;HydrateRouter unstable_transitions={"{"}true{"}"} /&gt;
79-
</code>
142+
<Link to="/transitions/slow">
143+
&lt;Link to="/transitions/slow" /&gt;
144+
</Link>
145+
<ul>
146+
<li>
147+
In the current state, <code>&lt;Link&gt;</code> navigations
148+
are not wrapped in startTransition, so they don't play nice
149+
with other transition-aware state updates
150+
</li>
151+
<li>
152+
Fixed by{" "}
153+
<code>
154+
&lt;HydrateRouter unstable_transitions={"{"}true{"}"} /&gt;
155+
</code>
156+
</li>
157+
</ul>
80158
</li>
81-
</ul>
82-
</li>
83159

84-
<li>
85-
<Link to="/transitions/slow">Slow Navigation via &lt;Link&gt;</Link>
86-
<ul>
87160
<li>
88-
In the current state, <code>&lt;Link&gt;</code> navigations are
89-
not wrapped in startTransition, so they don't play nice with other
90-
transition-aware state updates
161+
<Link to="/transitions/parent">/transitions/parent</Link>
91162
</li>
92163
<li>
93-
Fixed by{" "}
94-
<code>
95-
&lt;HydrateRouter unstable_transitions={"{"}true{"}"} /&gt;
96-
</code>
164+
<Link to="/transitions/parent/child">
165+
/transitions/parent/child
166+
</Link>
97167
</li>
98168
</ul>
99-
</li>
169+
</div>
100170

101-
<li>
102-
<Link to="/transitions/parent">/transitions/parent</Link>
103-
</li>
104-
<li>
105-
<Link to="/transitions/parent/child">/transitions/parent/child</Link>
106-
</li>
107-
</ul>
108-
<div>
109-
<p
110-
style={{
111-
color: pending ? "green" : "black",
112-
fontWeight: pending ? "bold" : "normal",
113-
}}
114-
>
115-
React Transition: {pending ? "Pending" : "Idle"}
116-
</p>
117-
<p
118-
style={{
119-
color: navigation.state !== "idle" ? "green" : "black",
120-
fontWeight: navigation.state !== "idle" ? "bold" : "normal",
121-
}}
122-
>
123-
React Router Navigation State: {navigation.state}
124-
</p>
171+
<div style={{ padding: "1rem" }}>
172+
<div>
173+
<h2>Fetchers</h2>
174+
<button onClick={() => fetcher.load("/api/random")}>
175+
fetcher.load("/api/random")
176+
</button>
177+
<br />
178+
<br />
179+
<button
180+
onClick={() => startTransition(() => fetcher.load("/api/random"))}
181+
>
182+
startTransition(() =&gt; fetcher.load("/api/random"))
183+
</button>
184+
<p
185+
style={{
186+
color: fetcher.state !== "idle" ? "green" : "lightgrey",
187+
fontWeight: fetcher.state !== "idle" ? "bold" : "normal",
188+
}}
189+
>
190+
Fetcher State: <strong>{fetcher.state}</strong>
191+
</p>
192+
<p
193+
style={{
194+
color: fetcher.data != null ? "green" : "lightgrey",
195+
fontWeight: fetcher.data != null ? "bold" : "normal",
196+
}}
197+
>
198+
Fetcher Data:{" "}
199+
{fetcher.data ? JSON.stringify(fetcher.data) : "null"}
200+
</p>
201+
<p
202+
style={{
203+
color: random !== 0 ? "green" : "lightgrey",
204+
fontWeight: random !== 0 ? "bold" : "normal",
205+
}}
206+
>
207+
Transition-aware fetcher data: {random}
208+
</p>
209+
</div>
210+
211+
<div>
212+
<h2>Counters</h2>
213+
<button onClick={() => setCount((c) => c + 1)}>
214+
<code>setCount(c =&gt; c + 1)</code>
215+
</button>{" "}
216+
<span>Count = {count}</span>
217+
<br />
218+
<br />
219+
<button
220+
onClick={() =>
221+
React.startTransition(() => setCount2((c) => c + 1))
222+
}
223+
>
224+
<code>startTransition(() =&gt; setCount2(c =&gt; c + 1))</code>
225+
</button>{" "}
226+
<span>Count2 = {count2}</span>
227+
</div>
228+
</div>
125229
</div>
126-
<button onClick={() => setCount((c) => c + 1)}>
127-
Increment counter w/o transition {count}
128-
</button>{" "}
129-
<button
130-
onClick={() => React.startTransition(() => setCount2((c) => c + 1))}
131-
>
132-
Increment counter w/transition {count2}
133-
</button>
230+
134231
<Outlet />
135232
<p>
136-
TODO: Is it possible to demonstrate the issue with
233+
TODO: Is it possible to demonstrate the issue with{" "}
137234
<code>React.useSyncExternalStore</code> where the global opt-out is
138235
needed?
139236
</p>

0 commit comments

Comments
 (0)