Skip to content

Commit

Permalink
Fix: Resolve entangled actions independently (#26726)
Browse files Browse the repository at this point in the history
When there are multiple async actions at the same time, we entangle them
together because we can't be sure which action an update might be
associated with. (For this, we'd need AsyncContext.) However, if one of
the async actions fails with an error, it should only affect that
action, not all the other actions it may be entangled with.

Resolving each action independently also means they can have independent
pending state types, rather than being limited to an `isPending`
boolean. We'll use this to implement an upcoming form API.

DiffTrain build for commit 6eadbe0.
  • Loading branch information
acdlite committed Apr 26, 2023
1 parent 9434552 commit 2c417e2
Show file tree
Hide file tree
Showing 13 changed files with 1,050 additions and 837 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @noflow
* @nolint
* @preventMunge
* @generated SignedSource<<c196c39a8657ac593b26ca40d9fedf8b>>
* @generated SignedSource<<1734297975a5c75886dc8f61c542873e>>
*/

'use strict';
Expand Down Expand Up @@ -6360,8 +6360,20 @@ function requestTransitionLane() {
return currentEventTransitionLane;
}

var currentAsyncAction = null;
function requestAsyncActionContext(actionReturnValue) {
// transition updates that occur while the async action is still in progress
// are treated as part of the action.
//
// The ideal behavior would be to treat each async function as an independent
// action. However, without a mechanism like AsyncContext, we can't tell which
// action an update corresponds to. So instead, we entangle them all into one.
// The listeners to notify once the entangled scope completes.

var currentEntangledListeners = null; // The number of pending async actions in the entangled scope.

var currentEntangledPendingCount = 0; // The transition lane shared by all updates in the entangled scope.

var currentEntangledLane = NoLane;
function requestAsyncActionContext(actionReturnValue, finishedState) {
if (
actionReturnValue !== null &&
typeof actionReturnValue === "object" &&
Expand All @@ -6370,81 +6382,134 @@ function requestAsyncActionContext(actionReturnValue) {
// This is an async action.
//
// Return a thenable that resolves once the action scope (i.e. the async
// function passed to startTransition) has finished running. The fulfilled
// value is `false` to represent that the action is not pending.
// function passed to startTransition) has finished running.
var thenable = actionReturnValue;
var entangledListeners;

if (currentAsyncAction === null) {
if (currentEntangledListeners === null) {
// There's no outer async action scope. Create a new one.
var asyncAction = {
lane: requestTransitionLane(),
listeners: [],
count: 0,
status: "pending",
value: false,
reason: undefined,
then: function (resolve) {
asyncAction.listeners.push(resolve);
}
};
attachPingListeners(thenable, asyncAction);
currentAsyncAction = asyncAction;
return asyncAction;
entangledListeners = currentEntangledListeners = [];
currentEntangledPendingCount = 0;
currentEntangledLane = requestTransitionLane();
} else {
// Inherit the outer scope.
var _asyncAction = currentAsyncAction;
attachPingListeners(thenable, _asyncAction);
return _asyncAction;
entangledListeners = currentEntangledListeners;
}

currentEntangledPendingCount++;
var resultStatus = "pending";
var rejectedReason;
thenable.then(
function () {
resultStatus = "fulfilled";
pingEngtangledActionScope();
},
function (error) {
resultStatus = "rejected";
rejectedReason = error;
pingEngtangledActionScope();
}
); // Create a thenable that represents the result of this action, but doesn't
// resolve until the entire entangled scope has finished.
//
// Expressed using promises:
// const [thisResult] = await Promise.all([thisAction, entangledAction]);
// return thisResult;

var resultThenable = createResultThenable(entangledListeners); // Attach a listener to fill in the result.

entangledListeners.push(function () {
switch (resultStatus) {
case "fulfilled": {
var fulfilledThenable = resultThenable;
fulfilledThenable.status = "fulfilled";
fulfilledThenable.value = finishedState;
break;
}

case "rejected": {
var rejectedThenable = resultThenable;
rejectedThenable.status = "rejected";
rejectedThenable.reason = rejectedReason;
break;
}

case "pending":
default: {
// The listener above should have been called first, so `resultStatus`
// should already be set to the correct value.
throw new Error(
"Thenable should have already resolved. This " +
"is a bug in React."
);
}
}
});
return resultThenable;
} else {
// This is not an async action, but it may be part of an outer async action.
if (currentAsyncAction === null) {
// There's no outer async action scope.
return false;
if (currentEntangledListeners === null) {
return finishedState;
} else {
// Inherit the outer scope.
return currentAsyncAction;
// Return a thenable that does not resolve until the entangled actions
// have finished.
var _entangledListeners = currentEntangledListeners;

var _resultThenable = createResultThenable(_entangledListeners);

_entangledListeners.push(function () {
var fulfilledThenable = _resultThenable;
fulfilledThenable.status = "fulfilled";
fulfilledThenable.value = finishedState;
});

return _resultThenable;
}
}
}
function peekAsyncActionContext() {
return currentAsyncAction;
}

function attachPingListeners(thenable, asyncAction) {
asyncAction.count++;
thenable.then(
function () {
if (--asyncAction.count === 0) {
var fulfilledAsyncAction = asyncAction;
fulfilledAsyncAction.status = "fulfilled";
completeAsyncActionScope(asyncAction);
}
},
function (error) {
if (--asyncAction.count === 0) {
var rejectedAsyncAction = asyncAction;
rejectedAsyncAction.status = "rejected";
rejectedAsyncAction.reason = error;
completeAsyncActionScope(asyncAction);
}
function pingEngtangledActionScope() {
if (
currentEntangledListeners !== null &&
--currentEntangledPendingCount === 0
) {
// All the actions have finished. Close the entangled async action scope
// and notify all the listeners.
var listeners = currentEntangledListeners;
currentEntangledListeners = null;
currentEntangledLane = NoLane;

for (var i = 0; i < listeners.length; i++) {
var listener = listeners[i];
listener();
}
}
}

function createResultThenable(entangledListeners) {
// Waits for the entangled async action to complete, then resolves to the
// result of an individual action.
var resultThenable = {
status: "pending",
value: null,
reason: null,
then: function (resolve) {
// This is a bit of a cheat. `resolve` expects a value of type `S` to be
// passed, but because we're instrumenting the `status` field ourselves,
// and we know this thenable will only be used by React, we also know
// the value isn't actually needed. So we add the resolve function
// directly to the entangled listeners.
//
// This is also why we don't need to check if the thenable is still
// pending; the Suspense implementation already performs that check.
var ping = resolve;
entangledListeners.push(ping);
}
);
return asyncAction;
};
return resultThenable;
}

function completeAsyncActionScope(action) {
if (currentAsyncAction === action) {
currentAsyncAction = null;
}

var listeners = action.listeners;
action.listeners = [];

for (var i = 0; i < listeners.length; i++) {
var listener = listeners[i];
listener(false);
}
function peekEntangledActionLane() {
return currentEntangledLane;
}

var ReactCurrentDispatcher$1 = ReactSharedInternals.ReactCurrentDispatcher,
Expand Down Expand Up @@ -7965,26 +8030,32 @@ function updateDeferredValueImpl(hook, prevValue, value) {
}
}

function startTransition(setPending, callback, options) {
function startTransition(
pendingState,
finishedState,
setPending,
callback,
options
) {
var previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(
higherEventPriority(previousPriority, ContinuousEventPriority)
);
var prevTransition = ReactCurrentBatchConfig$2.transition;
ReactCurrentBatchConfig$2.transition = null;
setPending(true);
setPending(pendingState);
var currentTransition = (ReactCurrentBatchConfig$2.transition = {});

{
ReactCurrentBatchConfig$2.transition._updatedFibers = new Set();
}

try {
var returnValue, isPending;
var returnValue, maybeThenable;
if (enableAsyncActions);
else {
// Async actions are not enabled.
setPending(false);
setPending(finishedState);
callback();
}
} catch (error) {
Expand Down Expand Up @@ -8019,7 +8090,7 @@ function mountTransition() {
var _mountState = mountState(false),
setPending = _mountState[1]; // The `start` method never changes.

var start = startTransition.bind(null, setPending);
var start = startTransition.bind(null, true, false, setPending);
var hook = mountWorkInProgressHook();
hook.memoizedState = start;
return [false, start];
Expand Down Expand Up @@ -19987,9 +20058,9 @@ function requestUpdateLane(fiber) {
transition._updatedFibers.add(fiber);
}

var asyncAction = peekAsyncActionContext();
return asyncAction !== null // We're inside an async action scope. Reuse the same lane.
? asyncAction.lane // We may or may not be inside an async action scope. If we are, this
var actionScopeLane = peekEntangledActionLane();
return actionScopeLane !== NoLane // We're inside an async action scope. Reuse the same lane.
? actionScopeLane // We may or may not be inside an async action scope. If we are, this
: // is the first update in that scope. Either way, we need to get a
// fresh transition lane.
requestTransitionLane();
Expand Down Expand Up @@ -23824,7 +23895,7 @@ function createFiberRoot(
return root;
}

var ReactVersion = "18.3.0-next-ec5e9c2a7-20230425";
var ReactVersion = "18.3.0-next-6eadbe0c4-20230425";

// Might add PROFILE later.

Expand Down
Loading

0 comments on commit 2c417e2

Please sign in to comment.