Re-enable risky work loop changes (#16771)

The stack of PRs in #16743 was reverted. This adds them back.
This commit is contained in:
Andrew Clark
2019-09-13 09:33:46 -07:00
committed by GitHub
parent d6f6b951e1
commit 137ea783b1
10 changed files with 1144 additions and 473 deletions
+6
View File
@@ -177,6 +177,7 @@ import {
retryDehydratedSuspenseBoundary,
scheduleWork,
renderDidSuspendDelayIfPossible,
markUnprocessedUpdateTime,
} from './ReactFiberWorkLoop';
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -2709,6 +2710,11 @@ function bailoutOnAlreadyFinishedWork(
stopProfilerTimerIfRunning(workInProgress);
}
const updateExpirationTime = workInProgress.expirationTime;
if (updateExpirationTime !== NoWork) {
markUnprocessedUpdateTime(updateExpirationTime);
}
// Check if the children have any pending work.
const childExpirationTime = workInProgress.childExpirationTime;
if (childExpirationTime < renderExpirationTime) {
@@ -21,7 +21,10 @@ import {
export type ExpirationTime = number;
export const NoWork = 0;
// TODO: Think of a better name for Never.
export const Never = 1;
// TODO: Use the Idle expiration time for idle state updates
export const Idle = 2;
export const Sync = MAX_SIGNED_31_BIT_INT;
export const Batched = Sync - 1;
+3
View File
@@ -43,6 +43,7 @@ import {
warnIfNotCurrentlyActingUpdatesInDev,
warnIfNotScopedWithMatchingAct,
markRenderEventTimeAndConfig,
markUnprocessedUpdateTime,
} from './ReactFiberWorkLoop';
import invariant from 'shared/invariant';
@@ -531,6 +532,7 @@ export function resetHooks(): void {
// This is used to reset the state of this module when a component throws.
// It's also called inside mountIndeterminateComponent if we determine the
// component is a module-style component.
renderExpirationTime = NoWork;
currentlyRenderingFiber = null;
@@ -755,6 +757,7 @@ function updateReducer<S, I, A>(
// Update the remaining priority in the queue.
if (updateExpirationTime > remainingExpirationTime) {
remainingExpirationTime = updateExpirationTime;
markUnprocessedUpdateTime(remainingExpirationTime);
}
} else {
// This update does have sufficient priority.
+125 -7
View File
@@ -14,6 +14,7 @@ import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig';
import type {Thenable} from './ReactFiberWorkLoop';
import type {Interaction} from 'scheduler/src/Tracing';
import type {SuspenseHydrationCallbacks} from './ReactFiberSuspenseComponent';
import type {ReactPriorityLevel} from './SchedulerWithReactIntegration';
import {noTimeout} from './ReactFiberHostConfig';
import {createHostRootFiber} from './ReactFiber';
@@ -23,6 +24,7 @@ import {
enableSuspenseCallback,
} from 'shared/ReactFeatureFlags';
import {unstable_getThreadID} from 'scheduler/tracing';
import {NoPriority} from './SchedulerWithReactIntegration';
// TODO: This should be lifted into the renderer.
export type Batch = {
@@ -69,12 +71,20 @@ type BaseFiberRootProperties = {|
callbackNode: *,
// Expiration of the callback associated with this root
callbackExpirationTime: ExpirationTime,
// Priority of the callback associated with this root
callbackPriority: ReactPriorityLevel,
// The earliest pending expiration time that exists in the tree
firstPendingTime: ExpirationTime,
// The latest pending expiration time that exists in the tree
lastPendingTime: ExpirationTime,
// The time at which a suspended component pinged the root to render again
pingTime: ExpirationTime,
// The earliest suspended expiration time that exists in the tree
firstSuspendedTime: ExpirationTime,
// The latest suspended expiration time that exists in the tree
lastSuspendedTime: ExpirationTime,
// The next known expiration time after the suspended range
nextKnownPendingLevel: ExpirationTime,
// The latest time at which a suspended component pinged the root to
// render again
lastPingedTime: ExpirationTime,
lastExpiredTime: ExpirationTime,
|};
// The following attributes are only used by interaction tracing builds.
@@ -117,10 +127,13 @@ function FiberRootNode(containerInfo, tag, hydrate) {
this.hydrate = hydrate;
this.firstBatch = null;
this.callbackNode = null;
this.callbackExpirationTime = NoWork;
this.callbackPriority = NoPriority;
this.firstPendingTime = NoWork;
this.lastPendingTime = NoWork;
this.pingTime = NoWork;
this.firstSuspendedTime = NoWork;
this.lastSuspendedTime = NoWork;
this.nextKnownPendingLevel = NoWork;
this.lastPingedTime = NoWork;
this.lastExpiredTime = NoWork;
if (enableSchedulerTracing) {
this.interactionThreadID = unstable_getThreadID();
@@ -151,3 +164,108 @@ export function createFiberRoot(
return root;
}
export function isRootSuspendedAtTime(
root: FiberRoot,
expirationTime: ExpirationTime,
): boolean {
const firstSuspendedTime = root.firstSuspendedTime;
const lastSuspendedTime = root.lastSuspendedTime;
return (
firstSuspendedTime !== NoWork &&
(firstSuspendedTime >= expirationTime &&
lastSuspendedTime <= expirationTime)
);
}
export function markRootSuspendedAtTime(
root: FiberRoot,
expirationTime: ExpirationTime,
): void {
const firstSuspendedTime = root.firstSuspendedTime;
const lastSuspendedTime = root.lastSuspendedTime;
if (firstSuspendedTime < expirationTime) {
root.firstSuspendedTime = expirationTime;
}
if (lastSuspendedTime > expirationTime || firstSuspendedTime === NoWork) {
root.lastSuspendedTime = expirationTime;
}
if (expirationTime <= root.lastPingedTime) {
root.lastPingedTime = NoWork;
}
if (expirationTime <= root.lastExpiredTime) {
root.lastExpiredTime = NoWork;
}
}
export function markRootUpdatedAtTime(
root: FiberRoot,
expirationTime: ExpirationTime,
): void {
// Update the range of pending times
const firstPendingTime = root.firstPendingTime;
if (expirationTime > firstPendingTime) {
root.firstPendingTime = expirationTime;
}
// Update the range of suspended times. Treat everything lower priority or
// equal to this update as unsuspended.
const firstSuspendedTime = root.firstSuspendedTime;
if (firstSuspendedTime !== NoWork) {
if (expirationTime >= firstSuspendedTime) {
// The entire suspended range is now unsuspended.
root.firstSuspendedTime = root.lastSuspendedTime = root.nextKnownPendingLevel = NoWork;
} else if (expirationTime >= root.lastSuspendedTime) {
root.lastSuspendedTime = expirationTime + 1;
}
// This is a pending level. Check if it's higher priority than the next
// known pending level.
if (expirationTime > root.nextKnownPendingLevel) {
root.nextKnownPendingLevel = expirationTime;
}
}
}
export function markRootFinishedAtTime(
root: FiberRoot,
finishedExpirationTime: ExpirationTime,
remainingExpirationTime: ExpirationTime,
): void {
// Update the range of pending times
root.firstPendingTime = remainingExpirationTime;
// Update the range of suspended times. Treat everything higher priority or
// equal to this update as unsuspended.
if (finishedExpirationTime <= root.lastSuspendedTime) {
// The entire suspended range is now unsuspended.
root.firstSuspendedTime = root.lastSuspendedTime = root.nextKnownPendingLevel = NoWork;
} else if (finishedExpirationTime <= root.firstSuspendedTime) {
// Part of the suspended range is now unsuspended. Narrow the range to
// include everything between the unsuspended time (non-inclusive) and the
// last suspended time.
root.firstSuspendedTime = finishedExpirationTime - 1;
}
if (finishedExpirationTime <= root.lastPingedTime) {
// Clear the pinged time
root.lastPingedTime = NoWork;
}
if (finishedExpirationTime <= root.lastExpiredTime) {
// Clear the expired time
root.lastExpiredTime = NoWork;
}
}
export function markRootExpiredAtTime(
root: FiberRoot,
expirationTime: ExpirationTime,
): void {
const lastExpiredTime = root.lastExpiredTime;
if (lastExpiredTime === NoWork || lastExpiredTime > expirationTime) {
root.lastExpiredTime = expirationTime;
}
}
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -103,7 +103,10 @@ import {
} from 'shared/ReactFeatureFlags';
import {StrictMode} from './ReactTypeOfMode';
import {markRenderEventTimeAndConfig} from './ReactFiberWorkLoop';
import {
markRenderEventTimeAndConfig,
markUnprocessedUpdateTime,
} from './ReactFiberWorkLoop';
import invariant from 'shared/invariant';
import warningWithoutStack from 'shared/warningWithoutStack';
@@ -580,6 +583,7 @@ export function processUpdateQueue<State>(
// dealt with the props. Context in components that specify
// shouldComponentUpdate is tricky; but we'll have to account for
// that regardless.
markUnprocessedUpdateTime(newExpirationTime);
workInProgress.expirationTime = newExpirationTime;
workInProgress.memoizedState = resultState;
@@ -243,11 +243,20 @@ describe('ReactIncrementalErrorHandling', () => {
// This update is in a separate batch
ReactNoop.render(<App isBroken={false} />, onCommit);
expect(Scheduler).toFlushAndYield([
expect(Scheduler).toFlushAndYieldThrough([
// The first render fails. But because there's a lower priority pending
// update, it doesn't throw.
'error',
// Now we retry at the lower priority. This time it succeeds.
]);
// React will try to recover by rendering all the pending updates in a
// single batch, synchronously. This time it succeeds.
//
// This tells Scheduler to render a single unit of work. Because the render
// to recover from the error is synchronous, this should be enough to
// finish the rest of the work.
Scheduler.unstable_flushNumberOfYields(1);
expect(Scheduler).toHaveYielded([
'success',
// Nothing commits until the second update completes.
'commit',
@@ -256,54 +265,80 @@ describe('ReactIncrementalErrorHandling', () => {
expect(ReactNoop.getChildren()).toEqual([span('Everything is fine.')]);
});
it('on error, retries at a lower priority using the expiration of higher priority', () => {
class Parent extends React.Component {
state = {hideChild: false};
componentDidUpdate() {
Scheduler.unstable_yieldValue('commit: ' + this.state.hideChild);
}
render() {
if (this.state.hideChild) {
Scheduler.unstable_yieldValue('(empty)');
return <span prop="(empty)" />;
}
return <Child isBroken={this.props.childIsBroken} />;
}
}
function Child(props) {
it('does not include offscreen work when retrying after an error', () => {
function App(props) {
if (props.isBroken) {
Scheduler.unstable_yieldValue('Error!');
throw new Error('Error!');
Scheduler.unstable_yieldValue('error');
throw new Error('Oops!');
}
Scheduler.unstable_yieldValue('Child');
return <span prop="Child" />;
Scheduler.unstable_yieldValue('success');
return (
<>
Everything is fine
<div hidden={true}>
<div>Offscreen content</div>
</div>
</>
);
}
// Initial mount
const parent = React.createRef(null);
ReactNoop.render(<Parent ref={parent} childIsBroken={false} />);
expect(Scheduler).toFlushAndYield(['Child']);
expect(ReactNoop.getChildren()).toEqual([span('Child')]);
function onCommit() {
Scheduler.unstable_yieldValue('commit');
}
// Schedule a low priority update to hide the child
parent.current.setState({hideChild: true});
function interrupt() {
ReactNoop.flushSync(() => {
ReactNoop.renderToRootWithID(null, 'other-root');
});
}
// Before the low priority update is flushed, synchronously trigger an
// error in the child.
ReactNoop.flushSync(() => {
ReactNoop.render(<Parent ref={parent} childIsBroken={true} />);
});
expect(Scheduler).toHaveYielded([
// First the sync update triggers an error
'Error!',
// Because there's a pending low priority update, we restart at the
// lower priority. This hides the children, suppressing the error.
'(empty)',
// Now the tree can commit.
'commit: true',
ReactNoop.render(<App isBroken={true} />, onCommit);
Scheduler.unstable_advanceTime(1000);
expect(Scheduler).toFlushAndYieldThrough(['error']);
interrupt();
expect(ReactNoop).toMatchRenderedOutput(null);
// This update is in a separate batch
ReactNoop.render(<App isBroken={false} />, onCommit);
expect(Scheduler).toFlushAndYieldThrough([
// The first render fails. But because there's a lower priority pending
// update, it doesn't throw.
'error',
]);
expect(ReactNoop.getChildren()).toEqual([span('(empty)')]);
// React will try to recover by rendering all the pending updates in a
// single batch, synchronously. This time it succeeds.
//
// This tells Scheduler to render a single unit of work. Because the render
// to recover from the error is synchronous, this should be enough to
// finish the rest of the work.
Scheduler.unstable_flushNumberOfYields(1);
expect(Scheduler).toHaveYielded([
'success',
// Nothing commits until the second update completes.
'commit',
'commit',
]);
// This should not include the offscreen content
expect(ReactNoop).toMatchRenderedOutput(
<>
Everything is fine
<div hidden={true} />
</>,
);
// The offscreen content finishes in a subsequent render
expect(Scheduler).toFlushAndYield([]);
expect(ReactNoop).toMatchRenderedOutput(
<>
Everything is fine
<div hidden={true}>
<div>Offscreen content</div>
</div>
</>,
);
});
it('retries one more time before handling error', () => {
@@ -346,10 +381,6 @@ describe('ReactIncrementalErrorHandling', () => {
expect(ReactNoop.getChildren()).toEqual([]);
});
// TODO: This is currently unobservable, but will be once we lift renderRoot
// and commitRoot into the renderer.
// it("does not retry synchronously if there's an update between complete and commit");
it('calls componentDidCatch multiple times for multiple errors', () => {
let id = 0;
class BadMount extends React.Component {
@@ -263,6 +263,274 @@ describe('ReactSuspense', () => {
expect(root).toMatchRenderedOutput('AsyncAfter SuspenseSibling');
});
it(
'interrupts current render if something already suspended with a ' +
"delay, and then subsequently there's a lower priority update",
() => {
const root = ReactTestRenderer.create(
<>
<Suspense fallback={<Text text="Loading..." />} />
<Text text="Initial" />
</>,
{
unstable_isConcurrent: true,
},
);
expect(Scheduler).toFlushAndYield(['Initial']);
expect(root).toMatchRenderedOutput('Initial');
// The update will suspend.
root.update(
<>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" ms={2000} />
</Suspense>
<Text text="After Suspense" />
<Text text="Sibling" />
</>,
);
// Yield past the Suspense boundary but don't complete the last sibling.
expect(Scheduler).toFlushAndYieldThrough([
'Suspend! [Async]',
'Loading...',
'After Suspense',
]);
// Receives a lower priority update before the current render phase
// has completed.
Scheduler.unstable_advanceTime(1000);
root.update(
<>
<Suspense fallback={<Text text="Loading..." />} />
<Text text="Updated" />
</>,
);
expect(Scheduler).toHaveYielded([]);
expect(root).toMatchRenderedOutput('Initial');
// Render the update, instead of continuing
expect(Scheduler).toFlushAndYield(['Updated']);
expect(root).toMatchRenderedOutput('Updated');
},
);
it(
'interrupts current render when something suspends with a ' +
"delay and we've already skipped over a lower priority update in " +
'a parent',
() => {
function interrupt() {
// React has a heuristic to batch all updates that occur within the same
// event. This is a trick to circumvent that heuristic.
ReactTestRenderer.create('whatever');
}
function App({shouldSuspend, step}) {
return (
<>
<Text text={`A${step}`} />
<Suspense fallback={<Text text="Loading..." />}>
{shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
</Suspense>
<Text text={`B${step}`} />
<Text text={`C${step}`} />
</>
);
}
const root = ReactTestRenderer.create(null, {
unstable_isConcurrent: true,
});
root.update(<App shouldSuspend={false} step={0} />);
expect(Scheduler).toFlushAndYield(['A0', 'B0', 'C0']);
expect(root).toMatchRenderedOutput('A0B0C0');
// This update will suspend.
root.update(<App shouldSuspend={true} step={1} />);
// Need to move into the next async bucket.
Scheduler.unstable_advanceTime(1000);
// Do a bit of work, then interrupt to trigger a restart.
expect(Scheduler).toFlushAndYieldThrough(['A1']);
interrupt();
// Schedule another update. This will have lower priority because of
// the interrupt trick above.
root.update(<App shouldSuspend={false} step={2} />);
expect(Scheduler).toFlushAndYieldThrough([
// Should have restarted the first update, because of the interruption
'A1',
'Suspend! [Async]',
'Loading...',
'B1',
]);
// Should not have committed loading state
expect(root).toMatchRenderedOutput('A0B0C0');
// After suspending, should abort the first update and switch to the
// second update. So, C1 should not appear in the log.
// TODO: This should work even if React does not yield to the main
// thread. Should use same mechanism as selective hydration to interrupt
// the render before the end of the current slice of work.
expect(Scheduler).toFlushAndYield(['A2', 'B2', 'C2']);
expect(root).toMatchRenderedOutput('A2B2C2');
},
);
it(
'interrupts current render when something suspends with a ' +
"delay and we've already bailed out lower priority update in " +
'a parent',
async () => {
// This is similar to the previous test case, except this covers when
// React completely bails out on the parent component, without processing
// the update queue.
const {useState} = React;
function interrupt() {
// React has a heuristic to batch all updates that occur within the same
// event. This is a trick to circumvent that heuristic.
ReactTestRenderer.create('whatever');
}
let setShouldSuspend;
function Async() {
const [shouldSuspend, _setShouldSuspend] = useState(false);
setShouldSuspend = _setShouldSuspend;
return (
<>
<Text text="A" />
<Suspense fallback={<Text text="Loading..." />}>
{shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
</Suspense>
<Text text="B" />
<Text text="C" />
</>
);
}
let setShouldHideInParent;
function App() {
const [shouldHideInParent, _setShouldHideInParent] = useState(false);
setShouldHideInParent = _setShouldHideInParent;
Scheduler.unstable_yieldValue(
'shouldHideInParent: ' + shouldHideInParent,
);
return shouldHideInParent ? <Text text="(empty)" /> : <Async />;
}
const root = ReactTestRenderer.create(null, {
unstable_isConcurrent: true,
});
await ReactTestRenderer.act(async () => {
root.update(<App />);
expect(Scheduler).toFlushAndYield([
'shouldHideInParent: false',
'A',
'B',
'C',
]);
expect(root).toMatchRenderedOutput('ABC');
// This update will suspend.
setShouldSuspend(true);
// Need to move into the next async bucket.
Scheduler.unstable_advanceTime(1000);
// Do a bit of work, then interrupt to trigger a restart.
expect(Scheduler).toFlushAndYieldThrough(['A']);
interrupt();
// Should not have committed loading state
expect(root).toMatchRenderedOutput('ABC');
// Schedule another update. This will have lower priority because of
// the interrupt trick above.
setShouldHideInParent(true);
expect(Scheduler).toFlushAndYieldThrough([
// Should have restarted the first update, because of the interruption
'A',
'Suspend! [Async]',
'Loading...',
'B',
]);
// Should not have committed loading state
expect(root).toMatchRenderedOutput('ABC');
// After suspending, should abort the first update and switch to the
// second update.
expect(Scheduler).toFlushAndYield([
'shouldHideInParent: true',
'(empty)',
]);
expect(root).toMatchRenderedOutput('(empty)');
});
},
);
it(
'interrupts current render when something suspends with a ' +
'delay, and a parent received an update after it completed',
() => {
function App({shouldSuspend, step}) {
return (
<>
<Text text={`A${step}`} />
<Suspense fallback={<Text text="Loading..." />}>
{shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
</Suspense>
<Text text={`B${step}`} />
<Text text={`C${step}`} />
</>
);
}
const root = ReactTestRenderer.create(null, {
unstable_isConcurrent: true,
});
root.update(<App shouldSuspend={false} step={0} />);
expect(Scheduler).toFlushAndYield(['A0', 'B0', 'C0']);
expect(root).toMatchRenderedOutput('A0B0C0');
// This update will suspend.
root.update(<App shouldSuspend={true} step={1} />);
// Flush past the root, but stop before the async component.
expect(Scheduler).toFlushAndYieldThrough(['A1']);
// Schedule an update on the root, which already completed.
root.update(<App shouldSuspend={false} step={2} />);
// We'll keep working on the existing update.
expect(Scheduler).toFlushAndYieldThrough([
// Now the async component suspends
'Suspend! [Async]',
'Loading...',
'B1',
]);
// Should not have committed loading state
expect(root).toMatchRenderedOutput('A0B0C0');
// After suspending, should abort the first update and switch to the
// second update. So, C1 should not appear in the log.
// TODO: This should work even if React does not yield to the main
// thread. Should use same mechanism as selective hydration to interrupt
// the render before the end of the current slice of work.
expect(Scheduler).toFlushAndYield(['A2', 'B2', 'C2']);
expect(root).toMatchRenderedOutput('A2B2C2');
},
);
it('mounts a lazy class component in non-concurrent mode', async () => {
class Class extends React.Component {
componentDidMount() {
@@ -494,7 +494,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
return <Text text="(empty)" />;
}
return (
<Suspense>
<Suspense fallback="Loading...">
<AsyncText ms={2000} text="Async" />
</Suspense>
);
@@ -518,6 +518,71 @@ describe('ReactSuspenseWithNoopRenderer', () => {
expect(ReactNoop.getChildren()).toEqual([span('(empty)')]);
});
it('tries each subsequent level after suspending', async () => {
const root = ReactNoop.createRoot();
function App({step, shouldSuspend}) {
return (
<Suspense fallback="Loading...">
<Text text="Sibling" />
{shouldSuspend ? (
<AsyncText ms={10000} text={'Step ' + step} />
) : (
<Text text={'Step ' + step} />
)}
</Suspense>
);
}
function interrupt() {
// React has a heuristic to batch all updates that occur within the same
// event. This is a trick to circumvent that heuristic.
ReactNoop.flushSync(() => {
ReactNoop.renderToRootWithID(null, 'other-root');
});
}
// Mount the Suspense boundary without suspending, so that the subsequent
// updates suspend with a delay.
await ReactNoop.act(async () => {
root.render(<App step={0} shouldSuspend={false} />);
});
await advanceTimers(1000);
expect(Scheduler).toHaveYielded(['Sibling', 'Step 0']);
// Schedule an update at several distinct expiration times
await ReactNoop.act(async () => {
root.render(<App step={1} shouldSuspend={true} />);
Scheduler.unstable_advanceTime(1000);
expect(Scheduler).toFlushAndYieldThrough(['Sibling']);
interrupt();
root.render(<App step={2} shouldSuspend={true} />);
Scheduler.unstable_advanceTime(1000);
expect(Scheduler).toFlushAndYieldThrough(['Sibling']);
interrupt();
root.render(<App step={3} shouldSuspend={true} />);
Scheduler.unstable_advanceTime(1000);
expect(Scheduler).toFlushAndYieldThrough(['Sibling']);
interrupt();
root.render(<App step={4} shouldSuspend={false} />);
});
// Should suspend at each distinct level
expect(Scheduler).toHaveYielded([
'Sibling',
'Suspend! [Step 1]',
'Sibling',
'Suspend! [Step 2]',
'Sibling',
'Suspend! [Step 3]',
'Sibling',
'Step 4',
]);
});
it('forces an expiration after an update times out', async () => {
ReactNoop.render(
<Fragment>
@@ -422,7 +422,10 @@ exports[`ReactDebugFiberPerf warns if an in-progress update is interrupted 1`] =
`;
exports[`ReactDebugFiberPerf warns if async work expires (starvation) 1`] = `
"⚛ (Committing Changes)
"⚛ (React Tree Reconciliation: Completed Root)
⚛ Foo [mount]
⚛ (Committing Changes)
⚛ (Committing Snapshot Effects: 0 Total)
⚛ (Committing Host Effects: 1 Total)
⚛ (Calling Lifecycle Methods: 0 Total)