Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix async batching in React.startTransition #29226

Merged
merged 1 commit into from
May 23, 2024

Conversation

acdlite
Copy link
Collaborator

@acdlite acdlite commented May 23, 2024

Note: Despite the similar-sounding description, this fix is unrelated to the issue where updates that occur after an await in an async action must also be wrapped in their own startTransition, due to the absence of an AsyncContext mechanism in browsers today.


Discovered a flaw in the current implementation of the isomorphic startTransition implementation (React.startTransition), related to async actions. It only creates an async scope if something calls setState within the synchronous part of the action (i.e. before the first await). I had thought this was fine because if there's no update during this part, then there's nothing that needs to be entangled. I didn't think this through, though — if there are multiple async updates interleaved throughout the rest of the action, we need the async scope to have already been created so that those are batched together.

An even easier way to observe this is to schedule an optimistic update after an await — the optimistic update should not be reverted until the async action is complete.

To implement, during the reconciler's module initialization, we compose its startTransition implementation with any previous reconciler's startTransition that was already initialized. Then, the isomorphic startTransition is the composition of every
reconciler's startTransition.

function startTransition(fn) {
  return startTransitionDOM(() => {
    return startTransitionART(() => {
      return startTransitionThreeFiber(() => {
        // and so on...
        return fn();
      });
    });
  });
}

This is basically how flushSync is implemented, too.

Copy link

vercel bot commented May 23, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
react-compiler-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback May 23, 2024 3:39am

@facebook-github-bot facebook-github-bot added CLA Signed React Core Team Opened by a member of the React Core Team labels May 23, 2024
@react-sizebot
Copy link

react-sizebot commented May 23, 2024

Comparing: 4c2e457...12a387d

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.66 kB 6.66 kB = 1.82 kB 1.82 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 495.89 kB 495.91 kB = 88.83 kB 88.78 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.67 kB 6.67 kB = 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 500.69 kB 500.71 kB = 89.51 kB 89.46 kB
facebook-www/ReactDOM-prod.classic.js = 593.05 kB 593.07 kB = 104.32 kB 104.27 kB
facebook-www/ReactDOM-prod.modern.js = 569.27 kB 569.29 kB = 100.72 kB 100.67 kB
test_utils/ReactAllWarnings.js Deleted 64.35 kB 0.00 kB Deleted 16.05 kB 0.00 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react/cjs/react.react-server.development.js = 69.09 kB 68.95 kB = 19.34 kB 19.28 kB
oss-stable-semver/react/cjs/react.react-server.development.js = 69.07 kB 68.93 kB = 19.31 kB 19.25 kB
oss-stable/react/cjs/react.react-server.production.js = 14.98 kB 14.95 kB = 4.09 kB 4.08 kB
oss-stable-semver/react/cjs/react.react-server.production.js = 14.96 kB 14.93 kB = 4.07 kB 4.06 kB
test_utils/ReactAllWarnings.js Deleted 64.35 kB 0.00 kB Deleted 16.05 kB 0.00 kB

Generated by 🚫 dangerJS against 12a387d

@acdlite acdlite force-pushed the fix-async-batching-starttransition branch from 69d94fc to 56f7017 Compare May 23, 2024 03:04
@acdlite acdlite marked this pull request as ready for review May 23, 2024 03:05
@acdlite acdlite requested review from gnoff and sebmarkbage May 23, 2024 03:05
Discovered a flaw in the current implementation of the isomorphic
startTransition implementation (React.startTransition), related to async
actions. It only creates an async scope if something calls setState
within the synchronous part of the action (i.e. before the first
`await`). I had thought this was fine because if there's no update
during this part, then there's nothing that needs to be entangled. I
didn't think this through, though — if there are multiple async updates
interleaved throughout the rest of the action, we need the async scope
to have already been created so that _those_ are batched together.

An even easier way to observe this is to schedule an optimistic update
after an `await` — the optimistic update should not be reverted until
the async action is complete.

To implement, during the reconciler's module initialization, we compose
its startTransition implementation with any previous reconciler's
startTransition that was already initialized. Then, the isomorphic
startTransition is the composition of every
reconciler's startTransition.

