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

Lines changed: 114 additions & 743 deletions

‎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-
awaitwaitForAll([1,4,'Suspend! [5]','Loading...']);
239+
awaitwaitForAll([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-
awaitwaitForAll([1,2,'Suspend! [3]','Loading...']);
267+
awaitwaitForAll([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
markRootSuspendedasmarkRootSuspended_dontCallThisOneDirectly,
151150
markRootPinged,
@@ -284,8 +283,6 @@ import {
284283
}from'./ReactFiberRootScheduler';
285284
import{getMaskedContext,getUnmaskedContext}from'./ReactFiberContext';
286285

287-
constceil=Math.ceil;
288-
289286
constPossiblyWeakMap=typeofWeakMap==='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-
constmostRecentEventTime=getMostRecentEventTime(root,lanes);
1205-
consteventTimeMs=mostRecentEventTime;
1206-
consttimeElapsedMs=now()-eventTimeMs;
1207-
constmsUntilTimeout=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-
functionjnd(timeElapsed: number){
3593-
returntimeElapsed<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
exportfunctionthrowIfInfiniteUpdateLoopDetected(){
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
awaitact(async()=>{
735-
if(gate(flags=>flags.enableSyncDefaultUpdates)){
736-
React.startTransition(()=>{
737-
root.render(<Appstep={1}/>);
738-
});
739-
}else{
735+
React.startTransition(()=>{
740736
root.render(<Appstep={1}/>);
741-
}
737+
});
742738
awaitwaitForAll(['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
awaitwaitForAll([0]);
693693
expect(root).toMatchRenderedOutput(<spanprop={0}/>);
694694

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

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

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

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

768755
awaitwaitForAll(['Suspend!']);
769756
expect(root).toMatchRenderedOutput(<spanprop="A:0"/>);
770757

771758
// Rendering again should suspend again.
772-
if(gate(flags=>flags.enableSyncDefaultUpdates)){
773-
React.startTransition(()=>{
774-
root.render(<Foosignal={false}/>);
775-
});
776-
}else{
759+
React.startTransition(()=>{
777760
root.render(<Foosignal={false}/>);
778-
}
761+
});
779762
awaitwaitForAll(['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(<Foosignal={true}/>);
786-
});
787-
}else{
766+
React.startTransition(()=>{
788767
root.render(<Foosignal={true}/>);
789-
}
768+
});
790769
awaitwaitForAll(['B:0']);
791770
expect(root).toMatchRenderedOutput(<spanprop="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(<Parentswap={true}/>);
1417-
awaitwaitForAll(['Init B2','Loading...']);
1418-
jest.runAllTimers();
1419-
1420-
assertLog(['Did unmount: A','Did unmount: B']);
1417+
awaitwaitForAll([
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(<Parentswap={true}/>);
1565-
});
1566-
}else{
1564+
React.startTransition(()=>{
15671565
root.update(<Parentswap={true}/>);
1568-
}
1566+
});
15691567
awaitwaitForAll(['Init B2','Loading...']);
15701568
awaitresolveFakeImport(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
letuseEffect;
1010
letstartTransition;
1111
lettextCache;
12+
letwaitFor;
1213
letwaitForPaint;
1314
letassertLog;
1415

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

3031
constInternalTestUtils=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(<spanhidden={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-
<Texttext={'Outer: '+outer}/>
447-
</span>
448446
<Offscreenmode={show ? 'visible' : 'hidden'}>
449447
<span>
450448
<Childouter={outer}/>
451449
</span>
452450
</Offscreen>
451+
<span>
452+
<Texttext={'Outer: '+outer}/>
453+
</span>
453454
<Suspensefallback={<Texttext="Loading..."/>}>
454455
<span>
455-
<AsyncTexttext={'Async: '+outer}/>
456+
<Texttext={'Sibling: '+outer}/>
456457
</span>
457458
</Suspense>
458459
</>
@@ -466,50 +467,41 @@ describe('ReactOffscreen', () => {
466467
root.render(<Appshow={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
awaitact(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(<Appshow={false}/>);
486+
startTransition(()=>{
487+
setOuter(1);
488+
setInner(1);
489+
// In the same render, also hide the offscreen tree.
490+
root.render(<Appshow={false}/>);
491+
});
489492

490-
awaitwaitForPaint([
493+
awaitwaitFor([
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+
awaitwaitForPaint(['Sibling: 1']);
525517
expect(root).toMatchRenderedOutput(
526518
<>
527-
<span>Outer: 1</span>
528519
<spanhidden={true}>Inner: 0</span>
529-
<spanhidden={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(<Appshow={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-
<spanhidden={true}>Async: 0</span>
564-
Loading...
549+
<span>Outer: 2</span>
550+
<span>Sibling: 2</span>
565551
</>,
566552
);
567553
});

0 commit comments

Comments
 (0)