From a375c3bda101a93047083452799c503956652d97 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sat, 13 Aug 2022 15:30:54 -0400 Subject: [PATCH 1/5] Internal `act`: Unwrapping resolved promises This update our internal implementation of `act` to support React's new behavior for unwrapping promises. Like we did with Scheduler, when something suspends, it will yield to the main thread so the microtasks can run, then continue in a new task. I need to implement the same behavior in the public version of `act`, but there are some additional considerations so I'll do that in a separate commit. --- packages/jest-react/src/internalAct.js | 36 ++++++++++++------- .../src/__tests__/ReactWakeable-test.js | 22 ++++++------ packages/scheduler/src/forks/SchedulerMock.js | 6 ++++ 3 files changed, 40 insertions(+), 24 deletions(-) diff --git a/packages/jest-react/src/internalAct.js b/packages/jest-react/src/internalAct.js index 52b1d6a7c2fc4..4e41f6234c928 100644 --- a/packages/jest-react/src/internalAct.js +++ b/packages/jest-react/src/internalAct.js @@ -23,7 +23,7 @@ import enqueueTask from 'shared/enqueueTask'; let actingUpdatesScopeDepth = 0; export function act(scope: () => Thenable | T): Thenable { - if (Scheduler.unstable_flushAllWithoutAsserting === undefined) { + if (Scheduler.unstable_flushUntilNextPaint === undefined) { throw Error( 'This version of `act` requires a special mock build of Scheduler.', ); @@ -120,19 +120,31 @@ export function act(scope: () => Thenable | T): Thenable { } function flushActWork(resolve, reject) { - // Flush suspended fallbacks - // $FlowFixMe: Flow doesn't know about global Jest object - jest.runOnlyPendingTimers(); - enqueueTask(() => { + if (Scheduler.unstable_hasPendingWork()) { try { - const didFlushWork = Scheduler.unstable_flushAllWithoutAsserting(); - if (didFlushWork) { - flushActWork(resolve, reject); - } else { - resolve(); - } + Scheduler.unstable_flushUntilNextPaint(); } catch (error) { reject(error); } - }); + + // If Scheduler yields while there's still work, it's so that we can + // unblock the main thread (e.g. for paint or for microtasks). Yield to + // the main thread and continue in a new task. + enqueueTask(() => flushActWork(resolve, reject)); + return; + } + + // Once the scheduler queue is empty, run all the timers. The purpose of this + // is to force any pending fallbacks to commit. The public version of act does + // this with dev-only React runtime logic, but since our internal act needs to + // work work production builds of React, we have to cheat. + // $FlowFixMe: Flow doesn't know about global Jest object + jest.runOnlyPendingTimers(); + if (Scheduler.unstable_hasPendingWork()) { + // Committing a fallback scheduled additional work. Continue flushing. + flushActWork(resolve, reject); + return; + } + + resolve(); } diff --git a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js index 848962c696c0b..baa4d870a5eb3 100644 --- a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js +++ b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js @@ -45,23 +45,21 @@ describe('ReactWakeable', () => { ); } + const root = ReactNoop.createRoot(); await act(async () => { startTransition(() => { - ReactNoop.render(); + root.render(); }); + }); + expect(Scheduler).toHaveYielded([ // React will yield when the async component suspends. - expect(Scheduler).toFlushUntilNextPaint(['Suspend!']); - - // Wait for microtasks to resolve - // TODO: The async form of `act` should automatically yield to microtasks - // when a continuation is returned, the way Scheduler does. - await null; - - expect(Scheduler).toHaveYielded(['Resolve in microtask']); - }); + 'Suspend!', + 'Resolve in microtask', - // Finished rendering without unwinding the stack. - expect(Scheduler).toHaveYielded(['Async']); + // Finished rendering without unwinding the stack or preparing a fallback. + 'Async', + ]); + expect(root).toMatchRenderedOutput('Async'); }); }); diff --git a/packages/scheduler/src/forks/SchedulerMock.js b/packages/scheduler/src/forks/SchedulerMock.js index a1374005ed05c..68dac0a7e6876 100644 --- a/packages/scheduler/src/forks/SchedulerMock.js +++ b/packages/scheduler/src/forks/SchedulerMock.js @@ -517,6 +517,11 @@ function unstable_flushUntilNextPaint(): void { isFlushing = false; } } + return false; +} + +function unstable_hasPendingWork(): boolean { + return scheduledCallback !== null; } function unstable_flushExpired() { @@ -644,6 +649,7 @@ export { unstable_flushExpired, unstable_clearYields, unstable_flushUntilNextPaint, + unstable_hasPendingWork, unstable_flushAll, unstable_yieldValue, unstable_advanceTime, From 4fb3c99cd47427183fd72bf49abb90d3b2647e6c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sat, 13 Aug 2022 23:34:20 -0400 Subject: [PATCH 2/5] Move throwException to after work loop resumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit throwException is the function that finds the nearest boundary and schedules it for a second render pass. We should only call it right before we unwind the stack — not if we receive an immediate ping and render the fiber again. This was an oversight in 8ef3a7c that I didn't notice because it happens to mostly work, anyway. What made me notice the mistake is that throwException also marks the entire render phase as suspended (RootDidSuspend or RootDidSuspendWithDelay), which is only supposed to be happen if we show a fallback. One consequence was that, in the RootDidSuspendWithDelay case, the entire commit phase was blocked, because that's the exit status we use to block a bad fallback from appearing. --- .../src/ReactFiberThrow.new.js | 19 +- .../src/ReactFiberThrow.old.js | 19 +- .../src/ReactFiberWakeable.new.js | 4 + .../src/ReactFiberWakeable.old.js | 4 + .../src/ReactFiberWorkLoop.new.js | 231 ++++++++++-------- .../src/ReactFiberWorkLoop.old.js | 231 ++++++++++-------- .../src/__tests__/ReactWakeable-test.js | 31 +++ 7 files changed, 325 insertions(+), 214 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index c17218f83be4e..4a05c4ec871b8 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -159,7 +159,11 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { +export function attachPingListener( + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { // Attach a ping listener // // The data might resolve before we have a chance to commit the fallback. Or, @@ -357,7 +361,7 @@ function throwException( sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, -): Wakeable | null { +): void { // The source fiber did not complete. sourceFiber.flags |= Incomplete; @@ -459,7 +463,7 @@ function throwException( if (suspenseBoundary.mode & ConcurrentMode) { attachPingListener(root, wakeable, rootRenderLanes); } - return wakeable; + return; } else { // No boundary was found. Unless this is a sync update, this is OK. // We can suspend and wait for more data to arrive. @@ -474,7 +478,7 @@ function throwException( // This case also applies to initial hydration. attachPingListener(root, wakeable, rootRenderLanes); renderDidSuspendDelayIfPossible(); - return wakeable; + return; } // This is a sync/discrete update. We treat this case like an error @@ -517,7 +521,7 @@ function throwException( // Even though the user may not be affected by this error, we should // still log it so it can be fixed. queueHydrationError(createCapturedValueAtFiber(value, sourceFiber)); - return null; + return; } } else { // Otherwise, fall through to the error path. @@ -540,7 +544,7 @@ function throwException( workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); const update = createRootErrorUpdate(workInProgress, errorInfo, lane); enqueueCapturedUpdate(workInProgress, update); - return null; + return; } case ClassComponent: // Capture and retry @@ -564,7 +568,7 @@ function throwException( lane, ); enqueueCapturedUpdate(workInProgress, update); - return null; + return; } break; default: @@ -572,7 +576,6 @@ function throwException( } workInProgress = workInProgress.return; } while (workInProgress !== null); - return null; } export {throwException, createRootErrorUpdate, createClassErrorUpdate}; diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index d6c5255807f32..f953e89657e6e 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -159,7 +159,11 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { +export function attachPingListener( + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { // Attach a ping listener // // The data might resolve before we have a chance to commit the fallback. Or, @@ -357,7 +361,7 @@ function throwException( sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, -): Wakeable | null { +): void { // The source fiber did not complete. sourceFiber.flags |= Incomplete; @@ -459,7 +463,7 @@ function throwException( if (suspenseBoundary.mode & ConcurrentMode) { attachPingListener(root, wakeable, rootRenderLanes); } - return wakeable; + return; } else { // No boundary was found. Unless this is a sync update, this is OK. // We can suspend and wait for more data to arrive. @@ -474,7 +478,7 @@ function throwException( // This case also applies to initial hydration. attachPingListener(root, wakeable, rootRenderLanes); renderDidSuspendDelayIfPossible(); - return wakeable; + return; } // This is a sync/discrete update. We treat this case like an error @@ -517,7 +521,7 @@ function throwException( // Even though the user may not be affected by this error, we should // still log it so it can be fixed. queueHydrationError(createCapturedValueAtFiber(value, sourceFiber)); - return null; + return; } } else { // Otherwise, fall through to the error path. @@ -540,7 +544,7 @@ function throwException( workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); const update = createRootErrorUpdate(workInProgress, errorInfo, lane); enqueueCapturedUpdate(workInProgress, update); - return null; + return; } case ClassComponent: // Capture and retry @@ -564,7 +568,7 @@ function throwException( lane, ); enqueueCapturedUpdate(workInProgress, update); - return null; + return; } break; default: @@ -572,7 +576,6 @@ function throwException( } workInProgress = workInProgress.return; } while (workInProgress !== null); - return null; } export {throwException, createRootErrorUpdate, createClassErrorUpdate}; diff --git a/packages/react-reconciler/src/ReactFiberWakeable.new.js b/packages/react-reconciler/src/ReactFiberWakeable.new.js index 589d61eae814a..2f2f0b81c9439 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.new.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.new.js @@ -15,6 +15,10 @@ let adHocSuspendCount: number = 0; const MAX_AD_HOC_SUSPEND_COUNT = 50; +export function isTrackingSuspendedWakeable() { + return suspendedWakeable !== null; +} + export function suspendedWakeableWasPinged() { return wasPinged; } diff --git a/packages/react-reconciler/src/ReactFiberWakeable.old.js b/packages/react-reconciler/src/ReactFiberWakeable.old.js index 589d61eae814a..2f2f0b81c9439 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.old.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.old.js @@ -15,6 +15,10 @@ let adHocSuspendCount: number = 0; const MAX_AD_HOC_SUSPEND_COUNT = 50; +export function isTrackingSuspendedWakeable() { + return suspendedWakeable !== null; +} + export function suspendedWakeableWasPinged() { return wasPinged; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index a1416e6c4cf06..c47c6ec5eeafb 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -174,6 +174,7 @@ import { throwException, createRootErrorUpdate, createClassErrorUpdate, + attachPingListener, } from './ReactFiberThrow.new'; import { commitBeforeMutationEffects, @@ -257,6 +258,7 @@ import { trackSuspendedWakeable, suspendedWakeableWasPinged, attemptToPingSuspendedWakeable, + isTrackingSuspendedWakeable, } from './ReactFiberWakeable.new'; const ceil = Math.ceil; @@ -298,6 +300,7 @@ let workInProgressRootRenderLanes: Lanes = NoLanes; // after this happens. If the fiber is pinged before we resume, we can retry // immediately instead of unwinding the stack. let workInProgressIsSuspended: boolean = false; +let workInProgressThrownValue: mixed = null; // A contextual version of workInProgressRootRenderLanes. It is a superset of // the lanes that we started working on at the root. When we enter a subtree @@ -1569,6 +1572,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgress = rootWorkInProgress; workInProgressRootRenderLanes = renderLanes = lanes; workInProgressIsSuspended = false; + workInProgressThrownValue = null; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; @@ -1587,94 +1591,72 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { return rootWorkInProgress; } -function handleError(root, thrownValue): Wakeable | null { - do { - let erroredWork = workInProgress; - try { - // Reset module-level state that was set during the render phase. - resetContextDependencies(); - resetHooksAfterThrow(); - resetCurrentDebugFiberInDEV(); - // TODO: I found and added this missing line while investigating a - // separate issue. Write a regression test using string refs. - ReactCurrentOwner.current = null; - - if (erroredWork === null || erroredWork.return === null) { - // Expected to be working on a non-root fiber. This is a fatal error - // because there's no ancestor that can handle it; the root is - // supposed to capture all errors that weren't caught by an error - // boundary. - workInProgressRootExitStatus = RootFatalErrored; - workInProgressRootFatalError = thrownValue; - // Set `workInProgress` to null. This represents advancing to the next - // sibling, or the parent if there are no siblings. But since the root - // has no siblings nor a parent, we set it to null. Usually this is - // handled by `completeUnitOfWork` or `unwindWork`, but since we're - // intentionally not calling those, we need set it here. - // TODO: Consider calling `unwindWork` to pop the contexts. - workInProgress = null; - return null; - } +function handleThrow(root, thrownValue): void { + // Reset module-level state that was set during the render phase. + resetContextDependencies(); + resetHooksAfterThrow(); + resetCurrentDebugFiberInDEV(); + // TODO: I found and added this missing line while investigating a + // separate issue. Write a regression test using string refs. + ReactCurrentOwner.current = null; - if (enableProfilerTimer && erroredWork.mode & ProfileMode) { - // Record the time spent rendering before an error was thrown. This - // avoids inaccurate Profiler durations in the case of a - // suspended render. - stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); - } + // Setting this to `true` tells the work loop to unwind the stack instead + // of entering the begin phase. It's called "suspended" because it usually + // happens because of Suspense, but it also applies to errors. Think of it + // as suspending the execution of the work loop. + workInProgressIsSuspended = true; + workInProgressThrownValue = thrownValue; + + const erroredWork = workInProgress; + if (erroredWork === null) { + // This is a fatal error + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + return; + } - if (enableSchedulingProfiler) { - markComponentRenderStopped(); + const isWakeable = + thrownValue !== null && + typeof thrownValue === 'object' && + typeof thrownValue.then === 'function'; - if ( - thrownValue !== null && - typeof thrownValue === 'object' && - typeof thrownValue.then === 'function' - ) { - const wakeable: Wakeable = (thrownValue: any); - markComponentSuspended( - erroredWork, - wakeable, - workInProgressRootRenderLanes, - ); - } else { - markComponentErrored( - erroredWork, - thrownValue, - workInProgressRootRenderLanes, - ); - } - } + if (enableProfilerTimer && erroredWork.mode & ProfileMode) { + // Record the time spent rendering before an error was thrown. This + // avoids inaccurate Profiler durations in the case of a + // suspended render. + stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); + } - const maybeWakeable = throwException( - root, - erroredWork.return, + if (enableSchedulingProfiler) { + markComponentRenderStopped(); + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + markComponentSuspended( + erroredWork, + wakeable, + workInProgressRootRenderLanes, + ); + } else { + markComponentErrored( erroredWork, thrownValue, workInProgressRootRenderLanes, ); - // Setting this to `true` tells the work loop to unwind the stack instead - // of entering the begin phase. It's called "suspended" because it usually - // happens because of Suspense, but it also applies to errors. Think of it - // as suspending the execution of the work loop. - workInProgressIsSuspended = true; - - // Return to the normal work loop. - return maybeWakeable; - } catch (yetAnotherThrownValue) { - // Something in the return path also threw. - thrownValue = yetAnotherThrownValue; - if (workInProgress === erroredWork && erroredWork !== null) { - // If this boundary has already errored, then we had trouble processing - // the error. Bubble it to the next boundary. - erroredWork = erroredWork.return; - workInProgress = erroredWork; - } else { - erroredWork = workInProgress; - } - continue; } - } while (true); + } + + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + // Attach the ping listener now, before we yield to the main thread. + // TODO: We don't need to attach a real ping listener here — we only need + // to confirm that this wakeable's status will be updated when it resolves. + // That logic isn't implemented until a later commit, though, so I'll leave + // this until then. + if (root.tag !== LegacyRoot) { + attachPingListener(root, wakeable, workInProgressRootRenderLanes); + trackSuspendedWakeable(wakeable); + } + } } function pushDispatcher() { @@ -1800,7 +1782,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { workLoopSync(); break; } catch (thrownValue) { - handleError(root, thrownValue); + handleThrow(root, thrownValue); } } while (true); resetContextDependencies(); @@ -1838,10 +1820,15 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { function workLoopSync() { // Perform work without checking if we need to yield between fiber. - if (workInProgressIsSuspended && workInProgress !== null) { + if (workInProgressIsSuspended) { // The current work-in-progress was already attempted. We need to unwind // it before we continue the normal work loop. - resumeSuspendedUnitOfWork(workInProgress); + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } } while (workInProgress !== null) { @@ -1893,12 +1880,11 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { workLoopConcurrent(); break; } catch (thrownValue) { - const maybeWakeable = handleError(root, thrownValue); - if (maybeWakeable !== null) { + handleThrow(root, thrownValue); + if (isTrackingSuspendedWakeable()) { // If this fiber just suspended, it's possible the data is already // cached. Yield to the the main thread to give it a chance to ping. If // it does, we can retry immediately without unwinding the stack. - trackSuspendedWakeable(maybeWakeable); break; } } @@ -1940,10 +1926,15 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { function workLoopConcurrent() { // Perform work until Scheduler asks us to yield - if (workInProgressIsSuspended && workInProgress !== null) { + if (workInProgressIsSuspended) { // The current work-in-progress was already attempted. We need to unwind // it before we continue the normal work loop. - resumeSuspendedUnitOfWork(workInProgress); + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } } while (workInProgress !== null && !shouldYield()) { @@ -1979,27 +1970,71 @@ function performUnitOfWork(unitOfWork: Fiber): void { ReactCurrentOwner.current = null; } -function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void { +function resumeSuspendedUnitOfWork( + unitOfWork: Fiber, + thrownValue: mixed, +): void { // This is a fork of performUnitOfWork specifcally for resuming a fiber that // just suspended. In some cases, we may choose to retry the fiber immediately // instead of unwinding the stack. It's a separate function to keep the // additional logic out of the work loop's hot path. - if (!suspendedWakeableWasPinged()) { + const wasPinged = suspendedWakeableWasPinged(); + resetWakeableState(); + + if (!wasPinged) { // The wakeable wasn't pinged. Return to the normal work loop. This will // unwind the stack, and potentially result in showing a fallback. - workInProgressIsSuspended = false; - resetWakeableState(); + + const returnFiber = unitOfWork.return; + if (returnFiber === null || workInProgressRoot === null) { + // Expected to be working on a non-root fiber. This is a fatal error + // because there's no ancestor that can handle it; the root is + // supposed to capture all errors that weren't caught by an error + // boundary. + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + // Set `workInProgress` to null. This represents advancing to the next + // sibling, or the parent if there are no siblings. But since the root + // has no siblings nor a parent, we set it to null. Usually this is + // handled by `completeUnitOfWork` or `unwindWork`, but since we're + // intentionally not calling those, we need set it here. + // TODO: Consider calling `unwindWork` to pop the contexts. + workInProgress = null; + return; + } + + try { + // Find and mark the nearest Suspense or error boundary that can handle + // this "exception". + throwException( + workInProgressRoot, + returnFiber, + unitOfWork, + thrownValue, + workInProgressRootRenderLanes, + ); + } catch (error) { + // We had trouble processing the error. An example of this happening is + // when accessing the `componentDidCatch` property of an error boundary + // throws an error. A weird edge case. There's a regression test for this. + // To prevent an infinite loop, bubble the error up to the next parent. + workInProgress = returnFiber; + throw error; + } + + // Return to the normal work loop. completeUnitOfWork(unitOfWork); return; } // The work-in-progress was immediately pinged. Instead of unwinding the - // stack and potentially showing a fallback, reset the fiber and try rendering - // it again. + // stack and potentially showing a fallback, unwind only the last stack frame, + // reset the fiber, and try rendering it again. + const current = unitOfWork.alternate; + unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); - const current = unitOfWork.alternate; setCurrentDebugFiberInDEV(unitOfWork); let next; @@ -2011,10 +2046,8 @@ function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void { next = beginWork(current, unitOfWork, renderLanes); } - // The begin phase finished successfully without suspending. Reset the state - // used to track the fiber while it was suspended. Then return to the normal - // work loop. - workInProgressIsSuspended = false; + // The begin phase finished successfully without suspending. Return to the + // normal work loop. resetWakeableState(); resetCurrentDebugFiberInDEV(); @@ -3138,7 +3171,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { throw originalError; } - // Keep this code in sync with handleError; any changes here must have + // Keep this code in sync with handleThrow; any changes here must have // corresponding changes there. resetContextDependencies(); resetHooksAfterThrow(); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 8343bad2ab488..0cf96d78113ca 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -174,6 +174,7 @@ import { throwException, createRootErrorUpdate, createClassErrorUpdate, + attachPingListener, } from './ReactFiberThrow.old'; import { commitBeforeMutationEffects, @@ -257,6 +258,7 @@ import { trackSuspendedWakeable, suspendedWakeableWasPinged, attemptToPingSuspendedWakeable, + isTrackingSuspendedWakeable, } from './ReactFiberWakeable.old'; const ceil = Math.ceil; @@ -298,6 +300,7 @@ let workInProgressRootRenderLanes: Lanes = NoLanes; // after this happens. If the fiber is pinged before we resume, we can retry // immediately instead of unwinding the stack. let workInProgressIsSuspended: boolean = false; +let workInProgressThrownValue: mixed = null; // A contextual version of workInProgressRootRenderLanes. It is a superset of // the lanes that we started working on at the root. When we enter a subtree @@ -1569,6 +1572,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgress = rootWorkInProgress; workInProgressRootRenderLanes = renderLanes = lanes; workInProgressIsSuspended = false; + workInProgressThrownValue = null; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; @@ -1587,94 +1591,72 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { return rootWorkInProgress; } -function handleError(root, thrownValue): Wakeable | null { - do { - let erroredWork = workInProgress; - try { - // Reset module-level state that was set during the render phase. - resetContextDependencies(); - resetHooksAfterThrow(); - resetCurrentDebugFiberInDEV(); - // TODO: I found and added this missing line while investigating a - // separate issue. Write a regression test using string refs. - ReactCurrentOwner.current = null; - - if (erroredWork === null || erroredWork.return === null) { - // Expected to be working on a non-root fiber. This is a fatal error - // because there's no ancestor that can handle it; the root is - // supposed to capture all errors that weren't caught by an error - // boundary. - workInProgressRootExitStatus = RootFatalErrored; - workInProgressRootFatalError = thrownValue; - // Set `workInProgress` to null. This represents advancing to the next - // sibling, or the parent if there are no siblings. But since the root - // has no siblings nor a parent, we set it to null. Usually this is - // handled by `completeUnitOfWork` or `unwindWork`, but since we're - // intentionally not calling those, we need set it here. - // TODO: Consider calling `unwindWork` to pop the contexts. - workInProgress = null; - return null; - } +function handleThrow(root, thrownValue): void { + // Reset module-level state that was set during the render phase. + resetContextDependencies(); + resetHooksAfterThrow(); + resetCurrentDebugFiberInDEV(); + // TODO: I found and added this missing line while investigating a + // separate issue. Write a regression test using string refs. + ReactCurrentOwner.current = null; - if (enableProfilerTimer && erroredWork.mode & ProfileMode) { - // Record the time spent rendering before an error was thrown. This - // avoids inaccurate Profiler durations in the case of a - // suspended render. - stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); - } + // Setting this to `true` tells the work loop to unwind the stack instead + // of entering the begin phase. It's called "suspended" because it usually + // happens because of Suspense, but it also applies to errors. Think of it + // as suspending the execution of the work loop. + workInProgressIsSuspended = true; + workInProgressThrownValue = thrownValue; + + const erroredWork = workInProgress; + if (erroredWork === null) { + // This is a fatal error + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + return; + } - if (enableSchedulingProfiler) { - markComponentRenderStopped(); + const isWakeable = + thrownValue !== null && + typeof thrownValue === 'object' && + typeof thrownValue.then === 'function'; - if ( - thrownValue !== null && - typeof thrownValue === 'object' && - typeof thrownValue.then === 'function' - ) { - const wakeable: Wakeable = (thrownValue: any); - markComponentSuspended( - erroredWork, - wakeable, - workInProgressRootRenderLanes, - ); - } else { - markComponentErrored( - erroredWork, - thrownValue, - workInProgressRootRenderLanes, - ); - } - } + if (enableProfilerTimer && erroredWork.mode & ProfileMode) { + // Record the time spent rendering before an error was thrown. This + // avoids inaccurate Profiler durations in the case of a + // suspended render. + stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); + } - const maybeWakeable = throwException( - root, - erroredWork.return, + if (enableSchedulingProfiler) { + markComponentRenderStopped(); + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + markComponentSuspended( + erroredWork, + wakeable, + workInProgressRootRenderLanes, + ); + } else { + markComponentErrored( erroredWork, thrownValue, workInProgressRootRenderLanes, ); - // Setting this to `true` tells the work loop to unwind the stack instead - // of entering the begin phase. It's called "suspended" because it usually - // happens because of Suspense, but it also applies to errors. Think of it - // as suspending the execution of the work loop. - workInProgressIsSuspended = true; - - // Return to the normal work loop. - return maybeWakeable; - } catch (yetAnotherThrownValue) { - // Something in the return path also threw. - thrownValue = yetAnotherThrownValue; - if (workInProgress === erroredWork && erroredWork !== null) { - // If this boundary has already errored, then we had trouble processing - // the error. Bubble it to the next boundary. - erroredWork = erroredWork.return; - workInProgress = erroredWork; - } else { - erroredWork = workInProgress; - } - continue; } - } while (true); + } + + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + // Attach the ping listener now, before we yield to the main thread. + // TODO: We don't need to attach a real ping listener here — we only need + // to confirm that this wakeable's status will be updated when it resolves. + // That logic isn't implemented until a later commit, though, so I'll leave + // this until then. + if (root.tag !== LegacyRoot) { + attachPingListener(root, wakeable, workInProgressRootRenderLanes); + trackSuspendedWakeable(wakeable); + } + } } function pushDispatcher() { @@ -1800,7 +1782,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { workLoopSync(); break; } catch (thrownValue) { - handleError(root, thrownValue); + handleThrow(root, thrownValue); } } while (true); resetContextDependencies(); @@ -1838,10 +1820,15 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { function workLoopSync() { // Perform work without checking if we need to yield between fiber. - if (workInProgressIsSuspended && workInProgress !== null) { + if (workInProgressIsSuspended) { // The current work-in-progress was already attempted. We need to unwind // it before we continue the normal work loop. - resumeSuspendedUnitOfWork(workInProgress); + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } } while (workInProgress !== null) { @@ -1893,12 +1880,11 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { workLoopConcurrent(); break; } catch (thrownValue) { - const maybeWakeable = handleError(root, thrownValue); - if (maybeWakeable !== null) { + handleThrow(root, thrownValue); + if (isTrackingSuspendedWakeable()) { // If this fiber just suspended, it's possible the data is already // cached. Yield to the the main thread to give it a chance to ping. If // it does, we can retry immediately without unwinding the stack. - trackSuspendedWakeable(maybeWakeable); break; } } @@ -1940,10 +1926,15 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { function workLoopConcurrent() { // Perform work until Scheduler asks us to yield - if (workInProgressIsSuspended && workInProgress !== null) { + if (workInProgressIsSuspended) { // The current work-in-progress was already attempted. We need to unwind // it before we continue the normal work loop. - resumeSuspendedUnitOfWork(workInProgress); + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } } while (workInProgress !== null && !shouldYield()) { @@ -1979,27 +1970,71 @@ function performUnitOfWork(unitOfWork: Fiber): void { ReactCurrentOwner.current = null; } -function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void { +function resumeSuspendedUnitOfWork( + unitOfWork: Fiber, + thrownValue: mixed, +): void { // This is a fork of performUnitOfWork specifcally for resuming a fiber that // just suspended. In some cases, we may choose to retry the fiber immediately // instead of unwinding the stack. It's a separate function to keep the // additional logic out of the work loop's hot path. - if (!suspendedWakeableWasPinged()) { + const wasPinged = suspendedWakeableWasPinged(); + resetWakeableState(); + + if (!wasPinged) { // The wakeable wasn't pinged. Return to the normal work loop. This will // unwind the stack, and potentially result in showing a fallback. - workInProgressIsSuspended = false; - resetWakeableState(); + + const returnFiber = unitOfWork.return; + if (returnFiber === null || workInProgressRoot === null) { + // Expected to be working on a non-root fiber. This is a fatal error + // because there's no ancestor that can handle it; the root is + // supposed to capture all errors that weren't caught by an error + // boundary. + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + // Set `workInProgress` to null. This represents advancing to the next + // sibling, or the parent if there are no siblings. But since the root + // has no siblings nor a parent, we set it to null. Usually this is + // handled by `completeUnitOfWork` or `unwindWork`, but since we're + // intentionally not calling those, we need set it here. + // TODO: Consider calling `unwindWork` to pop the contexts. + workInProgress = null; + return; + } + + try { + // Find and mark the nearest Suspense or error boundary that can handle + // this "exception". + throwException( + workInProgressRoot, + returnFiber, + unitOfWork, + thrownValue, + workInProgressRootRenderLanes, + ); + } catch (error) { + // We had trouble processing the error. An example of this happening is + // when accessing the `componentDidCatch` property of an error boundary + // throws an error. A weird edge case. There's a regression test for this. + // To prevent an infinite loop, bubble the error up to the next parent. + workInProgress = returnFiber; + throw error; + } + + // Return to the normal work loop. completeUnitOfWork(unitOfWork); return; } // The work-in-progress was immediately pinged. Instead of unwinding the - // stack and potentially showing a fallback, reset the fiber and try rendering - // it again. + // stack and potentially showing a fallback, unwind only the last stack frame, + // reset the fiber, and try rendering it again. + const current = unitOfWork.alternate; + unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); - const current = unitOfWork.alternate; setCurrentDebugFiberInDEV(unitOfWork); let next; @@ -2011,10 +2046,8 @@ function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void { next = beginWork(current, unitOfWork, renderLanes); } - // The begin phase finished successfully without suspending. Reset the state - // used to track the fiber while it was suspended. Then return to the normal - // work loop. - workInProgressIsSuspended = false; + // The begin phase finished successfully without suspending. Return to the + // normal work loop. resetWakeableState(); resetCurrentDebugFiberInDEV(); @@ -3138,7 +3171,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { throw originalError; } - // Keep this code in sync with handleError; any changes here must have + // Keep this code in sync with handleThrow; any changes here must have // corresponding changes there. resetContextDependencies(); resetHooksAfterThrow(); diff --git a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js index baa4d870a5eb3..80a11aa5be687 100644 --- a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js +++ b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js @@ -62,4 +62,35 @@ describe('ReactWakeable', () => { ]); expect(root).toMatchRenderedOutput('Async'); }); + + test('if suspended fiber is pinged in a microtask, it does not block a transition from completing', async () => { + let resolved = false; + function Async() { + if (resolved) { + return ; + } + Scheduler.unstable_yieldValue('Suspend!'); + throw Promise.resolve().then(() => { + Scheduler.unstable_yieldValue('Resolve in microtask'); + resolved = true; + }); + } + + function App() { + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded([ + 'Suspend!', + 'Resolve in microtask', + 'Async', + ]); + expect(root).toMatchRenderedOutput('Async'); + }); }); From 20a5eee81a947ffc460ab7fc258d7fe872b461fc Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sun, 14 Aug 2022 01:11:59 -0400 Subject: [PATCH 3/5] Use expando to check whether promise has resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `status` expando to a thrown thenable to track when its value has resolved. In a later step, we'll also use `value` and `reason` expandos to track the resolved value. This is not part of the official JavaScript spec — think of it as an extension of the Promise API, or a custom interface that is a superset of Thenable. However, it's inspired by the terminology used by `Promise.allSettled`. The intent is that this will be a public API — Suspense implementations can set these expandos to allow React to unwrap the value synchronously without waiting a microtask. --- .../src/ReactFiberThrow.new.js | 6 +- .../src/ReactFiberThrow.old.js | 6 +- .../src/ReactFiberWakeable.new.js | 84 ++++++++++++++----- .../src/ReactFiberWakeable.old.js | 84 ++++++++++++++----- .../src/ReactFiberWorkLoop.new.js | 66 ++++++--------- .../src/ReactFiberWorkLoop.old.js | 66 ++++++--------- .../src/__tests__/ReactWakeable-test.js | 40 +++++++++ packages/shared/ReactTypes.js | 33 ++++++-- 8 files changed, 252 insertions(+), 133 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 4a05c4ec871b8..3536417dc87e2 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -159,11 +159,7 @@ function createClassErrorUpdate( return update; } -export function attachPingListener( - root: FiberRoot, - wakeable: Wakeable, - lanes: Lanes, -) { +function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { // Attach a ping listener // // The data might resolve before we have a chance to commit the fallback. Or, diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index f953e89657e6e..9177dd34ad326 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -159,11 +159,7 @@ function createClassErrorUpdate( return update; } -export function attachPingListener( - root: FiberRoot, - wakeable: Wakeable, - lanes: Lanes, -) { +function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { // Attach a ping listener // // The data might resolve before we have a chance to commit the fallback. Or, diff --git a/packages/react-reconciler/src/ReactFiberWakeable.new.js b/packages/react-reconciler/src/ReactFiberWakeable.new.js index 2f2f0b81c9439..1b3c4ec6c3bf9 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.new.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.new.js @@ -7,41 +7,87 @@ * @flow */ -import type {Wakeable} from 'shared/ReactTypes'; +import type { + Wakeable, + Thenable, + PendingThenable, + FulfilledThenable, + RejectedThenable, +} from 'shared/ReactTypes'; -let suspendedWakeable: Wakeable | null = null; -let wasPinged = false; +let suspendedThenable: Thenable | null = null; let adHocSuspendCount: number = 0; const MAX_AD_HOC_SUSPEND_COUNT = 50; -export function isTrackingSuspendedWakeable() { - return suspendedWakeable !== null; +export function isTrackingSuspendedThenable() { + return suspendedThenable !== null; } -export function suspendedWakeableWasPinged() { - return wasPinged; +export function suspendedThenableDidResolve() { + if (suspendedThenable !== null) { + const status = suspendedThenable.status; + return status === 'fulfilled' || status === 'rejected'; + } + return false; } export function trackSuspendedWakeable(wakeable: Wakeable) { + // If this wakeable isn't already a thenable, turn it into one now. Then, + // when we resume the work loop, we can check if its status is + // still pending. + // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. + const thenable: Thenable = (wakeable: any); + adHocSuspendCount++; - suspendedWakeable = wakeable; -} + suspendedThenable = thenable; -export function attemptToPingSuspendedWakeable(wakeable: Wakeable) { - if (wakeable === suspendedWakeable) { - // This ping is from the wakeable that just suspended. Mark it as pinged. - // When the work loop resumes, we'll immediately try rendering the fiber - // again instead of unwinding the stack. - wasPinged = true; - return true; + // We use an expando to track the status and result of a thenable so that we + // can synchronously unwrap the value. Think of this as an extension of the + // Promise API, or a custom interface that is a superset of Thenable. + // + // If the thenable doesn't have a status, set it to "pending" and attach + // a listener that will update its status and result when it resolves. + switch (thenable.status) { + case 'pending': + // Since the status is already "pending", we can assume it will be updated + // when it resolves, either by React or something in userspace. + break; + case 'fulfilled': + case 'rejected': + // A thenable that already resolved shouldn't have been thrown, so this is + // unexpected. Suggests a mistake in a userspace data library. Don't track + // this thenable, because if we keep trying it will likely infinite loop + // without ever resolving. + // TODO: Log a warning? + suspendedThenable = null; + break; + default: { + const pendingThenable: PendingThenable = (thenable: any); + pendingThenable.status = 'pending'; + pendingThenable.then( + fulfilledValue => { + if (thenable.status === 'pending') { + const fulfilledThenable: FulfilledThenable = (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = fulfilledValue; + } + }, + (error: mixed) => { + if (thenable.status === 'pending') { + const rejectedThenable: RejectedThenable = (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + }, + ); + break; + } } - return false; } export function resetWakeableState() { - suspendedWakeable = null; - wasPinged = false; + suspendedThenable = null; adHocSuspendCount = 0; } diff --git a/packages/react-reconciler/src/ReactFiberWakeable.old.js b/packages/react-reconciler/src/ReactFiberWakeable.old.js index 2f2f0b81c9439..1b3c4ec6c3bf9 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.old.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.old.js @@ -7,41 +7,87 @@ * @flow */ -import type {Wakeable} from 'shared/ReactTypes'; +import type { + Wakeable, + Thenable, + PendingThenable, + FulfilledThenable, + RejectedThenable, +} from 'shared/ReactTypes'; -let suspendedWakeable: Wakeable | null = null; -let wasPinged = false; +let suspendedThenable: Thenable | null = null; let adHocSuspendCount: number = 0; const MAX_AD_HOC_SUSPEND_COUNT = 50; -export function isTrackingSuspendedWakeable() { - return suspendedWakeable !== null; +export function isTrackingSuspendedThenable() { + return suspendedThenable !== null; } -export function suspendedWakeableWasPinged() { - return wasPinged; +export function suspendedThenableDidResolve() { + if (suspendedThenable !== null) { + const status = suspendedThenable.status; + return status === 'fulfilled' || status === 'rejected'; + } + return false; } export function trackSuspendedWakeable(wakeable: Wakeable) { + // If this wakeable isn't already a thenable, turn it into one now. Then, + // when we resume the work loop, we can check if its status is + // still pending. + // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. + const thenable: Thenable = (wakeable: any); + adHocSuspendCount++; - suspendedWakeable = wakeable; -} + suspendedThenable = thenable; -export function attemptToPingSuspendedWakeable(wakeable: Wakeable) { - if (wakeable === suspendedWakeable) { - // This ping is from the wakeable that just suspended. Mark it as pinged. - // When the work loop resumes, we'll immediately try rendering the fiber - // again instead of unwinding the stack. - wasPinged = true; - return true; + // We use an expando to track the status and result of a thenable so that we + // can synchronously unwrap the value. Think of this as an extension of the + // Promise API, or a custom interface that is a superset of Thenable. + // + // If the thenable doesn't have a status, set it to "pending" and attach + // a listener that will update its status and result when it resolves. + switch (thenable.status) { + case 'pending': + // Since the status is already "pending", we can assume it will be updated + // when it resolves, either by React or something in userspace. + break; + case 'fulfilled': + case 'rejected': + // A thenable that already resolved shouldn't have been thrown, so this is + // unexpected. Suggests a mistake in a userspace data library. Don't track + // this thenable, because if we keep trying it will likely infinite loop + // without ever resolving. + // TODO: Log a warning? + suspendedThenable = null; + break; + default: { + const pendingThenable: PendingThenable = (thenable: any); + pendingThenable.status = 'pending'; + pendingThenable.then( + fulfilledValue => { + if (thenable.status === 'pending') { + const fulfilledThenable: FulfilledThenable = (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = fulfilledValue; + } + }, + (error: mixed) => { + if (thenable.status === 'pending') { + const rejectedThenable: RejectedThenable = (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + }, + ); + break; + } } - return false; } export function resetWakeableState() { - suspendedWakeable = null; - wasPinged = false; + suspendedThenable = null; adHocSuspendCount = 0; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index c47c6ec5eeafb..e119919334add 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -174,7 +174,6 @@ import { throwException, createRootErrorUpdate, createClassErrorUpdate, - attachPingListener, } from './ReactFiberThrow.new'; import { commitBeforeMutationEffects, @@ -256,9 +255,8 @@ import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new import { resetWakeableState, trackSuspendedWakeable, - suspendedWakeableWasPinged, - attemptToPingSuspendedWakeable, - isTrackingSuspendedWakeable, + suspendedThenableDidResolve, + isTrackingSuspendedThenable, } from './ReactFiberWakeable.new'; const ceil = Math.ceil; @@ -1647,15 +1645,8 @@ function handleThrow(root, thrownValue): void { if (isWakeable) { const wakeable: Wakeable = (thrownValue: any); - // Attach the ping listener now, before we yield to the main thread. - // TODO: We don't need to attach a real ping listener here — we only need - // to confirm that this wakeable's status will be updated when it resolves. - // That logic isn't implemented until a later commit, though, so I'll leave - // this until then. - if (root.tag !== LegacyRoot) { - attachPingListener(root, wakeable, workInProgressRootRenderLanes); - trackSuspendedWakeable(wakeable); - } + + trackSuspendedWakeable(wakeable); } } @@ -1881,7 +1872,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { break; } catch (thrownValue) { handleThrow(root, thrownValue); - if (isTrackingSuspendedWakeable()) { + if (isTrackingSuspendedThenable()) { // If this fiber just suspended, it's possible the data is already // cached. Yield to the the main thread to give it a chance to ping. If // it does, we can retry immediately without unwinding the stack. @@ -1979,11 +1970,11 @@ function resumeSuspendedUnitOfWork( // instead of unwinding the stack. It's a separate function to keep the // additional logic out of the work loop's hot path. - const wasPinged = suspendedWakeableWasPinged(); + const wasPinged = suspendedThenableDidResolve(); resetWakeableState(); if (!wasPinged) { - // The wakeable wasn't pinged. Return to the normal work loop. This will + // The thenable wasn't pinged. Return to the normal work loop. This will // unwind the stack, and potentially result in showing a fallback. const returnFiber = unitOfWork.return; @@ -2873,31 +2864,26 @@ export function pingSuspendedRoot( // Received a ping at the same priority level at which we're currently // rendering. We might want to restart this render. This should mirror // the logic of whether or not a root suspends once it completes. - const didPingSuspendedWakeable = attemptToPingSuspendedWakeable(wakeable); - if (didPingSuspendedWakeable) { - // Successfully pinged the in-progress fiber. Don't unwind the stack. - } else { - // TODO: If we're rendering sync either due to Sync, Batched or expired, - // we should probably never restart. + // TODO: If we're rendering sync either due to Sync, Batched or expired, + // we should probably never restart. - // If we're suspended with delay, or if it's a retry, we'll always suspend - // so we can always restart. - if ( - workInProgressRootExitStatus === RootSuspendedWithDelay || - (workInProgressRootExitStatus === RootSuspended && - includesOnlyRetries(workInProgressRootRenderLanes) && - now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) - ) { - // Restart from the root. - prepareFreshStack(root, NoLanes); - } else { - // Even though we can't restart right now, we might get an - // opportunity later. So we mark this render as having a ping. - workInProgressRootPingedLanes = mergeLanes( - workInProgressRootPingedLanes, - pingedLanes, - ); - } + // If we're suspended with delay, or if it's a retry, we'll always suspend + // so we can always restart. + if ( + workInProgressRootExitStatus === RootSuspendedWithDelay || + (workInProgressRootExitStatus === RootSuspended && + includesOnlyRetries(workInProgressRootRenderLanes) && + now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) + ) { + // Restart from the root. + prepareFreshStack(root, NoLanes); + } else { + // Even though we can't restart right now, we might get an + // opportunity later. So we mark this render as having a ping. + workInProgressRootPingedLanes = mergeLanes( + workInProgressRootPingedLanes, + pingedLanes, + ); } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 0cf96d78113ca..d4b01dc20f378 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -174,7 +174,6 @@ import { throwException, createRootErrorUpdate, createClassErrorUpdate, - attachPingListener, } from './ReactFiberThrow.old'; import { commitBeforeMutationEffects, @@ -256,9 +255,8 @@ import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.old import { resetWakeableState, trackSuspendedWakeable, - suspendedWakeableWasPinged, - attemptToPingSuspendedWakeable, - isTrackingSuspendedWakeable, + suspendedThenableDidResolve, + isTrackingSuspendedThenable, } from './ReactFiberWakeable.old'; const ceil = Math.ceil; @@ -1647,15 +1645,8 @@ function handleThrow(root, thrownValue): void { if (isWakeable) { const wakeable: Wakeable = (thrownValue: any); - // Attach the ping listener now, before we yield to the main thread. - // TODO: We don't need to attach a real ping listener here — we only need - // to confirm that this wakeable's status will be updated when it resolves. - // That logic isn't implemented until a later commit, though, so I'll leave - // this until then. - if (root.tag !== LegacyRoot) { - attachPingListener(root, wakeable, workInProgressRootRenderLanes); - trackSuspendedWakeable(wakeable); - } + + trackSuspendedWakeable(wakeable); } } @@ -1881,7 +1872,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { break; } catch (thrownValue) { handleThrow(root, thrownValue); - if (isTrackingSuspendedWakeable()) { + if (isTrackingSuspendedThenable()) { // If this fiber just suspended, it's possible the data is already // cached. Yield to the the main thread to give it a chance to ping. If // it does, we can retry immediately without unwinding the stack. @@ -1979,11 +1970,11 @@ function resumeSuspendedUnitOfWork( // instead of unwinding the stack. It's a separate function to keep the // additional logic out of the work loop's hot path. - const wasPinged = suspendedWakeableWasPinged(); + const wasPinged = suspendedThenableDidResolve(); resetWakeableState(); if (!wasPinged) { - // The wakeable wasn't pinged. Return to the normal work loop. This will + // The thenable wasn't pinged. Return to the normal work loop. This will // unwind the stack, and potentially result in showing a fallback. const returnFiber = unitOfWork.return; @@ -2873,31 +2864,26 @@ export function pingSuspendedRoot( // Received a ping at the same priority level at which we're currently // rendering. We might want to restart this render. This should mirror // the logic of whether or not a root suspends once it completes. - const didPingSuspendedWakeable = attemptToPingSuspendedWakeable(wakeable); - if (didPingSuspendedWakeable) { - // Successfully pinged the in-progress fiber. Don't unwind the stack. - } else { - // TODO: If we're rendering sync either due to Sync, Batched or expired, - // we should probably never restart. + // TODO: If we're rendering sync either due to Sync, Batched or expired, + // we should probably never restart. - // If we're suspended with delay, or if it's a retry, we'll always suspend - // so we can always restart. - if ( - workInProgressRootExitStatus === RootSuspendedWithDelay || - (workInProgressRootExitStatus === RootSuspended && - includesOnlyRetries(workInProgressRootRenderLanes) && - now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) - ) { - // Restart from the root. - prepareFreshStack(root, NoLanes); - } else { - // Even though we can't restart right now, we might get an - // opportunity later. So we mark this render as having a ping. - workInProgressRootPingedLanes = mergeLanes( - workInProgressRootPingedLanes, - pingedLanes, - ); - } + // If we're suspended with delay, or if it's a retry, we'll always suspend + // so we can always restart. + if ( + workInProgressRootExitStatus === RootSuspendedWithDelay || + (workInProgressRootExitStatus === RootSuspended && + includesOnlyRetries(workInProgressRootRenderLanes) && + now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) + ) { + // Restart from the root. + prepareFreshStack(root, NoLanes); + } else { + // Even though we can't restart right now, we might get an + // opportunity later. So we mark this render as having a ping. + workInProgressRootPingedLanes = mergeLanes( + workInProgressRootPingedLanes, + pingedLanes, + ); } } diff --git a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js index 80a11aa5be687..40dcfe7ed7170 100644 --- a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js +++ b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js @@ -93,4 +93,44 @@ describe('ReactWakeable', () => { ]); expect(root).toMatchRenderedOutput('Async'); }); + + test('does not infinite loop if already resolved thenable is thrown', async () => { + // An already resolved promise should never be thrown. Since it already + // resolved, we shouldn't bother trying to render again — doing so would + // likely lead to an infinite loop. This scenario should only happen if a + // userspace Suspense library makes an implementation mistake. + + // Create an already resolved thenable + const thenable = { + then(ping) {}, + status: 'fulfilled', + value: null, + }; + + let i = 0; + function Async() { + if (i++ > 50) { + throw new Error('Infinite loop detected'); + } + Scheduler.unstable_yieldValue('Suspend!'); + // This thenable should never be thrown because it already resolved. + // But if it is thrown, React should handle it gracefully. + throw thenable; + } + + function App() { + return ( + }> + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Suspend!', 'Loading...']); + expect(root).toMatchRenderedOutput('Loading...'); + }); }); diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 17aa509e89eb9..7f7cf69f1f7c9 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -174,12 +174,35 @@ export interface Wakeable { // The subset of a Promise that React APIs rely on. This resolves a value. // This doesn't require a return value neither from the handler nor the // then function. -export interface Thenable<+R> { - then( - onFulfill: (value: R) => void | Thenable | U, - onReject: (error: mixed) => void | Thenable | U, - ): void | Thenable; +interface ThenableImpl { + then( + onFulfill: (value: T) => mixed, + onReject: (error: mixed) => mixed, + ): void | Wakeable; } +interface UntrackedThenable extends ThenableImpl { + status?: void; +} + +export interface PendingThenable extends ThenableImpl { + status: 'pending'; +} + +export interface FulfilledThenable extends ThenableImpl { + status: 'fulfilled'; + value: T; +} + +export interface RejectedThenable extends ThenableImpl { + status: 'rejected'; + reason: mixed; +} + +export type Thenable = + | UntrackedThenable + | PendingThenable + | FulfilledThenable + | RejectedThenable; export type OffscreenMode = | 'hidden' From 9cf575e6548d09ac39b71bc1e01310a55f4a1082 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 11 Aug 2022 15:31:54 -0400 Subject: [PATCH 4/5] Scaffolding for `experimental_use` hook Sets up a new experimental hook behind a feature flag, but does not implement it yet. --- .../src/ReactFiberHooks.new.js | 54 +++++++++++++++++++ .../src/ReactFiberHooks.old.js | 54 +++++++++++++++++++ .../src/ReactInternalTypes.js | 2 + packages/react/index.classic.fb.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.js | 1 + packages/react/index.modern.fb.js | 1 + packages/react/src/React.js | 2 + packages/react/src/ReactHooks.js | 7 +++ packages/shared/ReactFeatureFlags.js | 1 + packages/shared/ReactTypes.js | 3 ++ .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.native.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.testing.js | 1 + .../forks/ReactFeatureFlags.testing.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 19 files changed, 135 insertions(+) diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 074bad71b3bc1..e802fe452fdd7 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -13,6 +13,7 @@ import type { MutableSourceSubscribeFn, ReactContext, StartTransitionOptions, + Usable, } from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.new'; @@ -32,6 +33,7 @@ import { enableLazyContextPropagation, enableUseMutableSource, enableTransitionTracing, + enableUseHook, enableUseMemoCacheHook, } from 'shared/ReactFeatureFlags'; @@ -722,6 +724,10 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { }; } +function use(usable: Usable): T { + throw new Error('Not implemented.'); +} + function useMemoCache(size: number): Array { throw new Error('Not implemented.'); } @@ -2421,6 +2427,9 @@ if (enableCache) { (ContextOnlyDispatcher: Dispatcher).getCacheForType = getCacheForType; (ContextOnlyDispatcher: Dispatcher).useCacheRefresh = throwInvalidHookError; } +if (enableUseHook) { + (ContextOnlyDispatcher: Dispatcher).use = throwInvalidHookError; +} if (enableUseMemoCacheHook) { (ContextOnlyDispatcher: Dispatcher).useMemoCache = throwInvalidHookError; } @@ -2452,6 +2461,9 @@ if (enableCache) { (HooksDispatcherOnMount: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnMount: Dispatcher).useCacheRefresh = mountRefresh; } +if (enableUseHook) { + (HooksDispatcherOnMount: Dispatcher).use = use; +} if (enableUseMemoCacheHook) { (HooksDispatcherOnMount: Dispatcher).useMemoCache = useMemoCache; } @@ -2485,6 +2497,9 @@ if (enableCache) { if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdate: Dispatcher).useMemoCache = useMemoCache; } +if (enableUseHook) { + (HooksDispatcherOnUpdate: Dispatcher).use = use; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -2513,6 +2528,9 @@ if (enableCache) { (HooksDispatcherOnRerender: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnRerender: Dispatcher).useCacheRefresh = updateRefresh; } +if (enableUseHook) { + (HooksDispatcherOnRerender: Dispatcher).use = use; +} if (enableUseMemoCacheHook) { (HooksDispatcherOnRerender: Dispatcher).useMemoCache = useMemoCache; } @@ -2691,6 +2709,9 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -2836,6 +2857,9 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -2981,6 +3005,9 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -3127,6 +3154,9 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -3289,6 +3319,14 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = function( size: number, @@ -3456,6 +3494,14 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = function( size: number, @@ -3624,6 +3670,14 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = function( size: number, diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 9e461e54678b0..f31bca97ac8e5 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -13,6 +13,7 @@ import type { MutableSourceSubscribeFn, ReactContext, StartTransitionOptions, + Usable, } from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.old'; @@ -32,6 +33,7 @@ import { enableLazyContextPropagation, enableUseMutableSource, enableTransitionTracing, + enableUseHook, enableUseMemoCacheHook, } from 'shared/ReactFeatureFlags'; @@ -722,6 +724,10 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { }; } +function use(usable: Usable): T { + throw new Error('Not implemented.'); +} + function useMemoCache(size: number): Array { throw new Error('Not implemented.'); } @@ -2421,6 +2427,9 @@ if (enableCache) { (ContextOnlyDispatcher: Dispatcher).getCacheForType = getCacheForType; (ContextOnlyDispatcher: Dispatcher).useCacheRefresh = throwInvalidHookError; } +if (enableUseHook) { + (ContextOnlyDispatcher: Dispatcher).use = throwInvalidHookError; +} if (enableUseMemoCacheHook) { (ContextOnlyDispatcher: Dispatcher).useMemoCache = throwInvalidHookError; } @@ -2452,6 +2461,9 @@ if (enableCache) { (HooksDispatcherOnMount: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnMount: Dispatcher).useCacheRefresh = mountRefresh; } +if (enableUseHook) { + (HooksDispatcherOnMount: Dispatcher).use = use; +} if (enableUseMemoCacheHook) { (HooksDispatcherOnMount: Dispatcher).useMemoCache = useMemoCache; } @@ -2485,6 +2497,9 @@ if (enableCache) { if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdate: Dispatcher).useMemoCache = useMemoCache; } +if (enableUseHook) { + (HooksDispatcherOnUpdate: Dispatcher).use = use; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -2513,6 +2528,9 @@ if (enableCache) { (HooksDispatcherOnRerender: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnRerender: Dispatcher).useCacheRefresh = updateRefresh; } +if (enableUseHook) { + (HooksDispatcherOnRerender: Dispatcher).use = use; +} if (enableUseMemoCacheHook) { (HooksDispatcherOnRerender: Dispatcher).useMemoCache = useMemoCache; } @@ -2691,6 +2709,9 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -2836,6 +2857,9 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -2981,6 +3005,9 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -3127,6 +3154,9 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -3289,6 +3319,14 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = function( size: number, @@ -3456,6 +3494,14 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = function( size: number, @@ -3624,6 +3670,14 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = function( size: number, diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index e9a4b61039f66..d73f3265083e5 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -17,6 +17,7 @@ import type { MutableSource, StartTransitionOptions, Wakeable, + Usable, } from 'shared/ReactTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {WorkTag} from './ReactWorkTags'; @@ -355,6 +356,7 @@ type BasicStateAction = (S => S) | S; type Dispatch = A => void; export type Dispatcher = {| + use?: (Usable) => T, getCacheSignal?: () => AbortSignal, getCacheForType?: (resourceType: () => T) => T, readContext(context: ReactContext): T, diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 65cc12a7cfc25..0bc75a3531681 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -27,6 +27,7 @@ export { createMutableSource as unstable_createMutableSource, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 7bfb6bc21f059..d60351e263981 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -24,6 +24,7 @@ export { createFactory, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, diff --git a/packages/react/index.js b/packages/react/index.js index 77cd739625b9f..d0628ab003a79 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -49,6 +49,7 @@ export { createMutableSource, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index e9e4202d7ae57..24de7511daed5 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -26,6 +26,7 @@ export { createMutableSource as unstable_createMutableSource, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 701c4d1781c1c..fae7ee56b758e 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -55,6 +55,7 @@ import { useDeferredValue, useId, useCacheRefresh, + use, useMemoCache, } from './ReactHooks'; import { @@ -128,6 +129,7 @@ export { getCacheForType as unstable_getCacheForType, useCacheRefresh as unstable_useCacheRefresh, REACT_CACHE_TYPE as unstable_Cache, + use as experimental_use, useMemoCache as unstable_useMemoCache, // enableScopeAPI REACT_SCOPE_TYPE as unstable_Scope, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 197edfaedc559..74699ea673e07 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -14,6 +14,7 @@ import type { MutableSourceSubscribeFn, ReactContext, StartTransitionOptions, + Usable, } from 'shared/ReactTypes'; import ReactCurrentDispatcher from './ReactCurrentDispatcher'; @@ -205,6 +206,12 @@ export function useCacheRefresh(): (?() => T, ?T) => void { return dispatcher.useCacheRefresh(); } +export function use(usable: Usable): T { + const dispatcher = resolveDispatcher(); + // $FlowFixMe This is unstable, thus optional + return dispatcher.use(usable); +} + export function useMemoCache(size: number): Array { const dispatcher = resolveDispatcher(); // $FlowFixMe This is unstable, thus optional diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 0bd4fc6c04abb..197124fd233e7 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -117,6 +117,7 @@ export const enableCPUSuspense = __EXPERIMENTAL__; export const deletedTreeCleanUpLevel = 3; export const enableFloat = __EXPERIMENTAL__; +export const enableUseHook = __EXPERIMENTAL__; // Enables unstable_useMemoCache hook, intended as a compilation target for // auto-memoization. diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 7f7cf69f1f7c9..7dacd489e4b8a 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -212,3 +212,6 @@ export type OffscreenMode = export type StartTransitionOptions = { name?: string, }; + +// TODO: Add Context support +export type Usable = Thenable; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index ec5ae1bf63a75..785aae48b7acd 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -50,6 +50,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 2ed90a83b9a59..29e28584ca8f7 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -40,6 +40,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 81992652fe96b..ac08f57552c4f 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -40,6 +40,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index 5d6eba8f7fbe4..9408f5d7adb15 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -49,6 +49,7 @@ export const deferRenderPhaseUpdateToNextBatch = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 5e7caa5fa6cd8..990d1a4a9053e 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -40,6 +40,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.js b/packages/shared/forks/ReactFeatureFlags.testing.js index 84a2d4f84ac8b..7698cde8231c6 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.js @@ -40,6 +40,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.www.js b/packages/shared/forks/ReactFeatureFlags.testing.www.js index 040e2caa2c163..e6768ca0fdb08 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.www.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.www.js @@ -40,6 +40,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 90a3f9007cd6c..985c593c03030 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -53,6 +53,7 @@ export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; export const enableFloat = false; +export const enableUseHook = true; export const enableUseMemoCacheHook = true; // Logs additional User Timing API marks for use with an experimental profiling tool. From 7cc1620e9647aabff2bc420d8447f42a5ba7831b Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 11 Aug 2022 16:24:13 -0400 Subject: [PATCH 5/5] use(promise) Adds experimental support to Fiber for unwrapping the value of a promise inside a component. It is not yet implemented for Server Components, but that is planned. If promise has already resolved, the value can be unwrapped "immediately" without showing a fallback. The trick we use to implement this is to yield to the main thread (literally suspending the work loop), wait for the microtask queue to drain, then check if the promise resolved in the meantime. If so, we can resume the last attempted fiber without unwinding the stack. This functionality was implemented in previous commits. Another feature is that the promises do not need to be cached between attempts. Because we assume idempotent execution of components, React will track the promises that were used during the previous attempt and reuse the result. You shouldn't rely on this property, but during initial render it mostly just works. Updates are trickier, though, because if you used an uncached promise, we have no way of knowing whether the underlying data has changed, so we have to unwrap the promise every time. It will still work, but it's inefficient and can lead to unnecessary fallbacks if it happens during a discrete update. When we implement this for Server Components, this will be less of an issue because there are no updates in that environment. However, it's still better for performance to cache data requests, so the same principles largely apply. The intention is that this will eventually be the only supported way to suspend on arbitrary promises. Throwing a promise directly will be deprecated. --- .../src/ReactFiberHooks.new.js | 77 +++++++- .../src/ReactFiberHooks.old.js | 77 +++++++- .../src/ReactFiberLane.new.js | 18 +- .../src/ReactFiberLane.old.js | 18 +- .../src/ReactFiberRoot.new.js | 1 + .../src/ReactFiberRoot.old.js | 1 + .../src/ReactFiberThrow.new.js | 44 +---- .../src/ReactFiberThrow.old.js | 44 +---- .../src/ReactFiberWakeable.new.js | 40 +++- .../src/ReactFiberWakeable.old.js | 40 +++- .../src/ReactFiberWorkLoop.new.js | 143 ++++++++++++-- .../src/ReactFiberWorkLoop.old.js | 143 ++++++++++++-- .../src/ReactInternalTypes.js | 1 + .../src/__tests__/ReactWakeable-test.js | 182 ++++++++++++++++++ scripts/error-codes/codes.json | 3 +- 15 files changed, 705 insertions(+), 127 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index e802fe452fdd7..f52aac204c693 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -14,6 +14,7 @@ import type { ReactContext, StartTransitionOptions, Usable, + Thenable, } from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.new'; @@ -122,6 +123,10 @@ import { } from './ReactFiberConcurrentUpdates.new'; import {getTreeId} from './ReactFiberTreeContext.new'; import {now} from './Scheduler'; +import { + trackUsedThenable, + getPreviouslyUsedThenableAtIndex, +} from './ReactFiberWakeable.new'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -207,6 +212,9 @@ let didScheduleRenderPhaseUpdate: boolean = false; let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; // Counts the number of useId hooks in this component. let localIdCounter: number = 0; +// Counts number of `use`-d thenables +let thenableIndexCounter: number = 0; + // Used for ids that are generated completely client-side (i.e. not during // hydration). This counter is global, so client ids are not stable across // render attempts. @@ -405,6 +413,7 @@ export function renderWithHooks( // didScheduleRenderPhaseUpdate = false; // localIdCounter = 0; + // thenableIndexCounter = 0; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -443,6 +452,7 @@ export function renderWithHooks( do { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; if (numberOfReRenders >= RE_RENDER_LIMIT) { throw new Error( @@ -526,6 +536,7 @@ export function renderWithHooks( didScheduleRenderPhaseUpdate = false; // This is reset by checkDidRenderIdHook // localIdCounter = 0; + thenableIndexCounter = 0; if (didRenderTooFewHooks) { throw new Error( @@ -633,6 +644,7 @@ export function resetHooksAfterThrow(): void { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; } function mountWorkInProgressHook(): Hook { @@ -725,7 +737,70 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { } function use(usable: Usable): T { - throw new Error('Not implemented.'); + if ( + usable !== null && + typeof usable === 'object' && + typeof usable.then === 'function' + ) { + // This is a thenable. + const thenable: Thenable = (usable: any); + + // Track the position of the thenable within this fiber. + const index = thenableIndexCounter; + thenableIndexCounter += 1; + + switch (thenable.status) { + case 'fulfilled': { + const fulfilledValue: T = thenable.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError = thenable.reason; + throw rejectedError; + } + default: { + const prevThenableAtIndex: Thenable | null = getPreviouslyUsedThenableAtIndex( + index, + ); + if (prevThenableAtIndex !== null) { + switch (prevThenableAtIndex.status) { + case 'fulfilled': { + const fulfilledValue: T = prevThenableAtIndex.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError: mixed = prevThenableAtIndex.reason; + throw rejectedError; + } + default: { + // The thenable still hasn't resolved. Suspend with the same + // thenable as last time to avoid redundant listeners. + throw prevThenableAtIndex; + } + } + } else { + // This is the first time something has been used at this index. + // Stash the thenable at the current index so we can reuse it during + // the next attempt. + trackUsedThenable(thenable, index); + + // Suspend. + // TODO: Throwing here is an implementation detail that allows us to + // unwind the call stack. But we shouldn't allow it to leak into + // userspace. Throw an opaque placeholder value instead of the + // actual thenable. If it doesn't get captured by the work loop, log + // a warning, because that means something in userspace must have + // caught it. + throw thenable; + } + } + } + } + + // TODO: Add support for Context + + // eslint-disable-next-line react-internal/safe-string-coercion + throw new Error('An unsupported type was passed to use(): ' + String(usable)); } function useMemoCache(size: number): Array { diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index f31bca97ac8e5..473b8aace6ccd 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -14,6 +14,7 @@ import type { ReactContext, StartTransitionOptions, Usable, + Thenable, } from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.old'; @@ -122,6 +123,10 @@ import { } from './ReactFiberConcurrentUpdates.old'; import {getTreeId} from './ReactFiberTreeContext.old'; import {now} from './Scheduler'; +import { + trackUsedThenable, + getPreviouslyUsedThenableAtIndex, +} from './ReactFiberWakeable.old'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -207,6 +212,9 @@ let didScheduleRenderPhaseUpdate: boolean = false; let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; // Counts the number of useId hooks in this component. let localIdCounter: number = 0; +// Counts number of `use`-d thenables +let thenableIndexCounter: number = 0; + // Used for ids that are generated completely client-side (i.e. not during // hydration). This counter is global, so client ids are not stable across // render attempts. @@ -405,6 +413,7 @@ export function renderWithHooks( // didScheduleRenderPhaseUpdate = false; // localIdCounter = 0; + // thenableIndexCounter = 0; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -443,6 +452,7 @@ export function renderWithHooks( do { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; if (numberOfReRenders >= RE_RENDER_LIMIT) { throw new Error( @@ -526,6 +536,7 @@ export function renderWithHooks( didScheduleRenderPhaseUpdate = false; // This is reset by checkDidRenderIdHook // localIdCounter = 0; + thenableIndexCounter = 0; if (didRenderTooFewHooks) { throw new Error( @@ -633,6 +644,7 @@ export function resetHooksAfterThrow(): void { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; } function mountWorkInProgressHook(): Hook { @@ -725,7 +737,70 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { } function use(usable: Usable): T { - throw new Error('Not implemented.'); + if ( + usable !== null && + typeof usable === 'object' && + typeof usable.then === 'function' + ) { + // This is a thenable. + const thenable: Thenable = (usable: any); + + // Track the position of the thenable within this fiber. + const index = thenableIndexCounter; + thenableIndexCounter += 1; + + switch (thenable.status) { + case 'fulfilled': { + const fulfilledValue: T = thenable.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError = thenable.reason; + throw rejectedError; + } + default: { + const prevThenableAtIndex: Thenable | null = getPreviouslyUsedThenableAtIndex( + index, + ); + if (prevThenableAtIndex !== null) { + switch (prevThenableAtIndex.status) { + case 'fulfilled': { + const fulfilledValue: T = prevThenableAtIndex.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError: mixed = prevThenableAtIndex.reason; + throw rejectedError; + } + default: { + // The thenable still hasn't resolved. Suspend with the same + // thenable as last time to avoid redundant listeners. + throw prevThenableAtIndex; + } + } + } else { + // This is the first time something has been used at this index. + // Stash the thenable at the current index so we can reuse it during + // the next attempt. + trackUsedThenable(thenable, index); + + // Suspend. + // TODO: Throwing here is an implementation detail that allows us to + // unwind the call stack. But we shouldn't allow it to leak into + // userspace. Throw an opaque placeholder value instead of the + // actual thenable. If it doesn't get captured by the work loop, log + // a warning, because that means something in userspace must have + // caught it. + throw thenable; + } + } + } + } + + // TODO: Add support for Context + + // eslint-disable-next-line react-internal/safe-string-coercion + throw new Error('An unsupported type was passed to use(): ' + String(usable)); } function useMemoCache(size: number): Array { diff --git a/packages/react-reconciler/src/ReactFiberLane.new.js b/packages/react-reconciler/src/ReactFiberLane.new.js index c3f8f5329ac79..4aebeba7a205e 100644 --- a/packages/react-reconciler/src/ReactFiberLane.new.js +++ b/packages/react-reconciler/src/ReactFiberLane.new.js @@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired( // Iterate through the pending lanes and check if we've reached their // expiration time. If so, we'll assume the update is being starved and mark // it as expired to force it to finish. - let lanes = pendingLanes; + // + // We exclude retry lanes because those must always be time sliced, in order + // to unwrap uncached promises. + // TODO: Write a test for this + let lanes = pendingLanes & ~RetryLanes; while (lanes > 0) { const index = pickArbitraryLaneIndex(lanes); const lane = 1 << index; @@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) { return getHighestPriorityLanes(root.pendingLanes); } -export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes { +export function getLanesToRetrySynchronouslyOnError( + root: FiberRoot, + originallyAttemptedLanes: Lanes, +): Lanes { + if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) { + // The error recovery mechanism is disabled until these lanes are cleared. + return NoLanes; + } + const everythingButOffscreen = root.pendingLanes & ~OffscreenLane; if (everythingButOffscreen !== NoLanes) { return everythingButOffscreen; @@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { root.entangledLanes &= remainingLanes; + root.errorRecoveryDisabledLanes &= remainingLanes; + const entanglements = root.entanglements; const eventTimes = root.eventTimes; const expirationTimes = root.expirationTimes; diff --git a/packages/react-reconciler/src/ReactFiberLane.old.js b/packages/react-reconciler/src/ReactFiberLane.old.js index b3f31ec0ceac7..5861e9d3d3252 100644 --- a/packages/react-reconciler/src/ReactFiberLane.old.js +++ b/packages/react-reconciler/src/ReactFiberLane.old.js @@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired( // Iterate through the pending lanes and check if we've reached their // expiration time. If so, we'll assume the update is being starved and mark // it as expired to force it to finish. - let lanes = pendingLanes; + // + // We exclude retry lanes because those must always be time sliced, in order + // to unwrap uncached promises. + // TODO: Write a test for this + let lanes = pendingLanes & ~RetryLanes; while (lanes > 0) { const index = pickArbitraryLaneIndex(lanes); const lane = 1 << index; @@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) { return getHighestPriorityLanes(root.pendingLanes); } -export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes { +export function getLanesToRetrySynchronouslyOnError( + root: FiberRoot, + originallyAttemptedLanes: Lanes, +): Lanes { + if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) { + // The error recovery mechanism is disabled until these lanes are cleared. + return NoLanes; + } + const everythingButOffscreen = root.pendingLanes & ~OffscreenLane; if (everythingButOffscreen !== NoLanes) { return everythingButOffscreen; @@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { root.entangledLanes &= remainingLanes; + root.errorRecoveryDisabledLanes &= remainingLanes; + const entanglements = root.entanglements; const eventTimes = root.eventTimes; const expirationTimes = root.expirationTimes; diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js index f171ca0de3943..892fe78ac1b1e 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.new.js +++ b/packages/react-reconciler/src/ReactFiberRoot.new.js @@ -70,6 +70,7 @@ function FiberRootNode( this.expiredLanes = NoLanes; this.mutableReadLanes = NoLanes; this.finishedLanes = NoLanes; + this.errorRecoveryDisabledLanes = NoLanes; this.entangledLanes = NoLanes; this.entanglements = createLaneMap(NoLanes); diff --git a/packages/react-reconciler/src/ReactFiberRoot.old.js b/packages/react-reconciler/src/ReactFiberRoot.old.js index 9b37cee41edab..f7e16f0bbdcc8 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.old.js +++ b/packages/react-reconciler/src/ReactFiberRoot.old.js @@ -70,6 +70,7 @@ function FiberRootNode( this.expiredLanes = NoLanes; this.mutableReadLanes = NoLanes; this.finishedLanes = NoLanes; + this.errorRecoveryDisabledLanes = NoLanes; this.entangledLanes = NoLanes; this.entanglements = createLaneMap(NoLanes); diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 3536417dc87e2..d4e69b7c66940 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -57,7 +57,7 @@ import { onUncaughtError, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, - pingSuspendedRoot, + attachPingListener, restorePendingUpdaters, } from './ReactFiberWorkLoop.new'; import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.new'; @@ -78,8 +78,6 @@ import { queueHydrationError, } from './ReactFiberHydrationContext.new'; -const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; - function createRootErrorUpdate( fiber: Fiber, errorInfo: CapturedValue, @@ -159,46 +157,6 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { - // Attach a ping listener - // - // The data might resolve before we have a chance to commit the fallback. Or, - // in the case of a refresh, we'll never commit a fallback. So we need to - // attach a listener now. When it resolves ("pings"), we can decide whether to - // try rendering the tree again. - // - // Only attach a listener if one does not already exist for the lanes - // we're currently rendering (which acts like a "thread ID" here). - // - // We only need to do this in concurrent mode. Legacy Suspense always - // commits fallbacks synchronously, so there are no pings. - let pingCache = root.pingCache; - let threadIDs; - if (pingCache === null) { - pingCache = root.pingCache = new PossiblyWeakMap(); - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } else { - threadIDs = pingCache.get(wakeable); - if (threadIDs === undefined) { - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } - } - if (!threadIDs.has(lanes)) { - // Memoize using the thread ID to prevent redundant listeners. - threadIDs.add(lanes); - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - // If we have pending work still, restore the original updaters - restorePendingUpdaters(root, lanes); - } - } - wakeable.then(ping, ping); - } -} - function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { if (enableLazyContextPropagation) { const currentSourceFiber = sourceFiber.alternate; diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index 9177dd34ad326..cdc7d3c2a79e4 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -57,7 +57,7 @@ import { onUncaughtError, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, - pingSuspendedRoot, + attachPingListener, restorePendingUpdaters, } from './ReactFiberWorkLoop.old'; import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.old'; @@ -78,8 +78,6 @@ import { queueHydrationError, } from './ReactFiberHydrationContext.old'; -const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; - function createRootErrorUpdate( fiber: Fiber, errorInfo: CapturedValue, @@ -159,46 +157,6 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { - // Attach a ping listener - // - // The data might resolve before we have a chance to commit the fallback. Or, - // in the case of a refresh, we'll never commit a fallback. So we need to - // attach a listener now. When it resolves ("pings"), we can decide whether to - // try rendering the tree again. - // - // Only attach a listener if one does not already exist for the lanes - // we're currently rendering (which acts like a "thread ID" here). - // - // We only need to do this in concurrent mode. Legacy Suspense always - // commits fallbacks synchronously, so there are no pings. - let pingCache = root.pingCache; - let threadIDs; - if (pingCache === null) { - pingCache = root.pingCache = new PossiblyWeakMap(); - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } else { - threadIDs = pingCache.get(wakeable); - if (threadIDs === undefined) { - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } - } - if (!threadIDs.has(lanes)) { - // Memoize using the thread ID to prevent redundant listeners. - threadIDs.add(lanes); - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - // If we have pending work still, restore the original updaters - restorePendingUpdaters(root, lanes); - } - } - wakeable.then(ping, ping); - } -} - function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { if (enableLazyContextPropagation) { const currentSourceFiber = sourceFiber.alternate; diff --git a/packages/react-reconciler/src/ReactFiberWakeable.new.js b/packages/react-reconciler/src/ReactFiberWakeable.new.js index 1b3c4ec6c3bf9..83bfad32c5cf1 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.new.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.new.js @@ -18,6 +18,9 @@ import type { let suspendedThenable: Thenable | null = null; let adHocSuspendCount: number = 0; +let usedThenables: Array | void> | null = null; +let lastUsedThenable: Thenable | null = null; + const MAX_AD_HOC_SUSPEND_COUNT = 50; export function isTrackingSuspendedThenable() { @@ -39,7 +42,15 @@ export function trackSuspendedWakeable(wakeable: Wakeable) { // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. const thenable: Thenable = (wakeable: any); - adHocSuspendCount++; + if (thenable !== lastUsedThenable) { + // If this wakeable was not just `use`-d, it must be an ad hoc wakeable + // that was thrown by an older Suspense implementation. Keep a count of + // these so that we can detect an infinite ping loop. + // TODO: Once `use` throws an opaque signal instead of the actual thenable, + // a better way to count ad hoc suspends is whether an actual thenable + // is caught by the work loop. + adHocSuspendCount++; + } suspendedThenable = thenable; // We use an expando to track the status and result of a thenable so that we @@ -86,9 +97,14 @@ export function trackSuspendedWakeable(wakeable: Wakeable) { } } -export function resetWakeableState() { +export function resetWakeableStateAfterEachAttempt() { suspendedThenable = null; adHocSuspendCount = 0; + lastUsedThenable = null; +} + +export function resetThenableStateOnCompletion() { + usedThenables = null; } export function throwIfInfinitePingLoopDetected() { @@ -98,3 +114,23 @@ export function throwIfInfinitePingLoopDetected() { // the render phase so that it gets the component stack. } } + +export function trackUsedThenable(thenable: Thenable, index: number) { + if (usedThenables === null) { + usedThenables = []; + } + usedThenables[index] = thenable; + lastUsedThenable = thenable; +} + +export function getPreviouslyUsedThenableAtIndex( + index: number, +): Thenable | null { + if (usedThenables !== null) { + const thenable = usedThenables[index]; + if (thenable !== undefined) { + return thenable; + } + } + return null; +} diff --git a/packages/react-reconciler/src/ReactFiberWakeable.old.js b/packages/react-reconciler/src/ReactFiberWakeable.old.js index 1b3c4ec6c3bf9..83bfad32c5cf1 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.old.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.old.js @@ -18,6 +18,9 @@ import type { let suspendedThenable: Thenable | null = null; let adHocSuspendCount: number = 0; +let usedThenables: Array | void> | null = null; +let lastUsedThenable: Thenable | null = null; + const MAX_AD_HOC_SUSPEND_COUNT = 50; export function isTrackingSuspendedThenable() { @@ -39,7 +42,15 @@ export function trackSuspendedWakeable(wakeable: Wakeable) { // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. const thenable: Thenable = (wakeable: any); - adHocSuspendCount++; + if (thenable !== lastUsedThenable) { + // If this wakeable was not just `use`-d, it must be an ad hoc wakeable + // that was thrown by an older Suspense implementation. Keep a count of + // these so that we can detect an infinite ping loop. + // TODO: Once `use` throws an opaque signal instead of the actual thenable, + // a better way to count ad hoc suspends is whether an actual thenable + // is caught by the work loop. + adHocSuspendCount++; + } suspendedThenable = thenable; // We use an expando to track the status and result of a thenable so that we @@ -86,9 +97,14 @@ export function trackSuspendedWakeable(wakeable: Wakeable) { } } -export function resetWakeableState() { +export function resetWakeableStateAfterEachAttempt() { suspendedThenable = null; adHocSuspendCount = 0; + lastUsedThenable = null; +} + +export function resetThenableStateOnCompletion() { + usedThenables = null; } export function throwIfInfinitePingLoopDetected() { @@ -98,3 +114,23 @@ export function throwIfInfinitePingLoopDetected() { // the render phase so that it gets the component stack. } } + +export function trackUsedThenable(thenable: Thenable, index: number) { + if (usedThenables === null) { + usedThenables = []; + } + usedThenables[index] = thenable; + lastUsedThenable = thenable; +} + +export function getPreviouslyUsedThenableAtIndex( + index: number, +): Thenable | null { + if (usedThenables !== null) { + const thenable = usedThenables[index]; + if (thenable !== undefined) { + return thenable; + } + } + return null; +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index e119919334add..7c1d74b4cde6c 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -253,7 +253,8 @@ import { } from './ReactFiberAct.new'; import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new'; import { - resetWakeableState, + resetWakeableStateAfterEachAttempt, + resetThenableStateOnCompletion, trackSuspendedWakeable, suspendedThenableDidResolve, isTrackingSuspendedThenable, @@ -261,6 +262,8 @@ import { const ceil = Math.ceil; +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; + const { ReactCurrentDispatcher, ReactCurrentOwner, @@ -300,6 +303,11 @@ let workInProgressRootRenderLanes: Lanes = NoLanes; let workInProgressIsSuspended: boolean = false; let workInProgressThrownValue: mixed = null; +// Whether a ping listener was attached during this render. This is slightly +// different that whether something suspended, because we don't add multiple +// listeners to a promise we've already seen (per root and lane). +let workInProgressRootDidAttachPingListener: boolean = false; + // A contextual version of workInProgressRootRenderLanes. It is a superset of // the lanes that we started working on at the root. When we enter a subtree // that is currently hidden, we add the lanes that would have committed if @@ -980,10 +988,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // render synchronously to block concurrent data mutations, and we'll // includes all pending updates are included. If it still fails after // the second attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } if (exitStatus === RootFatalErrored) { @@ -1023,10 +1039,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // We need to check again if something threw if (exitStatus === RootErrored) { - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); // We assume the tree is now consistent because we didn't yield to any // concurrent events. } @@ -1057,14 +1081,19 @@ function performConcurrentWorkOnRoot(root, didTimeout) { return null; } -function recoverFromConcurrentError(root, errorRetryLanes) { +function recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, +) { // If an error occurred during hydration, discard server response and fall // back to client side render. // Before rendering again, save the errors from the previous attempt. const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; - if (isRootDehydrated(root)) { + const wasRootDehydrated = isRootDehydrated(root); + if (wasRootDehydrated) { // The shell failed to hydrate. Set a flag to force a client rendering // during the next attempt. To do this, we call prepareFreshStack now // to create the root work-in-progress fiber. This is a bit weird in terms @@ -1087,6 +1116,32 @@ function recoverFromConcurrentError(root, errorRetryLanes) { if (exitStatus !== RootErrored) { // Successfully finished rendering on retry + if (workInProgressRootDidAttachPingListener && !wasRootDehydrated) { + // During the synchronous render, we attached additional ping listeners. + // This is highly suggestive of an uncached promise (though it's not the + // only reason this would happen). If it was an uncached promise, then + // it may have masked a downstream error from ocurring without actually + // fixing it. Example: + // + // use(Promise.resolve('uncached')) + // throw new Error('Oops!') + // + // When this happens, there's a conflict between blocking potential + // concurrent data races and unwrapping uncached promise values. We + // have to choose one or the other. Because the data race recovery is + // a last ditch effort, we'll disable it. + root.errorRecoveryDisabledLanes = mergeLanes( + root.errorRecoveryDisabledLanes, + originallyAttemptedLanes, + ); + + // Mark the current render as suspended and force it to restart. Once + // these lanes finish successfully, we'll re-enable the error recovery + // mechanism for subsequent updates. + workInProgressRootInterleavedUpdatedLanes |= originallyAttemptedLanes; + return RootSuspendedWithDelay; + } + // The errors from the failed first attempt have been recovered. Add // them to the collection of recoverable errors. We'll log them in the // commit phase. @@ -1343,10 +1398,18 @@ function performSyncWorkOnRoot(root) { // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second // attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } @@ -1563,7 +1626,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { ); interruptedWork = interruptedWork.return; } - resetWakeableState(); + resetWakeableStateAfterEachAttempt(); + resetThenableStateOnCompletion(); } workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); @@ -1571,6 +1635,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgressRootRenderLanes = renderLanes = lanes; workInProgressIsSuspended = false; workInProgressThrownValue = null; + workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; @@ -1971,11 +2036,12 @@ function resumeSuspendedUnitOfWork( // additional logic out of the work loop's hot path. const wasPinged = suspendedThenableDidResolve(); - resetWakeableState(); + resetWakeableStateAfterEachAttempt(); if (!wasPinged) { // The thenable wasn't pinged. Return to the normal work loop. This will // unwind the stack, and potentially result in showing a fallback. + resetThenableStateOnCompletion(); const returnFiber = unitOfWork.return; if (returnFiber === null || workInProgressRoot === null) { @@ -2037,9 +2103,10 @@ function resumeSuspendedUnitOfWork( next = beginWork(current, unitOfWork, renderLanes); } - // The begin phase finished successfully without suspending. Return to the - // normal work loop. - resetWakeableState(); + // The begin phase finished successfully without suspending. Reset the state + // used to track the fiber while it was suspended. Then return to the normal + // work loop. + resetThenableStateOnCompletion(); resetCurrentDebugFiberInDEV(); unitOfWork.memoizedProps = unitOfWork.pendingProps; @@ -2840,7 +2907,53 @@ export function captureCommitPhaseError( } } -export function pingSuspendedRoot( +export function attachPingListener( + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { + // Attach a ping listener + // + // The data might resolve before we have a chance to commit the fallback. Or, + // in the case of a refresh, we'll never commit a fallback. So we need to + // attach a listener now. When it resolves ("pings"), we can decide whether to + // try rendering the tree again. + // + // Only attach a listener if one does not already exist for the lanes + // we're currently rendering (which acts like a "thread ID" here). + // + // We only need to do this in concurrent mode. Legacy Suspense always + // commits fallbacks synchronously, so there are no pings. + let pingCache = root.pingCache; + let threadIDs; + if (pingCache === null) { + pingCache = root.pingCache = new PossiblyWeakMap(); + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } else { + threadIDs = pingCache.get(wakeable); + if (threadIDs === undefined) { + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } + } + if (!threadIDs.has(lanes)) { + workInProgressRootDidAttachPingListener = true; + + // Memoize using the thread ID to prevent redundant listeners. + threadIDs.add(lanes); + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, lanes); + } + } + wakeable.then(ping, ping); + } +} + +function pingSuspendedRoot( root: FiberRoot, wakeable: Wakeable, pingedLanes: Lanes, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index d4b01dc20f378..e3ce8c7a4bab4 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -253,7 +253,8 @@ import { } from './ReactFiberAct.old'; import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.old'; import { - resetWakeableState, + resetWakeableStateAfterEachAttempt, + resetThenableStateOnCompletion, trackSuspendedWakeable, suspendedThenableDidResolve, isTrackingSuspendedThenable, @@ -261,6 +262,8 @@ import { const ceil = Math.ceil; +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; + const { ReactCurrentDispatcher, ReactCurrentOwner, @@ -300,6 +303,11 @@ let workInProgressRootRenderLanes: Lanes = NoLanes; let workInProgressIsSuspended: boolean = false; let workInProgressThrownValue: mixed = null; +// Whether a ping listener was attached during this render. This is slightly +// different that whether something suspended, because we don't add multiple +// listeners to a promise we've already seen (per root and lane). +let workInProgressRootDidAttachPingListener: boolean = false; + // A contextual version of workInProgressRootRenderLanes. It is a superset of // the lanes that we started working on at the root. When we enter a subtree // that is currently hidden, we add the lanes that would have committed if @@ -980,10 +988,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // render synchronously to block concurrent data mutations, and we'll // includes all pending updates are included. If it still fails after // the second attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } if (exitStatus === RootFatalErrored) { @@ -1023,10 +1039,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // We need to check again if something threw if (exitStatus === RootErrored) { - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); // We assume the tree is now consistent because we didn't yield to any // concurrent events. } @@ -1057,14 +1081,19 @@ function performConcurrentWorkOnRoot(root, didTimeout) { return null; } -function recoverFromConcurrentError(root, errorRetryLanes) { +function recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, +) { // If an error occurred during hydration, discard server response and fall // back to client side render. // Before rendering again, save the errors from the previous attempt. const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; - if (isRootDehydrated(root)) { + const wasRootDehydrated = isRootDehydrated(root); + if (wasRootDehydrated) { // The shell failed to hydrate. Set a flag to force a client rendering // during the next attempt. To do this, we call prepareFreshStack now // to create the root work-in-progress fiber. This is a bit weird in terms @@ -1087,6 +1116,32 @@ function recoverFromConcurrentError(root, errorRetryLanes) { if (exitStatus !== RootErrored) { // Successfully finished rendering on retry + if (workInProgressRootDidAttachPingListener && !wasRootDehydrated) { + // During the synchronous render, we attached additional ping listeners. + // This is highly suggestive of an uncached promise (though it's not the + // only reason this would happen). If it was an uncached promise, then + // it may have masked a downstream error from ocurring without actually + // fixing it. Example: + // + // use(Promise.resolve('uncached')) + // throw new Error('Oops!') + // + // When this happens, there's a conflict between blocking potential + // concurrent data races and unwrapping uncached promise values. We + // have to choose one or the other. Because the data race recovery is + // a last ditch effort, we'll disable it. + root.errorRecoveryDisabledLanes = mergeLanes( + root.errorRecoveryDisabledLanes, + originallyAttemptedLanes, + ); + + // Mark the current render as suspended and force it to restart. Once + // these lanes finish successfully, we'll re-enable the error recovery + // mechanism for subsequent updates. + workInProgressRootInterleavedUpdatedLanes |= originallyAttemptedLanes; + return RootSuspendedWithDelay; + } + // The errors from the failed first attempt have been recovered. Add // them to the collection of recoverable errors. We'll log them in the // commit phase. @@ -1343,10 +1398,18 @@ function performSyncWorkOnRoot(root) { // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second // attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } @@ -1563,7 +1626,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { ); interruptedWork = interruptedWork.return; } - resetWakeableState(); + resetWakeableStateAfterEachAttempt(); + resetThenableStateOnCompletion(); } workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); @@ -1571,6 +1635,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgressRootRenderLanes = renderLanes = lanes; workInProgressIsSuspended = false; workInProgressThrownValue = null; + workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; @@ -1971,11 +2036,12 @@ function resumeSuspendedUnitOfWork( // additional logic out of the work loop's hot path. const wasPinged = suspendedThenableDidResolve(); - resetWakeableState(); + resetWakeableStateAfterEachAttempt(); if (!wasPinged) { // The thenable wasn't pinged. Return to the normal work loop. This will // unwind the stack, and potentially result in showing a fallback. + resetThenableStateOnCompletion(); const returnFiber = unitOfWork.return; if (returnFiber === null || workInProgressRoot === null) { @@ -2037,9 +2103,10 @@ function resumeSuspendedUnitOfWork( next = beginWork(current, unitOfWork, renderLanes); } - // The begin phase finished successfully without suspending. Return to the - // normal work loop. - resetWakeableState(); + // The begin phase finished successfully without suspending. Reset the state + // used to track the fiber while it was suspended. Then return to the normal + // work loop. + resetThenableStateOnCompletion(); resetCurrentDebugFiberInDEV(); unitOfWork.memoizedProps = unitOfWork.pendingProps; @@ -2840,7 +2907,53 @@ export function captureCommitPhaseError( } } -export function pingSuspendedRoot( +export function attachPingListener( + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { + // Attach a ping listener + // + // The data might resolve before we have a chance to commit the fallback. Or, + // in the case of a refresh, we'll never commit a fallback. So we need to + // attach a listener now. When it resolves ("pings"), we can decide whether to + // try rendering the tree again. + // + // Only attach a listener if one does not already exist for the lanes + // we're currently rendering (which acts like a "thread ID" here). + // + // We only need to do this in concurrent mode. Legacy Suspense always + // commits fallbacks synchronously, so there are no pings. + let pingCache = root.pingCache; + let threadIDs; + if (pingCache === null) { + pingCache = root.pingCache = new PossiblyWeakMap(); + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } else { + threadIDs = pingCache.get(wakeable); + if (threadIDs === undefined) { + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } + } + if (!threadIDs.has(lanes)) { + workInProgressRootDidAttachPingListener = true; + + // Memoize using the thread ID to prevent redundant listeners. + threadIDs.add(lanes); + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, lanes); + } + } + wakeable.then(ping, ping); + } +} + +function pingSuspendedRoot( root: FiberRoot, wakeable: Wakeable, pingedLanes: Lanes, diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index d73f3265083e5..95e6285929d92 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -239,6 +239,7 @@ type BaseFiberRootProperties = {| pingedLanes: Lanes, expiredLanes: Lanes, mutableReadLanes: Lanes, + errorRecoveryDisabledLanes: Lanes, finishedLanes: Lanes, diff --git a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js index 40dcfe7ed7170..7d946e5cbd03c 100644 --- a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js +++ b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js @@ -4,6 +4,7 @@ let React; let ReactNoop; let Scheduler; let act; +let use; let Suspense; let startTransition; @@ -15,6 +16,7 @@ describe('ReactWakeable', () => { ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); act = require('jest-react').act; + use = React.experimental_use; Suspense = React.Suspense; startTransition = React.startTransition; }); @@ -133,4 +135,184 @@ describe('ReactWakeable', () => { expect(Scheduler).toHaveYielded(['Suspend!', 'Loading...']); expect(root).toMatchRenderedOutput('Loading...'); }); + + // @gate enableUseHook + test('basic use(promise)', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.resolve('B'); + const promiseC = Promise.resolve('C'); + + function Async() { + const text = use(promiseA) + use(promiseB) + use(promiseC); + return ; + } + + function App() { + return ( + }> + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded(['ABC']); + expect(root).toMatchRenderedOutput('ABC'); + }); + + // @gate enableUseHook + test("using a promise that's not cached between attempts", async () => { + function Async() { + const text = + use(Promise.resolve('A')) + + use(Promise.resolve('B')) + + use(Promise.resolve('C')); + return ; + } + + function App() { + return ( + }> + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded(['ABC']); + expect(root).toMatchRenderedOutput('ABC'); + }); + + // @gate enableUseHook + test('using a rejected promise will throw', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } + } + + const promiseA = Promise.resolve('A'); + const promiseB = Promise.reject(new Error('Oops!')); + const promiseC = Promise.resolve('C'); + + // Jest/Node will raise an unhandled rejected error unless we await this. It + // works fine in the browser, though. + await expect(promiseB).rejects.toThrow('Oops!'); + + function Async() { + const text = use(promiseA) + use(promiseB) + use(promiseC); + return ; + } + + function App() { + return ( + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded(['Oops!', 'Oops!']); + }); + + // @gate enableUseHook + test('erroring in the same component as an uncached promise does not result in an infinite loop', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } + } + + let i = 0; + function Async({ + // Intentionally destrucutring a prop here so that our production error + // stack trick is triggered at the beginning of the function + prop, + }) { + if (i++ > 50) { + throw new Error('Infinite loop detected'); + } + try { + use(Promise.resolve('Async')); + } catch (e) { + Scheduler.unstable_yieldValue('Suspend! [Async]'); + throw e; + } + throw new Error('Oops!'); + } + + function App() { + return ( + }> + + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded([ + // First attempt. The uncached promise suspends. + 'Suspend! [Async]', + // Because the promise already resolved, we're able to unwrap the value + // immediately in a microtask. + // + // Then we proceed to the rest of the component, which throws an error. + 'Caught an error: Oops!', + + // During the sync error recovery pass, the component suspends, because + // we were unable to unwrap the value of the promise. + 'Suspend! [Async]', + 'Loading...', + + // Because the error recovery attempt suspended, React can't tell if the + // error was actually fixed, or it was masked by the suspended data. + // In this case, it wasn't actually fixed, so if we were to commit the + // suspended fallback, it would enter an endless error recovery loop. + // + // Instead, we disable error recovery for these lanes and start + // over again. + + // This time, the error is thrown and we commit the result. + 'Suspend! [Async]', + 'Caught an error: Oops!', + ]); + expect(root).toMatchRenderedOutput('Caught an error: Oops!'); + }); }); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 15e92133a14d8..423b30de52dd2 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -422,5 +422,6 @@ "434": "`dangerouslySetInnerHTML` does not make sense on .", "435": "Unexpected Suspense handler tag (%s). This is a bug in React.", "436": "Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of \"%s\".", - "437": "the \"precedence\" prop for links to stylesheets expects to receive a string but received something of type \"%s\" instead." + "437": "the \"precedence\" prop for links to stylesheets expects to receive a string but received something of type \"%s\" instead.", + "438": "An unsupported type was passed to use(): %s" }