function startTransition() {
  startTransitionDOM(() => {
    startTransitionART(() => {
      startTransitionThreeFiber(() => {
        // and so on...
      });
    });
  });
});

This is basically how flushSync is implemented, too.
Copy link
Collaborator

@gnoff gnoff left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think there may be an edge case and you may want to address the default vs nullable type for the handler but generally looks good to me

Comment on lines +76 to +78
if (prevOnStartTransitionFinish !== null) {
prevOnStartTransitionFinish(transition, returnValue);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For flushSync and friends we went with a noop default function so we don't have to do this type check on every invocation

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is calling a noop function less work than a null check?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebmarkbage had some concern over the runtime's ability to optimize it but I don't recall the specifics

Comment on lines +46 to +49
const onStartTransitionFinish = ReactSharedInternals.S;
if (onStartTransitionFinish !== null) {
onStartTransitionFinish(transition, returnValue);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to account for cases like

require('react').startTransition(() => {
  require('react-dom').createRoot(...).render(<App />)
})

I suppose the shared internals would be mutated by the time we reference the onStartTransitionFinish but maybe if you did the require after something async it would be too late?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike with flushSync I don't think there's a great way to account for the async case. The non-async case also wouldn't work if we were to move the isTransition logic off the shared internals and into the reconciler only, which is probably what we'll eventually do. I think it's fine to leave as a wont-fix.

@acdlite acdlite merged commit ee5c194 into facebook:main May 23, 2024
40 checks passed
github-actions bot pushed a commit that referenced this pull request May 23, 2024
Note: Despite the similar-sounding description, this fix is unrelated to
the issue where updates that occur after an `await` in an async action
must also be wrapped in their own `startTransition`, due to the absence
of an AsyncContext mechanism in browsers today.

---

Discovered a flaw in the current implementation of the isomorphic
startTransition implementation (React.startTransition), related to async
actions. It only creates an async scope if something calls setState
within the synchronous part of the action (i.e. before the first
`await`). I had thought this was fine because if there's no update
during this part, then there's nothing that needs to be entangled. I
didn't think this through, though — if there are multiple async updates
interleaved throughout the rest of the action, we need the async scope
to have already been created so that _those_ are batched together.

An even easier way to observe this is to schedule an optimistic update
after an `await` — the optimistic update should not be reverted until
the async action is complete.

To implement, during the reconciler's module initialization, we compose
its startTransition implementation with any previous reconciler's
startTransition that was already initialized. Then, the isomorphic
startTransition is the composition of every
reconciler's startTransition.

```js
function startTransition(fn) {
  return startTransitionDOM(() => {
    return startTransitionART(() => {
      return startTransitionThreeFiber(() => {
        // and so on...
        return fn();
      });
    });
  });
}
```

This is basically how flushSync is implemented, too.

DiffTrain build for [ee5c194](ee5c194)
github-actions bot pushed a commit that referenced this pull request May 23, 2024
Note: Despite the similar-sounding description, this fix is unrelated to
the issue where updates that occur after an `await` in an async action
must also be wrapped in their own `startTransition`, due to the absence
of an AsyncContext mechanism in browsers today.

---

Discovered a flaw in the current implementation of the isomorphic
startTransition implementation (React.startTransition), related to async
actions. It only creates an async scope if something calls setState
within the synchronous part of the action (i.e. before the first
`await`). I had thought this was fine because if there's no update
during this part, then there's nothing that needs to be entangled. I
didn't think this through, though — if there are multiple async updates
interleaved throughout the rest of the action, we need the async scope
to have already been created so that _those_ are batched together.

An even easier way to observe this is to schedule an optimistic update
after an `await` — the optimistic update should not be reverted until
the async action is complete.

To implement, during the reconciler's module initialization, we compose
its startTransition implementation with any previous reconciler's
startTransition that was already initialized. Then, the isomorphic
startTransition is the composition of every
reconciler's startTransition.

```js
function startTransition(fn) {
  return startTransitionDOM(() => {
    return startTransitionART(() => {
      return startTransitionThreeFiber(() => {
        // and so on...
        return fn();
      });
    });
  });
}
```

This is basically how flushSync is implemented, too.

DiffTrain build for commit ee5c194.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants