Skip to content

Commit 98286cf

Browse files
authored
[DevTools] Send suspense nodes to frontend store (#34070)
1 parent cf6e502 commit 98286cf

File tree

11 files changed

+1001
-171
lines changed

11 files changed

+1001
-171
lines changed

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

Lines changed: 276 additions & 108 deletions
Large diffs are not rendered by default.

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4;
2424
export const TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS = 5;
2525
export const TREE_OPERATION_REMOVE_ROOT = 6;
2626
export const TREE_OPERATION_SET_SUBTREE_MODE = 7;
27+
export const SUSPENSE_TREE_OPERATION_ADD = 8;
28+
export const SUSPENSE_TREE_OPERATION_REMOVE = 9;
29+
export const SUSPENSE_TREE_OPERATION_REORDER_CHILDREN = 10;
2730

2831
export const PROFILING_FLAG_BASIC_SUPPORT = 0b01;
2932
export const PROFILING_FLAG_TIMELINE_SUPPORT = 0b10;

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

Lines changed: 218 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import {
2020
TREE_OPERATION_SET_SUBTREE_MODE,
2121
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
2222
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
23+
SUSPENSE_TREE_OPERATION_ADD,
24+
SUSPENSE_TREE_OPERATION_REMOVE,
25+
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
2326
} from '../constants';
2427
import {ElementTypeRoot} from '../frontend/types';
2528
import {
@@ -44,6 +47,7 @@ import type {
4447
Element,
4548
ComponentFilter,
4649
ElementType,
50+
SuspenseNode,
4751
} from 'react-devtools-shared/src/frontend/types';
4852
import type {
4953
FrontendBridge,
@@ -100,11 +104,12 @@ export default class Store extends EventEmitter<{
100104
hookSettings: [$ReadOnly<DevToolsHookSettings>],
101105
hostInstanceSelected: [Element['id']],
102106
settingsUpdated: [$ReadOnly<DevToolsHookSettings>],
103-
mutated: [[Array<number>, Map<number, number>]],
107+
mutated: [[Array<Element['id']>, Map<Element['id'], Element['id']>]],
104108
recordChangeDescriptions: [],
105109
roots: [],
106110
rootSupportsBasicProfiling: [],
107111
rootSupportsTimelineProfiling: [],
112+
suspenseTreeMutated: [],
108113
supportsNativeStyleEditor: [],
109114
supportsReloadAndProfile: [],
110115
unsupportedBridgeProtocolDetected: [],
@@ -127,16 +132,20 @@ export default class Store extends EventEmitter<{
127132
_componentFilters: Array<ComponentFilter>;
128133

129134
// Map of ID to number of recorded error and warning message IDs.
130-
_errorsAndWarnings: Map<number, {errorCount: number, warningCount: number}> =
131-
new Map();
135+
_errorsAndWarnings: Map<
136+
Element['id'],
137+
{errorCount: number, warningCount: number},
138+
> = new Map();
132139

133140
// At least one of the injected renderers contains (DEV only) owner metadata.
134141
_hasOwnerMetadata: boolean = false;
135142

136143
// Map of ID to (mutable) Element.
137144
// Elements are mutated to avoid excessive cloning during tree updates.
138145
// The InspectedElement Suspense cache also relies on this mutability for its WeakMap usage.
139-
_idToElement: Map<number, Element> = new Map();
146+
_idToElement: Map<Element['id'], Element> = new Map();
147+
148+
_idToSuspense: Map<SuspenseNode['id'], SuspenseNode> = new Map();
140149

141150
// Should the React Native style editor panel be shown?
142151
_isNativeStyleEditorSupported: boolean = false;
@@ -149,7 +158,7 @@ export default class Store extends EventEmitter<{
149158

150159
// Map of element (id) to the set of elements (ids) it owns.
151160
// This map enables getOwnersListForElement() to avoid traversing the entire tree.
152-
_ownersMap: Map<number, Set<number>> = new Map();
161+
_ownersMap: Map<Element['id'], Set<Element['id']>> = new Map();
153162

154163
_profilerStore: ProfilerStore;
155164

@@ -158,15 +167,16 @@ export default class Store extends EventEmitter<{
158167
// Incremented each time the store is mutated.
159168
// This enables a passive effect to detect a mutation between render and commit phase.
160169
_revision: number = 0;
170+
_revisionSuspense: number = 0;
161171

162172
// This Array must be treated as immutable!
163173
// Passive effects will check it for changes between render and mount.
164-
_roots: $ReadOnlyArray<number> = [];
174+
_roots: $ReadOnlyArray<Element['id']> = [];
165175

166-
_rootIDToCapabilities: Map<number, Capabilities> = new Map();
176+
_rootIDToCapabilities: Map<Element['id'], Capabilities> = new Map();
167177

168178
// Renderer ID is needed to support inspection fiber props, state, and hooks.
169-
_rootIDToRendererID: Map<number, number> = new Map();
179+
_rootIDToRendererID: Map<Element['id'], number> = new Map();
170180

171181
// These options may be initially set by a configuration option when constructing the Store.
172182
_supportsInspectMatchingDOMElement: boolean = false;
@@ -439,6 +449,9 @@ export default class Store extends EventEmitter<{
439449
get revision(): number {
440450
return this._revision;
441451
}
452+
get revisionSuspense(): number {
453+
return this._revisionSuspense;
454+
}
442455

443456
get rootIDToRendererID(): Map<number, number> {
444457
return this._rootIDToRendererID;
@@ -595,6 +608,16 @@ export default class Store extends EventEmitter<{
595608
return element;
596609
}
597610

611+
getSuspenseByID(id: SuspenseNode['id']): SuspenseNode | null {
612+
const suspense = this._idToSuspense.get(id);
613+
if (suspense === undefined) {
614+
console.warn(`No suspense found with id "${id}"`);
615+
return null;
616+
}
617+
618+
return suspense;
619+
}
620+
598621
// Returns a tuple of [id, index]
599622
getElementsWithErrorsAndWarnings(): ErrorAndWarningTuples {
600623
if (!this._shouldShowWarningsAndErrors) {
@@ -989,6 +1012,7 @@ export default class Store extends EventEmitter<{
9891012

9901013
let haveRootsChanged = false;
9911014
let haveErrorsOrWarningsChanged = false;
1015+
let hasSuspenseTreeChanged = false;
9921016

9931017
// The first two values are always rendererID and rootID
9941018
const rendererID = operations[0];
@@ -1369,7 +1393,7 @@ export default class Store extends EventEmitter<{
13691393
// The profiler UI uses them lazily in order to generate the tree.
13701394
i += 3;
13711395
break;
1372-
case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS:
1396+
case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS: {
13731397
const id = operations[i + 1];
13741398
const errorCount = operations[i + 2];
13751399
const warningCount = operations[i + 3];
@@ -1383,6 +1407,184 @@ export default class Store extends EventEmitter<{
13831407
}
13841408
haveErrorsOrWarningsChanged = true;
13851409
break;
1410+
}
1411+
case SUSPENSE_TREE_OPERATION_ADD: {
1412+
const id = operations[i + 1];
1413+
const parentID = operations[i + 2];
1414+
const nameStringID = operations[i + 3];
1415+
let name = stringTable[nameStringID];
1416+
1417+
if (this._idToSuspense.has(id)) {
1418+
this._throwAndEmitError(
1419+
Error(
1420+
`Cannot add suspense node "${id}" because a suspense node with that id is already in the Store.`,
1421+
),
1422+
);
1423+
}
1424+
1425+
const element = this._idToElement.get(id);
1426+
if (element === undefined) {
1427+
this._throwAndEmitError(
1428+
Error(
1429+
`Cannot add suspense node "${id}" because no matching element was found in the Store.`,
1430+
),
1431+
);
1432+
} else {
1433+
if (name === null) {
1434+
// The boundary isn't explicitly named.
1435+
// Pick a sensible default.
1436+
// TODO: Use key
1437+
const owner = this._idToElement.get(element.ownerID);
1438+
if (owner !== undefined) {
1439+
// TODO: This is clowny
1440+
name = `${owner.displayName || 'Unknown'}>?`;
1441+
}
1442+
}
1443+
}
1444+
1445+
if (__DEBUG__) {
1446+
debug('Suspense Add', `node ${id} as child of ${parentID}`);
1447+
}
1448+
1449+
if (parentID !== 0) {
1450+
const parentSuspense = this._idToSuspense.get(parentID);
1451+
if (parentSuspense === undefined) {
1452+
this._throwAndEmitError(
1453+
Error(
1454+
`Cannot add suspense child "${id}" to parent suspense "${parentID}" because parent suspense node was not found in the Store.`,
1455+
),
1456+
);
1457+
1458+
break;
1459+
}
1460+
1461+
parentSuspense.children.push(id);
1462+
}
1463+
1464+
if (name === null) {
1465+
name = 'Unknown';
1466+
}
1467+
1468+
this._idToSuspense.set(id, {
1469+
id,
1470+
parentID,
1471+
children: [],
1472+
name,
1473+
});
1474+
1475+
i += 4;
1476+
1477+
hasSuspenseTreeChanged = true;
1478+
break;
1479+
}
1480+
case SUSPENSE_TREE_OPERATION_REMOVE: {
1481+
const removeLength = operations[i + 1];
1482+
i += 2;
1483+
1484+
for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) {
1485+
const id = operations[i];
1486+
const suspense = this._idToSuspense.get(id);
1487+
1488+
if (suspense === undefined) {
1489+
this._throwAndEmitError(
1490+
Error(
1491+
`Cannot remove suspense node "${id}" because no matching node was found in the Store.`,
1492+
),
1493+
);
1494+
1495+
break;
1496+
}
1497+
1498+
i += 1;
1499+
1500+
const {children, parentID} = suspense;
1501+
if (children.length > 0) {
1502+
this._throwAndEmitError(
1503+
Error(`Suspense node "${id}" was removed before its children.`),
1504+
);
1505+
}
1506+
1507+
this._idToSuspense.delete(id);
1508+
1509+
let parentSuspense: ?SuspenseNode = null;
1510+
if (parentID === 0) {
1511+
if (__DEBUG__) {
1512+
debug('Suspense remove', `node ${id} root`);
1513+
}
1514+
} else {
1515+
if (__DEBUG__) {
1516+
debug('Suspense Remove', `node ${id} from parent ${parentID}`);
1517+
}
1518+
1519+
parentSuspense = this._idToSuspense.get(parentID);
1520+
if (parentSuspense === undefined) {
1521+
this._throwAndEmitError(
1522+
Error(
1523+
`Cannot remove suspense node "${id}" from parent "${parentID}" because no matching node was found in the Store.`,
1524+
),
1525+
);
1526+
1527+
break;
1528+
}
1529+
1530+
const index = parentSuspense.children.indexOf(id);
1531+
parentSuspense.children.splice(index, 1);
1532+
}
1533+
}
1534+
1535+
hasSuspenseTreeChanged = true;
1536+
break;
1537+
}
1538+
case SUSPENSE_TREE_OPERATION_REORDER_CHILDREN: {
1539+
const id = operations[i + 1];
1540+
const numChildren = operations[i + 2];
1541+
i += 3;
1542+
1543+
const suspense = this._idToSuspense.get(id);
1544+
if (suspense === undefined) {
1545+
this._throwAndEmitError(
1546+
Error(
1547+
`Cannot reorder children for suspense node "${id}" because no matching node was found in the Store.`,
1548+
),
1549+
);
1550+
1551+
break;
1552+
}
1553+
1554+
const children = suspense.children;
1555+
if (children.length !== numChildren) {
1556+
this._throwAndEmitError(
1557+
Error(
1558+
`Suspense children cannot be added or removed during a reorder operation.`,
1559+
),
1560+
);
1561+
}
1562+
1563+
for (let j = 0; j < numChildren; j++) {
1564+
const childID = operations[i + j];
1565+
children[j] = childID;
1566+
if (__DEV__) {
1567+
// This check is more expensive so it's gated by __DEV__.
1568+
const childSuspense = this._idToSuspense.get(childID);
1569+
if (childSuspense == null || childSuspense.parentID !== id) {
1570+
console.error(
1571+
`Suspense children cannot be added or removed during a reorder operation.`,
1572+
);
1573+
}
1574+
}
1575+
}
1576+
i += numChildren;
1577+
1578+
if (__DEBUG__) {
1579+
debug(
1580+
'Re-order',
1581+
`Suspense node ${id} children ${children.join(',')}`,
1582+
);
1583+
}
1584+
1585+
hasSuspenseTreeChanged = true;
1586+
break;
1587+
}
13861588
default:
13871589
this._throwAndEmitError(
13881590
new UnsupportedBridgeOperationError(
@@ -1393,6 +1595,9 @@ export default class Store extends EventEmitter<{
13931595
}
13941596

13951597
this._revision++;
1598+
if (hasSuspenseTreeChanged) {
1599+
this._revisionSuspense++;
1600+
}
13961601

13971602
// Any time the tree changes (e.g. elements added, removed, or reordered) cached indices may be invalid.
13981603
this._cachedErrorAndWarningTuples = null;
@@ -1451,6 +1656,10 @@ export default class Store extends EventEmitter<{
14511656
}
14521657
}
14531658

1659+
if (hasSuspenseTreeChanged) {
1660+
this.emit('suspenseTreeMutated');
1661+
}
1662+
14541663
if (__DEBUG__) {
14551664
console.log(printStore(this, true));
14561665
console.groupEnd();

0 commit comments

Comments
 (0)