Skip to content

Commit cb1e73b

Browse files
authored
[DevTools] Batch Suspense toggles when advancing the Suspense timeline (#34251)
1 parent cacc20e commit cb1e73b

File tree

13 files changed

+342
-43
lines changed

13 files changed

+342
-43
lines changed

packages/react-devtools-shared/src/__tests__/store-test.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,94 @@ describe('Store', () => {
901901
`);
902902
});
903903

904+
// @reactVersion >= 18.0
905+
it('can override multiple Suspense simultaneously', async () => {
906+
const Component = () => {
907+
return <div>Hello</div>;
908+
};
909+
const App = () => (
910+
<React.Fragment>
911+
<Component key="Outside" />
912+
<React.Suspense
913+
name="parent"
914+
fallback={<Component key="Parent Fallback" />}>
915+
<Component key="Unrelated at Start" />
916+
<React.Suspense
917+
name="one"
918+
fallback={<Component key="Suspense 1 Fallback" />}>
919+
<Component key="Suspense 1 Content" />
920+
</React.Suspense>
921+
<React.Suspense
922+
name="two"
923+
fallback={<Component key="Suspense 2 Fallback" />}>
924+
<Component key="Suspense 2 Content" />
925+
</React.Suspense>
926+
<React.Suspense
927+
name="three"
928+
fallback={<Component key="Suspense 3 Fallback" />}>
929+
<Component key="Suspense 3 Content" />
930+
</React.Suspense>
931+
<Component key="Unrelated at End" />
932+
</React.Suspense>
933+
</React.Fragment>
934+
);
935+
936+
await actAsync(() => render(<App />));
937+
938+
expect(store).toMatchInlineSnapshot(`
939+
[root]
940+
▾ <App>
941+
<Component key="Outside">
942+
▾ <Suspense name="parent">
943+
<Component key="Unrelated at Start">
944+
▾ <Suspense name="one">
945+
<Component key="Suspense 1 Content">
946+
▾ <Suspense name="two">
947+
<Component key="Suspense 2 Content">
948+
▾ <Suspense name="three">
949+
<Component key="Suspense 3 Content">
950+
<Component key="Unrelated at End">
951+
[shell]
952+
<Suspense name="parent" rects={[{x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}]}>
953+
<Suspense name="one" rects={[{x:1,y:2,width:5,height:1}]}>
954+
<Suspense name="two" rects={[{x:1,y:2,width:5,height:1}]}>
955+
<Suspense name="three" rects={[{x:1,y:2,width:5,height:1}]}>
956+
`);
957+
958+
const rendererID = getRendererID();
959+
const rootID = store.getRootIDForElement(store.getElementIDAtIndex(0));
960+
await actAsync(() => {
961+
agent.overrideSuspenseMilestone({
962+
rendererID,
963+
rootID,
964+
suspendedSet: [
965+
store.getElementIDAtIndex(4),
966+
store.getElementIDAtIndex(8),
967+
],
968+
});
969+
});
970+
971+
expect(store).toMatchInlineSnapshot(`
972+
[root]
973+
▾ <App>
974+
<Component key="Outside">
975+
▾ <Suspense name="parent">
976+
<Component key="Unrelated at Start">
977+
▾ <Suspense name="one">
978+
<Component key="Suspense 1 Fallback">
979+
▾ <Suspense name="two">
980+
<Component key="Suspense 2 Content">
981+
▾ <Suspense name="three">
982+
<Component key="Suspense 3 Fallback">
983+
<Component key="Unrelated at End">
984+
[shell]
985+
<Suspense name="parent" rects={[{x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}, {x:1,y:2,width:5,height:1}]}>
986+
<Suspense name="one" rects={[{x:1,y:2,width:5,height:1}]}>
987+
<Suspense name="two" rects={[{x:1,y:2,width:5,height:1}]}>
988+
<Suspense name="three" rects={[{x:1,y:2,width:5,height:1}]}>
989+
`);
990+
});
991+
904992
it('should display a partially rendered SuspenseList', async () => {
905993
const Loading = () => <div>Loading...</div>;
906994
const SuspendingComponent = () => {

packages/react-devtools-shared/src/backend/agent.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ type OverrideSuspenseParams = {
130130
forceFallback: boolean,
131131
};
132132

133+
type OverrideSuspenseMilestoneParams = {
134+
rendererID: number,
135+
rootID: number,
136+
suspendedSet: Array<number>,
137+
};
138+
133139
type PersistedSelection = {
134140
rendererID: number,
135141
path: Array<PathFrame>,
@@ -198,6 +204,10 @@ export default class Agent extends EventEmitter<{
198204
bridge.addListener('logElementToConsole', this.logElementToConsole);
199205
bridge.addListener('overrideError', this.overrideError);
200206
bridge.addListener('overrideSuspense', this.overrideSuspense);
207+
bridge.addListener(
208+
'overrideSuspenseMilestone',
209+
this.overrideSuspenseMilestone,
210+
);
201211
bridge.addListener('overrideValueAtPath', this.overrideValueAtPath);
202212
bridge.addListener('reloadAndProfile', this.reloadAndProfile);
203213
bridge.addListener('renamePath', this.renamePath);
@@ -556,6 +566,21 @@ export default class Agent extends EventEmitter<{
556566
}
557567
};
558568

569+
overrideSuspenseMilestone: OverrideSuspenseMilestoneParams => void = ({
570+
rendererID,
571+
rootID,
572+
suspendedSet,
573+
}) => {
574+
const renderer = this._rendererInterfaces[rendererID];
575+
if (renderer == null) {
576+
console.warn(
577+
`Invalid renderer id "${rendererID}" to override suspense milestone`,
578+
);
579+
} else {
580+
renderer.overrideSuspenseMilestone(rootID, suspendedSet);
581+
}
582+
};
583+
559584
overrideValueAtPath: OverrideValueAtPathParams => void = ({
560585
hookID,
561586
id,

packages/react-devtools-shared/src/backend/fiber/renderer.js

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2366,6 +2366,7 @@ export function attach(
23662366
!isProductionBuildOfRenderer && StrictModeBits !== 0 ? 1 : 0,
23672367
);
23682368
pushOperation(hasOwnerMetadata ? 1 : 0);
2369+
pushOperation(supportsTogglingSuspense ? 1 : 0);
23692370
23702371
if (isProfiling) {
23712372
if (displayNamesByRootID !== null) {
@@ -7455,13 +7456,6 @@ export function attach(
74557456
}
74567457
74577458
function overrideSuspense(id: number, forceFallback: boolean) {
7458-
if (!supportsTogglingSuspense) {
7459-
// TODO:: Add getter to decide if overrideSuspense is available.
7460-
// Currently only available on inspectElement.
7461-
// Probably need a different affordance to batch since the timeline
7462-
// fallback is not the same as resuspending.
7463-
return;
7464-
}
74657459
if (
74667460
typeof setSuspenseHandler !== 'function' ||
74677461
typeof scheduleUpdate !== 'function'
@@ -7506,6 +7500,58 @@ export function attach(
75067500
scheduleUpdate(fiber);
75077501
}
75087502
7503+
/**
7504+
* Resets the all other roots of this renderer.
7505+
* @param rootID The root that contains this milestone
7506+
* @param suspendedSet List of IDs of SuspenseComponent Fibers
7507+
*/
7508+
function overrideSuspenseMilestone(
7509+
rootID: FiberInstance['id'],
7510+
suspendedSet: Array<FiberInstance['id']>,
7511+
) {
7512+
if (
7513+
typeof setSuspenseHandler !== 'function' ||
7514+
typeof scheduleUpdate !== 'function'
7515+
) {
7516+
throw new Error(
7517+
'Expected overrideSuspenseMilestone() to not get called for earlier React versions.',
7518+
);
7519+
}
7520+
7521+
// TODO: Allow overriding the timeline for the specified root.
7522+
forceFallbackForFibers.clear();
7523+
7524+
for (let i = 0; i < suspendedSet.length; ++i) {
7525+
const instance = idToDevToolsInstanceMap.get(suspendedSet[i]);
7526+
if (instance === undefined) {
7527+
console.warn(
7528+
`Could not suspend ID '${suspendedSet[i]}' since the instance can't be found.`,
7529+
);
7530+
continue;
7531+
}
7532+
7533+
if (instance.kind === FIBER_INSTANCE) {
7534+
const fiber = instance.data;
7535+
forceFallbackForFibers.add(fiber);
7536+
// We could find a minimal set that covers all the Fibers in this suspended set.
7537+
// For now we rely on React's batching of updates.
7538+
scheduleUpdate(fiber);
7539+
} else {
7540+
console.warn(`Cannot not suspend ID '${suspendedSet[i]}'.`);
7541+
}
7542+
}
7543+
7544+
if (forceFallbackForFibers.size > 0) {
7545+
// First override is added. Switch React to slower path.
7546+
// TODO: Semantics for suspending a timeline are different. We want a suspended
7547+
// timeline to act like a first reveal which is relevant for SuspenseList.
7548+
// Resuspending would not affect rows in SuspenseList
7549+
setSuspenseHandler(shouldSuspendFiberAccordingToSet);
7550+
} else {
7551+
setSuspenseHandler(shouldSuspendFiberAlwaysFalse);
7552+
}
7553+
}
7554+
75097555
// Remember if we're trying to restore the selection after reload.
75107556
// In that case, we'll do some extra checks for matching mounts.
75117557
let trackedPath: Array<PathFrame> | null = null;
@@ -8006,6 +8052,7 @@ export function attach(
80068052
onErrorOrWarning,
80078053
overrideError,
80088054
overrideSuspense,
8055+
overrideSuspenseMilestone,
80098056
overrideValueAtPath,
80108057
renamePath,
80118058
renderer,
@@ -8014,6 +8061,7 @@ export function attach(
80148061
startProfiling,
80158062
stopProfiling,
80168063
storeAsGlobal,
8064+
supportsTogglingSuspense,
80178065
updateComponentFilters,
80188066
getEnvironmentNames,
80198067
...internalMcpFunctions,

packages/react-devtools-shared/src/backend/flight/renderer.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ export function attach(
140140
// The changes will be flushed later when we commit this tree to Fiber.
141141
}
142142

143+
const supportsTogglingSuspense = false;
144+
143145
return {
144146
cleanup() {},
145147
clearErrorsAndWarnings() {},
@@ -202,6 +204,7 @@ export function attach(
202204
onErrorOrWarning,
203205
overrideError() {},
204206
overrideSuspense() {},
207+
overrideSuspenseMilestone() {},
205208
overrideValueAtPath() {},
206209
renamePath() {},
207210
renderer,
@@ -210,6 +213,7 @@ export function attach(
210213
startProfiling() {},
211214
stopProfiling() {},
212215
storeAsGlobal() {},
216+
supportsTogglingSuspense,
213217
updateComponentFilters() {},
214218
getEnvironmentNames() {
215219
return [];

packages/react-devtools-shared/src/backend/legacy/renderer.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ export function attach(
180180
};
181181
}
182182

183+
const supportsTogglingSuspense = false;
184+
183185
function getDisplayNameForElementID(id: number): string | null {
184186
const internalInstance = idToInternalInstanceMap.get(id);
185187
return internalInstance ? getData(internalInstance).displayName : null;
@@ -408,6 +410,7 @@ export function attach(
408410
pushOperation(0); // Profiling flag
409411
pushOperation(0); // StrictMode supported?
410412
pushOperation(hasOwnerMetadata ? 1 : 0);
413+
pushOperation(supportsTogglingSuspense ? 1 : 0);
411414
} else {
412415
const type = getElementType(internalInstance);
413416
const {displayName, key} = getData(internalInstance);
@@ -1070,6 +1073,9 @@ export function attach(
10701073
const overrideSuspense = () => {
10711074
throw new Error('overrideSuspense not supported by this renderer');
10721075
};
1076+
const overrideSuspenseMilestone = () => {
1077+
throw new Error('overrideSuspenseMilestone not supported by this renderer');
1078+
};
10731079
const startProfiling = () => {
10741080
// Do not throw, since this would break a multi-root scenario where v15 and v16 were both present.
10751081
};
@@ -1153,6 +1159,7 @@ export function attach(
11531159
logElementToConsole,
11541160
overrideError,
11551161
overrideSuspense,
1162+
overrideSuspenseMilestone,
11561163
overrideValueAtPath,
11571164
renamePath,
11581165
getElementAttributeByPath,
@@ -1163,6 +1170,7 @@ export function attach(
11631170
startProfiling,
11641171
stopProfiling,
11651172
storeAsGlobal,
1173+
supportsTogglingSuspense,
11661174
updateComponentFilters,
11671175
getEnvironmentNames,
11681176
};

packages/react-devtools-shared/src/backend/types.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,10 @@ export type RendererInterface = {
437437
onErrorOrWarning?: OnErrorOrWarning,
438438
overrideError: (id: number, forceError: boolean) => void,
439439
overrideSuspense: (id: number, forceFallback: boolean) => void,
440+
overrideSuspenseMilestone: (
441+
rootID: number,
442+
suspendedSet: Array<number>,
443+
) => void,
440444
overrideValueAtPath: (
441445
type: Type,
442446
id: number,
@@ -469,6 +473,7 @@ export type RendererInterface = {
469473
path: Array<string | number>,
470474
count: number,
471475
) => void,
476+
supportsTogglingSuspense: boolean,
472477
updateComponentFilters: (componentFilters: Array<ComponentFilter>) => void,
473478
getEnvironmentNames: () => Array<string>,
474479

packages/react-devtools-shared/src/bridge.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export type BridgeProtocol = {
2727
// Version supported by the current frontend/backend.
2828
version: number,
2929

30-
// NPM version range that also supports this version.
30+
// NPM version range of `react-devtools-inline` that also supports this version.
3131
// Note that 'maxNpmVersion' is only set when the version is bumped.
3232
minNpmVersion: string,
3333
maxNpmVersion: string | null,
@@ -65,6 +65,12 @@ export const BRIDGE_PROTOCOL: Array<BridgeProtocol> = [
6565
{
6666
version: 2,
6767
minNpmVersion: '4.22.0',
68+
maxNpmVersion: '6.2.0',
69+
},
70+
// Version 3 adds supports-toggling-suspense bit to add-root
71+
{
72+
version: 3,
73+
minNpmVersion: '6.2.0',
6874
maxNpmVersion: null,
6975
},
7076
];
@@ -134,6 +140,12 @@ type OverrideSuspense = {
134140
forceFallback: boolean,
135141
};
136142

143+
type OverrideSuspenseMilestone = {
144+
rendererID: number,
145+
rootID: number,
146+
suspendedSet: Array<number>,
147+
};
148+
137149
type CopyElementPathParams = {
138150
...ElementAndRendererID,
139151
path: Array<string | number>,
@@ -231,6 +243,7 @@ type FrontendEvents = {
231243
logElementToConsole: [ElementAndRendererID],
232244
overrideError: [OverrideError],
233245
overrideSuspense: [OverrideSuspense],
246+
overrideSuspenseMilestone: [OverrideSuspenseMilestone],
234247
overrideValueAtPath: [OverrideValueAtPath],
235248
profilingData: [ProfilingDataBackend],
236249
reloadAndProfile: [ReloadAndProfilingParams],

0 commit comments

Comments
 (0)