Skip to content

Commit 0b931f9

Browse files
authored
Remove JND delay for non-transition updates (#26597)
Updates that are marked as part of a transition are allowed to block a render from committing. Generally, other updates cannot — however, there's one exception that's leftover from a previous iteration of our Suspense architecture. If an update is not the result of a known urgent event type — known as "Default" updates — then we allow it to suspend briefly, as long as the delay is short enough that the user won't notice. We refer to this delay as a "Just Noticable Difference" (JND) delay. To illustrate, if the user has already waited 400ms for an update to be reflected on the screen, the theory is that they won't notice if you wait an additional 100ms. So React can suspend for a bit longer in case more data comes in. The longer the user has already waited, the longer the JND. While we still believe this theory is sound from a UX perspective, we no longer think the implementation complexity is worth it. The main thing that's changed is how we handle Default updates. We used to render Default updates concurrently (i.e. they were time sliced, and were scheduled with postTask), but now they are blocking. Soon, they will also be scheduled with rAF, too, which means by the end of the next rAF, they will have either finished rendering or the main thread will be blocked until they do. There are various motivations for this but part of the rationale is that anything that can be made non-blocking should be marked as a Transition, anyway, so it's not worth adding implementation complexity to Default. This commit removes the JND delay for Default updates. They will now commit immediately once the render phase is complete, even if a component suspends.
1 parent ac43bf6 commit 0b931f9

10 files changed

+114
-743
lines changed

packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ describe('ReactCache', () => {
236236

237237
jest.advanceTimersByTime(100);
238238
assertLog(['Promise resolved [4]']);
239-
await waitForAll([1, 4, 'Suspend! [5]', 'Loading...']);
239+
await waitForAll([1, 4, 'Suspend! [5]']);
240240

241241
jest.advanceTimersByTime(100);
242242
assertLog(['Promise resolved [5]']);
@@ -264,7 +264,7 @@ describe('ReactCache', () => {
264264
]);
265265
jest.advanceTimersByTime(100);
266266
assertLog(['Promise resolved [2]']);
267-
await waitForAll([1, 2, 'Suspend! [3]', 'Loading...']);
267+
await waitForAll([1, 2, 'Suspend! [3]']);
268268

269269
jest.advanceTimersByTime(100);
270270
assertLog(['Promise resolved [3]']);

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@ import {
145145
includesExpiredLane,
146146
getNextLanes,
147147
getLanesToRetrySynchronouslyOnError,
148-
getMostRecentEventTime,
149148
markRootUpdated,
150149
markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
151150
markRootPinged,
@@ -284,8 +283,6 @@ import {
284283
} from './ReactFiberRootScheduler';
285284
import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
286285

287-
const ceil = Math.ceil;
288-
289286
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
290287

291288
const {
@@ -1193,38 +1190,6 @@ function finishConcurrentRender(
11931190
break;
11941191
}
11951192

1196-
if (!shouldForceFlushFallbacksInDEV()) {
1197-
// This is not a transition, but we did trigger an avoided state.
1198-
// Schedule a placeholder to display after a short delay, using the Just
1199-
// Noticeable Difference.
1200-
// TODO: Is the JND optimization worth the added complexity? If this is
1201-
// the only reason we track the event time, then probably not.
1202-
// Consider removing.
1203-
1204-
const mostRecentEventTime = getMostRecentEventTime(root, lanes);
1205-
const eventTimeMs = mostRecentEventTime;
1206-
const timeElapsedMs = now() - eventTimeMs;
1207-
const msUntilTimeout = jnd(timeElapsedMs) - timeElapsedMs;
1208-
1209-
// Don't bother with a very short suspense time.
1210-
if (msUntilTimeout > 10) {
1211-
// Instead of committing the fallback immediately, wait for more data
1212-
// to arrive.
1213-
root.timeoutHandle = scheduleTimeout(
1214-
commitRootWhenReady.bind(
1215-
null,
1216-
root,
1217-
finishedWork,
1218-
workInProgressRootRecoverableErrors,
1219-
workInProgressTransitions,
1220-
lanes,
1221-
),
1222-
msUntilTimeout,
1223-
);
1224-
break;
1225-
}
1226-
}
1227-
12281193
// Commit the placeholder.
12291194
commitRootWhenReady(
12301195
root,
@@ -3580,31 +3545,6 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
35803545
retryTimedOutBoundary(boundaryFiber, retryLane);
35813546
}
35823547

3583-
// Computes the next Just Noticeable Difference (JND) boundary.
3584-
// The theory is that a person can't tell the difference between small differences in time.
3585-
// Therefore, if we wait a bit longer than necessary that won't translate to a noticeable
3586-
// difference in the experience. However, waiting for longer might mean that we can avoid
3587-
// showing an intermediate loading state. The longer we have already waited, the harder it
3588-
// is to tell small differences in time. Therefore, the longer we've already waited,
3589-
// the longer we can wait additionally. At some point we have to give up though.
3590-
// We pick a train model where the next boundary commits at a consistent schedule.
3591-
// These particular numbers are vague estimates. We expect to adjust them based on research.
3592-
function jnd(timeElapsed: number) {
3593-
return timeElapsed < 120
3594-
? 120
3595-
: timeElapsed < 480
3596-
? 480
3597-
: timeElapsed < 1080
3598-
? 1080
3599-
: timeElapsed < 1920
3600-
? 1920
3601-
: timeElapsed < 3000
3602-
? 3000
3603-
: timeElapsed < 4320
3604-
? 4320
3605-
: ceil(timeElapsed / 1960) * 1960;
3606-
}
3607-
36083548
export function throwIfInfiniteUpdateLoopDetected() {
36093549
if (nestedUpdateCount > NESTED_UPDATE_LIMIT) {
36103550
nestedUpdateCount = 0;

packages/react-reconciler/src/__tests__/ReactExpiration-test.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -732,13 +732,9 @@ describe('ReactExpiration', () => {
732732
expect(root).toMatchRenderedOutput('A0BC');
733733

734734
await act(async () => {
735-
if (gate(flags => flags.enableSyncDefaultUpdates)) {
736-
React.startTransition(() => {
737-
root.render(<App step={1} />);
738-
});
739-
} else {
735+
React.startTransition(() => {
740736
root.render(<App step={1} />);
741-
}
737+
});
742738
await waitForAll(['Suspend! [A1]', 'Loading...']);
743739

744740
// Lots of time elapses before the promise resolves

packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -692,24 +692,16 @@ describe('ReactHooksWithNoopRenderer', () => {
692692
await waitForAll([0]);
693693
expect(root).toMatchRenderedOutput(<span prop={0} />);
694694

695-
if (gate(flags => flags.enableSyncDefaultUpdates)) {
696-
React.startTransition(() => {
697-
root.render(<Foo signal={false} />);
698-
});
699-
} else {
695+
React.startTransition(() => {
700696
root.render(<Foo signal={false} />);
701-
}
697+
});
702698
await waitForAll(['Suspend!']);
703699
expect(root).toMatchRenderedOutput(<span prop={0} />);
704700

705701
// Rendering again should suspend again.
706-
if (gate(flags => flags.enableSyncDefaultUpdates)) {
707-
React.startTransition(() => {
708-
root.render(<Foo signal={false} />);
709-
});
710-
} else {
702+
React.startTransition(() => {
711703
root.render(<Foo signal={false} />);
712-
}
704+
});
713705
await waitForAll(['Suspend!']);
714706
});
715707

@@ -755,38 +747,25 @@ describe('ReactHooksWithNoopRenderer', () => {
755747
expect(root).toMatchRenderedOutput(<span prop="A:0" />);
756748

757749
await act(async () => {
758-
if (gate(flags => flags.enableSyncDefaultUpdates)) {
759-
React.startTransition(() => {
760-
root.render(<Foo signal={false} />);
761-
setLabel('B');
762-
});
763-
} else {
750+
React.startTransition(() => {
764751
root.render(<Foo signal={false} />);
765752
setLabel('B');
766-
}
753+
});
767754

768755
await waitForAll(['Suspend!']);
769756
expect(root).toMatchRenderedOutput(<span prop="A:0" />);
770757

771758
// Rendering again should suspend again.
772-
if (gate(flags => flags.enableSyncDefaultUpdates)) {
773-
React.startTransition(() => {
774-
root.render(<Foo signal={false} />);
775-
});
776-
} else {
759+
React.startTransition(() => {
777760
root.render(<Foo signal={false} />);
778-
}
761+
});
779762
await waitForAll(['Suspend!']);
780763

781764
// Flip the signal back to "cancel" the update. However, the update to
782765
// label should still proceed. It shouldn't have been dropped.
783-
if (gate(flags => flags.enableSyncDefaultUpdates)) {
784-
React.startTransition(() => {
785-
root.render(<Foo signal={true} />);
786-
});
787-
} else {
766+
React.startTransition(() => {
788767
root.render(<Foo signal={true} />);
789-
}
768+
});
790769
await waitForAll(['B:0']);
791770
expect(root).toMatchRenderedOutput(<span prop="B:0" />);
792771
});

packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,10 +1414,12 @@ describe('ReactLazy', () => {
14141414

14151415
// Swap the position of A and B
14161416
root.update(<Parent swap={true} />);
1417-
await waitForAll(['Init B2', 'Loading...']);
1418-
jest.runAllTimers();
1419-
1420-
assertLog(['Did unmount: A', 'Did unmount: B']);
1417+
await waitForAll([
1418+
'Init B2',
1419+
'Loading...',
1420+
'Did unmount: A',
1421+
'Did unmount: B',
1422+
]);
14211423

14221424
// The suspense boundary should've triggered now.
14231425
expect(root).toMatchRenderedOutput('Loading...');
@@ -1559,13 +1561,9 @@ describe('ReactLazy', () => {
15591561
expect(root).toMatchRenderedOutput('AB');
15601562

15611563
// Swap the position of A and B
1562-
if (gate(flags => flags.enableSyncDefaultUpdates)) {
1563-
React.startTransition(() => {
1564-
root.update(<Parent swap={true} />);
1565-
});
1566-
} else {
1564+
React.startTransition(() => {
15671565
root.update(<Parent swap={true} />);
1568-
}
1566+
});
15691567
await waitForAll(['Init B2', 'Loading...']);
15701568
await resolveFakeImport(ChildB2);
15711569
// We need to flush to trigger the second one to load.

packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js

Lines changed: 29 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ let useState;
99
let useEffect;
1010
let startTransition;
1111
let textCache;
12+
let waitFor;
1213
let waitForPaint;
1314
let assertLog;
1415

@@ -28,6 +29,7 @@ describe('ReactOffscreen', () => {
2829
startTransition = React.startTransition;
2930

3031
const InternalTestUtils = require('internal-test-utils');
32+
waitFor = InternalTestUtils.waitFor;
3133
waitForPaint = InternalTestUtils.waitForPaint;
3234
assertLog = InternalTestUtils.assertLog;
3335

@@ -407,7 +409,6 @@ describe('ReactOffscreen', () => {
407409
expect(root).toMatchRenderedOutput(<span hidden={true}>B1</span>);
408410
});
409411

410-
// Only works in new reconciler
411412
// @gate enableOffscreen
412413
test('detect updates to a hidden tree during a concurrent event', async () => {
413414
// This is a pretty complex test case. It relates to how we detect if an
@@ -442,17 +443,17 @@ describe('ReactOffscreen', () => {
442443
setOuter = _setOuter;
443444
return (
444445
<>
445-
<span>
446-
<Text text={'Outer: ' + outer} />
447-
</span>
448446
<Offscreen mode={show ? 'visible' : 'hidden'}>
449447
<span>
450448
<Child outer={outer} />
451449
</span>
452450
</Offscreen>
451+
<span>
452+
<Text text={'Outer: ' + outer} />
453+
</span>
453454
<Suspense fallback={<Text text="Loading..." />}>
454455
<span>
455-
<AsyncText text={'Async: ' + outer} />
456+
<Text text={'Sibling: ' + outer} />
456457
</span>
457458
</Suspense>
458459
</>
@@ -466,50 +467,41 @@ describe('ReactOffscreen', () => {
466467
root.render(<App show={true} />);
467468
});
468469
assertLog([
469-
'Outer: 0',
470470
'Inner: 0',
471-
'Async: 0',
471+
'Outer: 0',
472+
'Sibling: 0',
472473
'Inner and outer are consistent',
473474
]);
474475
expect(root).toMatchRenderedOutput(
475476
<>
476-
<span>Outer: 0</span>
477477
<span>Inner: 0</span>
478-
<span>Async: 0</span>
478+
<span>Outer: 0</span>
479+
<span>Sibling: 0</span>
479480
</>,
480481
);
481482

482483
await act(async () => {
483484
// Update a value both inside and outside the hidden tree. These values
484485
// must always be consistent.
485-
setOuter(1);
486-
setInner(1);
487-
// In the same render, also hide the offscreen tree.
488-
root.render(<App show={false} />);
486+
startTransition(() => {
487+
setOuter(1);
488+
setInner(1);
489+
// In the same render, also hide the offscreen tree.
490+
root.render(<App show={false} />);
491+
});
489492

490-
await waitForPaint([
493+
await waitFor([
491494
// The outer update will commit, but the inner update is deferred until
492495
// a later render.
493496
'Outer: 1',
494-
495-
// Something suspended. This means we won't commit immediately; there
496-
// will be an async gap between render and commit. In this test, we will
497-
// use this property to schedule a concurrent update. The fact that
498-
// we're using Suspense to schedule a concurrent update is not directly
499-
// relevant to the test — we could also use time slicing, but I've
500-
// chosen to use Suspense the because implementation details of time
501-
// slicing are more volatile.
502-
'Suspend! [Async: 1]',
503-
504-
'Loading...',
505497
]);
506498

507499
// Assert that we haven't committed quite yet
508500
expect(root).toMatchRenderedOutput(
509501
<>
510-
<span>Outer: 0</span>
511502
<span>Inner: 0</span>
512-
<span>Async: 0</span>
503+
<span>Outer: 0</span>
504+
<span>Sibling: 0</span>
513505
</>,
514506
);
515507

@@ -520,14 +512,13 @@ describe('ReactOffscreen', () => {
520512
setInner(2);
521513
});
522514

523-
// Commit the previous render.
524-
jest.runAllTimers();
515+
// Finish rendering and commit the in-progress render.
516+
await waitForPaint(['Sibling: 1']);
525517
expect(root).toMatchRenderedOutput(
526518
<>
527-
<span>Outer: 1</span>
528519
<span hidden={true}>Inner: 0</span>
529-
<span hidden={true}>Async: 0</span>
530-
Loading...
520+
<span>Outer: 1</span>
521+
<span>Sibling: 1</span>
531522
</>,
532523
);
533524

@@ -536,32 +527,27 @@ describe('ReactOffscreen', () => {
536527
root.render(<App show={true} />);
537528
});
538529
assertLog([
539-
'Outer: 1',
540-
541530
// There are two pending updates on Inner, but only the first one
542531
// is processed, even though they share the same lane. If the second
543532
// update were erroneously processed, then Inner would be inconsistent
544533
// with Outer.
545534
'Inner: 1',
546-
547-
'Suspend! [Async: 1]',
548-
'Loading...',
535+
'Outer: 1',
536+
'Sibling: 1',
549537
'Inner and outer are consistent',
550538
]);
551539
});
552540
assertLog([
553-
'Outer: 2',
554541
'Inner: 2',
555-
'Suspend! [Async: 2]',
556-
'Loading...',
542+
'Outer: 2',
543+
'Sibling: 2',
557544
'Inner and outer are consistent',
558545
]);
559546
expect(root).toMatchRenderedOutput(
560547
<>
561-
<span>Outer: 2</span>
562548
<span>Inner: 2</span>
563-
<span hidden={true}>Async: 0</span>
564-
Loading...
549+
<span>Outer: 2</span>
550+
<span>Sibling: 2</span>
565551
</>,
566552
);
567553
});

0 commit comments

Comments
 (0)