-
Notifications
You must be signed in to change notification settings - Fork 47.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fix async batching in React.startTransition
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 more 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.
- Loading branch information
Showing
7 changed files
with
268 additions
and
42 deletions.
There are no files selected for viewing
143 changes: 143 additions & 0 deletions
143
packages/react-dom/src/__tests__/ReactStartTransitionMultipleRenderers-test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
/** | ||
* Copyright (c) Meta Platforms, Inc. and affiliates. | ||
* | ||
* This source code is licensed under the MIT license found in the | ||
* LICENSE file in the root directory of this source tree. | ||
* | ||
* @emails react-core | ||
*/ | ||
|
||
'use strict'; | ||
|
||
describe('ReactStartTransitionMultipleRenderers', () => { | ||
let act; | ||
let container; | ||
let React; | ||
let ReactDOMClient; | ||
let Scheduler; | ||
let assertLog; | ||
let startTransition; | ||
let useOptimistic; | ||
let textCache; | ||
|
||
beforeEach(() => { | ||
jest.resetModules(); | ||
React = require('react'); | ||
ReactDOMClient = require('react-dom/client'); | ||
Scheduler = require('scheduler'); | ||
act = require('internal-test-utils').act; | ||
assertLog = require('internal-test-utils').assertLog; | ||
startTransition = React.startTransition; | ||
useOptimistic = React.useOptimistic; | ||
container = document.createElement('div'); | ||
document.body.appendChild(container); | ||
|
||
textCache = new Map(); | ||
}); | ||
|
||
function resolveText(text) { | ||
const record = textCache.get(text); | ||
if (record === undefined) { | ||
const newRecord = { | ||
status: 'resolved', | ||
value: text, | ||
}; | ||
textCache.set(text, newRecord); | ||
} else if (record.status === 'pending') { | ||
const thenable = record.value; | ||
record.status = 'resolved'; | ||
record.value = text; | ||
thenable.pings.forEach(t => t(text)); | ||
} | ||
} | ||
|
||
function getText(text) { | ||
const record = textCache.get(text); | ||
if (record === undefined) { | ||
const thenable = { | ||
pings: [], | ||
then(resolve) { | ||
if (newRecord.status === 'pending') { | ||
thenable.pings.push(resolve); | ||
} else { | ||
Promise.resolve().then(() => resolve(newRecord.value)); | ||
} | ||
}, | ||
}; | ||
const newRecord = { | ||
status: 'pending', | ||
value: thenable, | ||
}; | ||
textCache.set(text, newRecord); | ||
return thenable; | ||
} else { | ||
switch (record.status) { | ||
case 'pending': | ||
return record.value; | ||
case 'rejected': | ||
return Promise.reject(record.value); | ||
case 'resolved': | ||
return Promise.resolve(record.value); | ||
} | ||
} | ||
} | ||
|
||
function Text({text}) { | ||
Scheduler.log(text); | ||
return text; | ||
} | ||
|
||
afterEach(() => { | ||
document.body.removeChild(container); | ||
}); | ||
|
||
// This test imports multiple reconcilers. Because of how the renderers are | ||
// built, it only works when running tests using the actual build artifacts, | ||
// not the source files. | ||
// @gate !source | ||
test('React.startTransition works across multiple renderers', async () => { | ||
const ReactNoop = require('react-noop-renderer'); | ||
|
||
const setIsPendings = new Set(); | ||
|
||
function App() { | ||
const [isPending, setIsPending] = useOptimistic(false); | ||
setIsPendings.add(setIsPending); | ||
return <Text text={isPending ? 'Pending' : 'Not pending'} />; | ||
} | ||
|
||
const noopRoot = ReactNoop.createRoot(null); | ||
const domRoot = ReactDOMClient.createRoot(container); | ||
|
||
// Run the same component in two separate renderers. | ||
await act(() => { | ||
noopRoot.render(<App />); | ||
domRoot.render(<App />); | ||
}); | ||
assertLog(['Not pending', 'Not pending']); | ||
expect(container.textContent).toEqual('Not pending'); | ||
expect(noopRoot).toMatchRenderedOutput('Not pending'); | ||
|
||
await act(() => { | ||
startTransition(async () => { | ||
// Wait until after an async gap before setting the optimistic state. | ||
await getText('Wait before setting isPending'); | ||
setIsPendings.forEach(setIsPending => setIsPending(true)); | ||
|
||
// The optimistic state should not be reverted until the | ||
// action completes. | ||
await getText('Wait until end of async action'); | ||
}); | ||
}); | ||
|
||
await act(() => resolveText('Wait before setting isPending')); | ||
assertLog(['Pending', 'Pending']); | ||
expect(container.textContent).toEqual('Pending'); | ||
expect(noopRoot).toMatchRenderedOutput('Pending'); | ||
|
||
await act(() => resolveText('Wait until end of async action')); | ||
assertLog(['Not pending', 'Not pending']); | ||
expect(container.textContent).toEqual('Not pending'); | ||
expect(noopRoot).toMatchRenderedOutput('Not pending'); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.