[DevTools] Only show boundaries with unique suspenders by default in the timeline (#34397)
Sebastian "Sebbie" Silbermann committed
Sep 11, 2025 at 11:33 UTC
b1c519f3d40932c126523dcad745ed9e06ddd636
10 files changed
+187
-22
packages/react-devtools-shared/src/backend/fiber/renderer.js
+50
-2
@@ -88,6 +88,7 @@ import {
88
SUSPENSE_TREE_OPERATION_REMOVE,
89
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
90
SUSPENSE_TREE_OPERATION_RESIZE,
91
+ SUSPENSE_TREE_OPERATION_SUSPENDERS,
92
UNKNOWN_SUSPENDERS_NONE,
93
UNKNOWN_SUSPENDERS_REASON_PRODUCTION,
94
UNKNOWN_SUSPENDERS_REASON_OLD_VERSION,
@@ -2016,6 +2017,7 @@ export function attach(
2017
const pendingOperations: OperationsArray = [];
2018
const pendingRealUnmountedIDs: Array<FiberInstance['id']> = [];
2019
const pendingRealUnmountedSuspenseIDs: Array<FiberInstance['id']> = [];
2020
+ const pendingSuspenderChanges: Set<FiberInstance['id']> = new Set();
2021
let pendingOperationsQueue: Array<OperationsArray> | null = [];
2022
const pendingStringTable: Map<string, StringTableEntry> = new Map();
2023
let pendingStringTableLength: number = 0;
@@ -2047,6 +2049,7 @@ export function attach(
2049
pendingOperations.length === 0 &&
2050
pendingRealUnmountedIDs.length === 0 &&
2051
pendingRealUnmountedSuspenseIDs.length === 0 &&
2052
+ pendingSuspenderChanges.size === 0 &&
2053
pendingUnmountedRootID === null
2054
);
2055
}
@@ -2113,6 +2116,7 @@ export function attach(
2116
pendingRealUnmountedIDs.length +
2117
(pendingUnmountedRootID === null ? 0 : 1);
2118
const numUnmountSuspenseIDs = pendingRealUnmountedSuspenseIDs.length;
2119
+ const numSuspenderChanges = pendingSuspenderChanges.size;
2120
2121
const operations = new Array<number>(
2122
// Identify which renderer this update is coming from.
@@ -2128,7 +2132,10 @@ export function attach(
2132
// [TREE_OPERATION_REMOVE, removedIDLength, ...ids]
2133
(numUnmountIDs > 0 ? 2 + numUnmountIDs : 0) +
2134
// Regular operations
2131
- pendingOperations.length,
2135
+ pendingOperations.length +
2136
+ // All suspender changes are batched in a single message.
2137
+ // [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders]]
2138
+ (numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 2 : 0),
2139
);
2140
2141
// Identify which renderer this update is coming from.
@@ -2191,12 +2198,31 @@ export function attach(
2198
i++;
2199
}
2200
}
2194
- // Fill in the rest of the operations.
2201
+
2202
+ // Fill in pending operations.
2203
for (let j = 0; j < pendingOperations.length; j++) {
2204
operations[i + j] = pendingOperations[j];
2205
}
2206
i += pendingOperations.length;
2207
2208
+ // Suspender changes might affect newly mounted nodes that we already recorded
2209
+ // in pending operations.
2210
+ if (numSuspenderChanges > 0) {
2211
+ operations[i++] = SUSPENSE_TREE_OPERATION_SUSPENDERS;
2212
+ operations[i++] = numSuspenderChanges;
2213
+ pendingSuspenderChanges.forEach(fiberIdWithChanges => {
2214
+ const suspense = idToSuspenseNodeMap.get(fiberIdWithChanges);
2215
+ if (suspense === undefined) {
2216
+ // Probably forgot to cleanup pendingSuspenderChanges when this node was removed.
2217
+ throw new Error(
2218
+ `Could not send suspender changes for "${fiberIdWithChanges}" since the Fiber no longer exists.`,
2219
+ );
2220
+ }
2221
+ operations[i++] = fiberIdWithChanges;
2222
+ operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
2223
+ });
2224
+ }
2225
+
2226
// Let the frontend know about tree operations.
2227
flushOrQueueOperations(operations);
2228
@@ -2204,6 +2230,7 @@ export function attach(
2230
pendingOperations.length = 0;
2231
pendingRealUnmountedIDs.length = 0;
2232
pendingRealUnmountedSuspenseIDs.length = 0;
2233
+ pendingSuspenderChanges.clear();
2234
pendingUnmountedRootID = null;
2235
pendingStringTable.clear();
2236
pendingStringTableLength = 0;
@@ -2688,6 +2715,19 @@ export function attach(
2715
}
2716
}
2717
2718
+ function recordSuspenseSuspenders(suspenseNode: SuspenseNode): void {
2719
+ if (__DEBUG__) {
2720
+ console.log('recordSuspenseSuspenders()', suspenseNode);
2721
+ }
2722
+ const fiberInstance = suspenseNode.instance;
2723
+ if (fiberInstance.kind !== FIBER_INSTANCE) {
2724
+ // TODO: Suspender updates of filtered Suspense nodes are currently dropped.
2725
+ return;
2726
+ }
2727
+
2728
+ pendingSuspenderChanges.add(fiberInstance.id);
2729
+ }
2730
+
2731
function recordSuspenseUnmount(suspenseInstance: SuspenseNode): void {
2732
if (__DEBUG__) {
2733
console.log(
@@ -2709,6 +2749,7 @@ export function attach(
2749
// and later arrange them in the correct order.
2750
pendingRealUnmountedSuspenseIDs.push(id);
2751
2752
+ pendingSuspenderChanges.delete(id);
2753
idToSuspenseNodeMap.delete(id);
2754
}
2755
@@ -2779,6 +2820,7 @@ export function attach(
2820
) {
2821
// This didn't exist in the parent before, so let's mark this boundary as having a unique suspender.
2822
parentSuspenseNode.hasUniqueSuspenders = true;
2823
+ recordSuspenseSuspenders(parentSuspenseNode);
2824
}
2825
}
2826
// We have observed at least one known reason this might have been suspended.
@@ -2820,6 +2862,9 @@ export function attach(
2862
// We have found a child boundary that depended on the unblocked I/O.
2863
// It can now be marked as having unique suspenders. We can skip its children
2864
// since they'll still be blocked by this one.
2865
+ if (!node.hasUniqueSuspenders) {
2866
+ recordSuspenseSuspenders(node);
2867
+ }
2868
node.hasUniqueSuspenders = true;
2869
node.hasUnknownSuspenders = false;
2870
} else if (node.firstChild !== null) {
@@ -3522,6 +3567,9 @@ export function attach(
3567
// Unfortunately if we don't have any DEV time debug info or debug thenables then
3568
// we have no meta data to show. However, we still mark this Suspense boundary as
3569
// participating in the loading sequence since apparently it can suspend.
3570
+ if (!suspenseNode.hasUniqueSuspenders) {
3571
+ recordSuspenseSuspenders(suspenseNode);
3572
+ }
3573
suspenseNode.hasUniqueSuspenders = true;
3574
// We have not seen any reason yet for why this suspense node might have been
3575
// suspended but it clearly has been at some point. If we later discover a reason
packages/react-devtools-shared/src/constants.js
+1
@@ -28,6 +28,7 @@ export const SUSPENSE_TREE_OPERATION_ADD = 8;
28
export const SUSPENSE_TREE_OPERATION_REMOVE = 9;
29
export const SUSPENSE_TREE_OPERATION_REORDER_CHILDREN = 10;
30
export const SUSPENSE_TREE_OPERATION_RESIZE = 11;
31
+export const SUSPENSE_TREE_OPERATION_SUSPENDERS = 12;
32
33
export const PROFILING_FLAG_BASIC_SUPPORT = 0b01;
34
export const PROFILING_FLAG_TIMELINE_SUPPORT = 0b10;
packages/react-devtools-shared/src/devtools/store.js
+49
-4
@@ -24,6 +24,7 @@ import {
24
SUSPENSE_TREE_OPERATION_REMOVE,
25
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
26
SUSPENSE_TREE_OPERATION_RESIZE,
27
+ SUSPENSE_TREE_OPERATION_SUSPENDERS,
28
} from '../constants';
29
import {ElementTypeRoot} from '../frontend/types';
30
import {
@@ -879,8 +880,13 @@ export default class Store extends EventEmitter<{
880
return null;
881
}
882
883
+ /**
884
+ * @param rootID
885
+ * @param uniqueSuspendersOnly Filters out boundaries without unique suspenders
886
+ */
887
getSuspendableDocumentOrderSuspense(
888
rootID: Element['id'] | void,
889
+ uniqueSuspendersOnly: boolean,
890
): $ReadOnlyArray<SuspenseNode['id']> {
891
if (rootID === undefined) {
892
return [];
@@ -892,7 +898,7 @@ export default class Store extends EventEmitter<{
898
if (!this.supportsTogglingSuspense(root.id)) {
899
return [];
900
}
895
- const suspenseTreeList: SuspenseNode['id'][] = [];
901
+ const list: SuspenseNode['id'][] = [];
902
const suspense = this.getSuspenseByID(root.id);
903
if (suspense !== null) {
904
const stack = [suspense];
@@ -901,9 +907,11 @@ export default class Store extends EventEmitter<{
907
if (current === undefined) {
908
continue;
909
}
904
- // Include the root even if we won't suspend it.
910
+ // Include the root even if we won't show it suspended (because that's just blank).
911
// You should be able to see what suspended the shell.
906
- suspenseTreeList.push(current.id);
912
+ if (!uniqueSuspendersOnly || current.hasUniqueSuspenders) {
913
+ list.push(current.id);
914
+ }
915
// Add children in reverse order to maintain document order
916
for (let j = current.children.length - 1; j >= 0; j--) {
917
const childSuspense = this.getSuspenseByID(current.children[j]);
@@ -914,7 +922,7 @@ export default class Store extends EventEmitter<{
922
}
923
}
924
917
- return suspenseTreeList;
925
+ return list;
926
}
927
928
getRendererIDForElement(id: number): number | null {
@@ -1580,6 +1588,7 @@ export default class Store extends EventEmitter<{
1588
children: [],
1589
name,
1590
rects,
1591
+ hasUniqueSuspenders: false,
1592
});
1593
1594
hasSuspenseTreeChanged = true;
@@ -1749,6 +1758,42 @@ export default class Store extends EventEmitter<{
1758
1759
break;
1760
}
1761
+ case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
1762
+ const changeLength = operations[i + 1];
1763
+ i += 2;
1764
+
1765
+ for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
1766
+ const id = operations[i];
1767
+ const hasUniqueSuspenders = operations[i + 1] === 1;
1768
+ const suspense = this._idToSuspense.get(id);
1769
+
1770
+ if (suspense === undefined) {
1771
+ this._throwAndEmitError(
1772
+ Error(
1773
+ `Cannot update suspenders of suspense node "${id}" because no matching node was found in the Store.`,
1774
+ ),
1775
+ );
1776
+
1777
+ break;
1778
+ }
1779
+
1780
+ i += 2;
1781
+
1782
+ if (__DEBUG__) {
1783
+ const previousHasUniqueSuspenders = suspense.hasUniqueSuspenders;
1784
+ debug(
1785
+ 'Suspender changes',
1786
+ `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} (was ${String(previousHasUniqueSuspenders)})`,
1787
+ );
1788
+ }
1789
+
1790
+ suspense.hasUniqueSuspenders = hasUniqueSuspenders;
1791
+ }
1792
+
1793
+ hasSuspenseTreeChanged = true;
1794
+
1795
+ break;
1796
+ }
1797
default:
1798
this._throwAndEmitError(
1799
new UnsupportedBridgeOperationError(
packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
+7
-7
@@ -52,7 +52,7 @@ type Props = {
52
type: IconType,
53
};
54
55
-const materialIconsViewBox = '0 -960 960 960';
55
+const panelIcons = '0 -960 960 820';
56
export default function ButtonIcon({className = '', type}: Props): React.Node {
57
let pathData = null;
58
let viewBox = '0 0 24 24';
@@ -131,27 +131,27 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
131
break;
132
case 'panel-left-close':
133
pathData = PATH_MATERIAL_PANEL_LEFT_CLOSE;
134
- viewBox = materialIconsViewBox;
134
+ viewBox = panelIcons;
135
break;
136
case 'panel-left-open':
137
pathData = PATH_MATERIAL_PANEL_LEFT_OPEN;
138
- viewBox = materialIconsViewBox;
138
+ viewBox = panelIcons;
139
break;
140
case 'panel-right-close':
141
pathData = PATH_MATERIAL_PANEL_RIGHT_CLOSE;
142
- viewBox = materialIconsViewBox;
142
+ viewBox = panelIcons;
143
break;
144
case 'panel-right-open':
145
pathData = PATH_MATERIAL_PANEL_RIGHT_OPEN;
146
- viewBox = materialIconsViewBox;
146
+ viewBox = panelIcons;
147
break;
148
case 'panel-bottom-open':
149
pathData = PATH_MATERIAL_PANEL_BOTTOM_OPEN;
150
- viewBox = materialIconsViewBox;
150
+ viewBox = panelIcons;
151
break;
152
case 'panel-bottom-close':
153
pathData = PATH_MATERIAL_PANEL_BOTTOM_CLOSE;
154
- viewBox = materialIconsViewBox;
154
+ viewBox = panelIcons;
155
break;
156
case 'suspend':
157
pathData = PATH_SUSPEND;
packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js
+13
@@ -20,6 +20,7 @@ import {
20
SUSPENSE_TREE_OPERATION_REMOVE,
21
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
22
SUSPENSE_TREE_OPERATION_RESIZE,
23
+ SUSPENSE_TREE_OPERATION_SUSPENDERS,
24
} from 'react-devtools-shared/src/constants';
25
import {
26
parseElementDisplayNameFromBackend,
@@ -452,6 +453,18 @@ function updateTree(
453
break;
454
}
455
456
+ case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
457
+ const changesLength = ((operations[i + 1]: any): number);
458
+
459
+ if (__DEBUG__) {
460
+ const changes = operations.slice(i + 2, i + 2 + changesLength * 2);
461
+ debug('Suspender changes', `[${changes.join(',')}]`);
462
+ }
463
+
464
+ i += 2 + changesLength * 2;
465
+ break;
466
+ }
467
+
468
default:
469
throw Error(`Unsupported Bridge operation "${operation}"`);
470
}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
+1
@@ -3,6 +3,7 @@
3
display: flex;
4
flex-direction: row;
5
padding: 0.25rem;
6
+ align-items: center;
7
}
8
9
.SuspenseTimelineInput {
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+35
-6
@@ -11,6 +11,7 @@ import * as React from 'react';
11
import {useContext, useLayoutEffect, useRef} from 'react';
12
import {BridgeContext, StoreContext} from '../context';
13
import {TreeDispatcherContext} from '../Components/TreeContext';
14
+import Tooltip from '../Components/reach-ui/tooltip';
15
import {useHighlightHostInstance} from '../hooks';
16
import {
17
SuspenseTreeDispatcherContext,
@@ -34,8 +35,26 @@ function SuspenseTimelineInput() {
35
selectedRootID: rootID,
36
timeline,
37
timelineIndex,
38
+ uniqueSuspendersOnly,
39
} = useContext(SuspenseTreeStateContext);
40
41
+ function handleToggleUniqueSuspenders(event: SyntheticEvent) {
42
+ const nextUniqueSuspendersOnly = (event.currentTarget as HTMLInputElement)
43
+ .checked;
44
+ const nextTimeline =
45
+ rootID === null
46
+ ? []
47
+ : // TODO: Handle different timeline modes (e.g. random order)
48
+ store.getSuspendableDocumentOrderSuspense(
49
+ rootID,
50
+ nextUniqueSuspendersOnly,
51
+ );
52
+ suspenseTreeDispatch({
53
+ type: 'SET_SUSPENSE_TIMELINE',
54
+ payload: [nextTimeline, null, nextUniqueSuspendersOnly],
55
+ });
56
+ }
57
+
58
const inputRef = useRef<HTMLElement | null>(null);
59
const inputBBox = useRef<ClientRect | null>(null);
60
useLayoutEffect(() => {
@@ -155,9 +174,7 @@ function SuspenseTimelineInput() {
174
175
return (
176
<>
158
- <div>
159
- {timelineIndex}/{max}
160
- </div>
177
+ {timelineIndex}/{max}
178
<div className={styles.SuspenseTimelineInput}>
179
<input
180
className={styles.SuspenseTimelineSlider}
@@ -173,23 +190,35 @@ function SuspenseTimelineInput() {
190
ref={inputRef}
191
/>
192
</div>
193
+ <Tooltip label="Only include boundaries with unique suspenders">
194
+ <input
195
+ checked={uniqueSuspendersOnly}
196
+ type="checkbox"
197
+ onChange={handleToggleUniqueSuspenders}
198
+ />
199
+ </Tooltip>
200
</>
201
);
202
}
203
204
export default function SuspenseTimeline(): React$Node {
205
const store = useContext(StoreContext);
182
- const {roots, selectedRootID} = useContext(SuspenseTreeStateContext);
206
+ const {roots, selectedRootID, uniqueSuspendersOnly} = useContext(
207
+ SuspenseTreeStateContext,
208
+ );
209
const treeDispatch = useContext(TreeDispatcherContext);
210
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
211
212
function handleChange(event: SyntheticEvent) {
213
const newRootID = +event.currentTarget.value;
214
// TODO: scrollIntoView both suspense rects and host instance.
189
- const nextTimeline = store.getSuspendableDocumentOrderSuspense(newRootID);
215
+ const nextTimeline = store.getSuspendableDocumentOrderSuspense(
216
+ newRootID,
217
+ uniqueSuspendersOnly,
218
+ );
219
suspenseTreeDispatch({
220
type: 'SET_SUSPENSE_TIMELINE',
192
- payload: [nextTimeline, newRootID],
221
+ payload: [nextTimeline, newRootID, uniqueSuspendersOnly],
222
});
223
if (nextTimeline.length > 0) {
224
const milestone = nextTimeline[nextTimeline.length - 1];
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+19
-3
@@ -31,6 +31,7 @@ export type SuspenseTreeState = {
31
selectedSuspenseID: SuspenseNode['id'] | null,
32
timeline: $ReadOnlyArray<SuspenseNode['id']>,
33
timelineIndex: number | -1,
34
+ uniqueSuspendersOnly: boolean,
35
};
36
37
type ACTION_SUSPENSE_TREE_MUTATION = {
@@ -51,6 +52,8 @@ type ACTION_SET_SUSPENSE_TIMELINE = {
52
$ReadOnlyArray<SuspenseNode['id']>,
53
// The next Suspense ID to select in the timeline
54
SuspenseNode['id'] | null,
55
+ // Whether this timeline includes only unique suspenders
56
+ boolean,
57
],
58
};
59
type ACTION_SUSPENSE_SET_TIMELINE_INDEX = {
@@ -92,6 +95,7 @@ function getDefaultRootID(store: Store): Element['id'] | null {
95
96
function getInitialState(store: Store): SuspenseTreeState {
97
let initialState: SuspenseTreeState;
98
+ const uniqueSuspendersOnly = true;
99
const selectedRootID = getDefaultRootID(store);
100
// TODO: Default to nearest from inspected
101
if (selectedRootID === null) {
@@ -102,9 +106,13 @@ function getInitialState(store: Store): SuspenseTreeState {
106
selectedRootID,
107
timeline: [],
108
timelineIndex: -1,
109
+ uniqueSuspendersOnly,
110
};
111
} else {
107
- const timeline = store.getSuspendableDocumentOrderSuspense(selectedRootID);
112
+ const timeline = store.getSuspendableDocumentOrderSuspense(
113
+ selectedRootID,
114
+ uniqueSuspendersOnly,
115
+ );
116
const timelineIndex = timeline.length - 1;
117
const selectedSuspenseID =
118
timelineIndex === -1 ? null : timeline[timelineIndex];
@@ -119,6 +127,7 @@ function getInitialState(store: Store): SuspenseTreeState {
127
selectedRootID,
128
timeline,
129
timelineIndex,
130
+ uniqueSuspendersOnly,
131
};
132
}
133
@@ -182,7 +191,10 @@ function SuspenseTreeContextController({children}: Props): React.Node {
191
nextRootID === null
192
? []
193
: // TODO: Handle different timeline modes (e.g. random order)
185
- store.getSuspendableDocumentOrderSuspense(nextRootID);
194
+ store.getSuspendableDocumentOrderSuspense(
195
+ nextRootID,
196
+ state.uniqueSuspendersOnly,
197
+ );
198
199
let nextTimelineIndex =
200
selectedTimelineID === null || nextTimeline.length === 0
@@ -242,6 +254,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
254
const previousTimeline = state.timeline;
255
const nextTimeline = action.payload[0];
256
const nextRootID: SuspenseNode['id'] | null = action.payload[1];
257
+ const nextUniqueSuspendersOnly = action.payload[2];
258
let nextLineage = state.lineage;
259
let nextMilestoneIndex: number | -1 = -1;
260
let nextSelectedSuspenseID = state.selectedSuspenseID;
@@ -255,8 +268,10 @@ function SuspenseTreeContextController({children}: Props): React.Node {
268
const previousMilestoneID =
269
previousTimeline[previousMilestoneIndex];
270
nextMilestoneIndex = nextTimeline.indexOf(previousMilestoneID);
258
- if (nextMilestoneIndex === -1) {
271
+ if (nextMilestoneIndex === -1 && nextTimeline.length > 0) {
272
nextMilestoneIndex = nextTimeline.length - 1;
273
+ nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
274
+ nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
275
}
276
} else if (nextRootID !== null) {
277
nextMilestoneIndex = nextTimeline.length - 1;
@@ -272,6 +287,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
287
nextRootID === null ? state.selectedRootID : nextRootID,
288
timeline: nextTimeline,
289
timelineIndex: nextMilestoneIndex,
290
+ uniqueSuspendersOnly: nextUniqueSuspendersOnly,
291
};
292
}
293
case 'SUSPENSE_SET_TIMELINE_INDEX': {
packages/react-devtools-shared/src/frontend/types.js
+1
@@ -199,6 +199,7 @@ export type SuspenseNode = {
199
children: Array<SuspenseNode['id']>,
200
name: string | null,
201
rects: null | Array<Rect>,
202
+ hasUniqueSuspenders: boolean,
203
};
204
205
// Serialized version of ReactIOInfo
packages/react-devtools-shared/src/utils.js
+11
@@ -44,6 +44,7 @@ import {
44
SUSPENSE_TREE_OPERATION_REMOVE,
45
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
46
SUSPENSE_TREE_OPERATION_RESIZE,
47
+ SUSPENSE_TREE_OPERATION_SUSPENDERS,
48
} from './constants';
49
import {
50
ComponentFilterElementType,
@@ -424,6 +425,16 @@ export function printOperationsArray(operations: Array<number>) {
425
426
break;
427
}
428
+ case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
429
+ const changeLength = operations[i + 1];
430
+ i += 2;
431
+ const changes = operations.slice(i, i + changeLength * 2);
432
+ i += changeLength;
433
+
434
+ logs.push(`Suspense node suspender changes ${changes.join(',')}`);
435
+
436
+ break;
437
+ }
438
default:
439
throw Error(`Unsupported Bridge operation "${operation}"`);
440
}