Skip to content

Commit 36426e6

Browse files
authored
Allow uncached IO to stablize (#25561)
Initial draft. I need to test this more. If a promise is passed to `use`, but the I/O isn't cached, we should still be able to unwrap it. This already worked in Server Components, and during SSR. For Fiber (in the browser), before this fix the state would get lost between attempts unless the promise resolved immediately in a microtask, which requires IO to be cached. This was due to an implementation quirk of Fiber where the state is reset as soon as the stack unwinds. The workaround is to suspend the entire Fiber work loop until the promise resolves. The Server Components and SSR runtimes don't require a workaround: they can maintain multiple parallel child tasks and reuse the state indefinitely across attempts. That's ideally how Fiber should work, too, but it will require larger refactor. The downside of our approach in Fiber is that it won't "warm up" the siblings while you're suspended, but to avoid waterfalls you're supposed to hoist data fetches higher in the tree regardless. But we have other ideas for how we can add this back in the future. (Though again, this doesn't affect Server Components, which already have the ideal behavior.)
1 parent 6883d79 commit 36426e6

File tree

3 files changed

+388
-45
lines changed

3 files changed

+388
-45
lines changed

packages/react-reconciler/src/ReactFiberWorkLoop.new.js

+115-15
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99

1010
import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols';
1111

12-
import type {Wakeable} from 'shared/ReactTypes';
12+
import type {Wakeable, Thenable} from 'shared/ReactTypes';
1313
import type {Fiber, FiberRoot} from './ReactInternalTypes';
1414
import type {Lanes, Lane} from './ReactFiberLane.new';
15-
import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
15+
import type {
16+
SuspenseProps,
17+
SuspenseState,
18+
} from './ReactFiberSuspenseComponent.new';
1619
import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.new';
1720
import type {EventPriority} from './ReactEventPriorities.new';
1821
import type {
@@ -271,6 +274,10 @@ import {
271274
isThenableStateResolved,
272275
} from './ReactFiberThenable.new';
273276
import {schedulePostPaintCallback} from './ReactPostPaintCallback';
277+
import {
278+
getSuspenseHandler,
279+
isBadSuspenseFallback,
280+
} from './ReactFiberSuspenseContext.new';
274281

275282
const ceil = Math.ceil;
276283

@@ -312,7 +319,7 @@ let workInProgressRootRenderLanes: Lanes = NoLanes;
312319
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4;
313320
const NotSuspended: SuspendedReason = 0;
314321
const SuspendedOnError: SuspendedReason = 1;
315-
// const SuspendedOnData: SuspendedReason = 2;
322+
const SuspendedOnData: SuspendedReason = 2;
316323
const SuspendedOnImmediate: SuspendedReason = 3;
317324
const SuspendedAndReadyToUnwind: SuspendedReason = 4;
318325

@@ -706,6 +713,18 @@ export function scheduleUpdateOnFiber(
706713
}
707714
}
708715

716+
// Check if the work loop is currently suspended and waiting for data to
717+
// finish loading.
718+
if (
719+
workInProgressSuspendedReason === SuspendedOnData &&
720+
root === workInProgressRoot
721+
) {
722+
// The incoming update might unblock the current render. Interrupt the
723+
// current attempt and restart from the top.
724+
prepareFreshStack(root, NoLanes);
725+
markRootSuspended(root, workInProgressRootRenderLanes);
726+
}
727+
709728
// Mark that the root has a pending update.
710729
markRootUpdated(root, lane, eventTime);
711730

@@ -1130,6 +1149,20 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
11301149
if (root.callbackNode === originalCallbackNode) {
11311150
// The task node scheduled for this root is the same one that's
11321151
// currently executed. Need to return a continuation.
1152+
if (
1153+
workInProgressSuspendedReason === SuspendedOnData &&
1154+
workInProgressRoot === root
1155+
) {
1156+
// Special case: The work loop is currently suspended and waiting for
1157+
// data to resolve. Unschedule the current task.
1158+
//
1159+
// TODO: The factoring is a little weird. Arguably this should be checked
1160+
// in ensureRootIsScheduled instead. I went back and forth, not totally
1161+
// sure yet.
1162+
root.callbackPriority = NoLane;
1163+
root.callbackNode = null;
1164+
return null;
1165+
}
11331166
return performConcurrentWorkOnRoot.bind(null, root);
11341167
}
11351168
return null;
@@ -1739,7 +1772,9 @@ function handleThrow(root, thrownValue): void {
17391772
// deprecate the old API in favor of `use`.
17401773
thrownValue = getSuspendedThenable();
17411774
workInProgressSuspendedThenableState = getThenableStateAfterSuspending();
1742-
workInProgressSuspendedReason = SuspendedOnImmediate;
1775+
workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves()
1776+
? SuspendedOnData
1777+
: SuspendedOnImmediate;
17431778
} else {
17441779
// This is a regular error. If something earlier in the component already
17451780
// suspended, we must clear the thenable state to unblock the work loop.
@@ -1796,6 +1831,48 @@ function handleThrow(root, thrownValue): void {
17961831
}
17971832
}
17981833

1834+
function shouldAttemptToSuspendUntilDataResolves() {
1835+
// TODO: We should be able to move the
1836+
// renderDidSuspend/renderDidSuspendWithDelay logic into this function,
1837+
// instead of repeating it in the complete phase. Or something to that effect.
1838+
1839+
if (includesOnlyRetries(workInProgressRootRenderLanes)) {
1840+
// We can always wait during a retry.
1841+
return true;
1842+
}
1843+
1844+
// TODO: We should be able to remove the equivalent check in
1845+
// finishConcurrentRender, and rely just on this one.
1846+
if (includesOnlyTransitions(workInProgressRootRenderLanes)) {
1847+
const suspenseHandler = getSuspenseHandler();
1848+
if (suspenseHandler !== null && suspenseHandler.tag === SuspenseComponent) {
1849+
const currentSuspenseHandler = suspenseHandler.alternate;
1850+
const nextProps: SuspenseProps = suspenseHandler.memoizedProps;
1851+
if (isBadSuspenseFallback(currentSuspenseHandler, nextProps)) {
1852+
// The nearest Suspense boundary is already showing content. We should
1853+
// avoid replacing it with a fallback, and instead wait until the
1854+
// data finishes loading.
1855+
return true;
1856+
} else {
1857+
// This is not a bad fallback condition. We should show a fallback
1858+
// immediately instead of waiting for the data to resolve. This includes
1859+
// when suspending inside new trees.
1860+
return false;
1861+
}
1862+
}
1863+
1864+
// During a transition, if there is no Suspense boundary (i.e. suspending in
1865+
// the "shell" of an application), or if we're inside a hidden tree, then
1866+
// we should wait until the data finishes loading.
1867+
return true;
1868+
}
1869+
1870+
// For all other Lanes besides Transitions and Retries, we should not wait
1871+
// for the data to load.
1872+
// TODO: We should wait during Offscreen prerendering, too.
1873+
return false;
1874+
}
1875+
17991876
function pushDispatcher(container) {
18001877
prepareRendererToRender(container);
18011878
const prevDispatcher = ReactCurrentDispatcher.current;
@@ -2060,7 +2137,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
20602137
markRenderStarted(lanes);
20612138
}
20622139

2063-
do {
2140+
outer: do {
20642141
try {
20652142
if (
20662143
workInProgressSuspendedReason !== NotSuspended &&
@@ -2070,19 +2147,48 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
20702147
// replay the suspended component.
20712148
const unitOfWork = workInProgress;
20722149
const thrownValue = workInProgressThrownValue;
2073-
workInProgressSuspendedReason = NotSuspended;
2074-
workInProgressThrownValue = null;
20752150
switch (workInProgressSuspendedReason) {
20762151
case SuspendedOnError: {
20772152
// Unwind then continue with the normal work loop.
2153+
workInProgressSuspendedReason = NotSuspended;
2154+
workInProgressThrownValue = null;
20782155
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
20792156
break;
20802157
}
2158+
case SuspendedOnData: {
2159+
const didResolve =
2160+
workInProgressSuspendedThenableState !== null &&
2161+
isThenableStateResolved(workInProgressSuspendedThenableState);
2162+
if (didResolve) {
2163+
workInProgressSuspendedReason = NotSuspended;
2164+
workInProgressThrownValue = null;
2165+
replaySuspendedUnitOfWork(unitOfWork, thrownValue);
2166+
} else {
2167+
// The work loop is suspended on data. We should wait for it to
2168+
// resolve before continuing to render.
2169+
const thenable: Thenable<mixed> = (workInProgressThrownValue: any);
2170+
const onResolution = () => {
2171+
ensureRootIsScheduled(root, now());
2172+
};
2173+
thenable.then(onResolution, onResolution);
2174+
break outer;
2175+
}
2176+
break;
2177+
}
2178+
case SuspendedOnImmediate: {
2179+
// If this fiber just suspended, it's possible the data is already
2180+
// cached. Yield to the main thread to give it a chance to ping. If
2181+
// it does, we can retry immediately without unwinding the stack.
2182+
workInProgressSuspendedReason = SuspendedAndReadyToUnwind;
2183+
break outer;
2184+
}
20812185
default: {
2082-
const wasPinged =
2186+
workInProgressSuspendedReason = NotSuspended;
2187+
workInProgressThrownValue = null;
2188+
const didResolve =
20832189
workInProgressSuspendedThenableState !== null &&
20842190
isThenableStateResolved(workInProgressSuspendedThenableState);
2085-
if (wasPinged) {
2191+
if (didResolve) {
20862192
replaySuspendedUnitOfWork(unitOfWork, thrownValue);
20872193
} else {
20882194
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
@@ -2096,12 +2202,6 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
20962202
break;
20972203
} catch (thrownValue) {
20982204
handleThrow(root, thrownValue);
2099-
if (workInProgressSuspendedThenableState !== null) {
2100-
// If this fiber just suspended, it's possible the data is already
2101-
// cached. Yield to the main thread to give it a chance to ping. If
2102-
// it does, we can retry immediately without unwinding the stack.
2103-
break;
2104-
}
21052205
}
21062206
} while (true);
21072207
resetContextDependencies();

0 commit comments

Comments
 (0)