From 8c1501452cea0d3779a2b9e7a5e4dd5348427b8a Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 11 Sep 2025 10:54:25 +0200 Subject: [PATCH 1/2] [DevTools] Preserve Suspense lineage when clicking through breadcrumbs (#34422) --- .../src/devtools/store.js | 77 ++++- .../views/SuspenseTab/SuspenseBreadcrumbs.css | 3 + .../views/SuspenseTab/SuspenseBreadcrumbs.js | 82 ++--- .../views/SuspenseTab/SuspenseRects.js | 23 +- .../views/SuspenseTab/SuspenseTimeline.js | 157 ++++----- .../views/SuspenseTab/SuspenseTreeContext.js | 304 +++++++++++++++--- 6 files changed, 454 insertions(+), 192 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 310321b5ffc..0a9c84717f0 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -111,7 +111,7 @@ export default class Store extends EventEmitter<{ roots: [], rootSupportsBasicProfiling: [], rootSupportsTimelineProfiling: [], - suspenseTreeMutated: [], + suspenseTreeMutated: [[Map]], supportsNativeStyleEditor: [], supportsReloadAndProfile: [], unsupportedBridgeProtocolDetected: [], @@ -847,6 +847,76 @@ export default class Store extends EventEmitter<{ return list; } + getSuspenseLineage( + suspenseID: SuspenseNode['id'], + ): $ReadOnlyArray { + const lineage: Array = []; + let next: null | SuspenseNode = this.getSuspenseByID(suspenseID); + while (next !== null) { + if (next.parentID === 0) { + next = null; + } else { + lineage.unshift(next.id); + next = this.getSuspenseByID(next.parentID); + } + } + + return lineage; + } + + /** + * Like {@link getRootIDForElement} but should be used for traversing Suspense since it works with disconnected nodes. + */ + getSuspenseRootIDForSuspense(id: SuspenseNode['id']): number | null { + let current = this._idToSuspense.get(id); + while (current !== undefined) { + if (current.parentID === 0) { + return current.id; + } else { + current = this._idToSuspense.get(current.parentID); + } + } + return null; + } + + getSuspendableDocumentOrderSuspense( + rootID: Element['id'] | void, + ): $ReadOnlyArray { + if (rootID === undefined) { + return []; + } + const root = this.getElementByID(rootID); + if (root === null) { + return []; + } + if (!this.supportsTogglingSuspense(root.id)) { + return []; + } + const suspenseTreeList: SuspenseNode['id'][] = []; + const suspense = this.getSuspenseByID(root.id); + if (suspense !== null) { + const stack = [suspense]; + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) { + continue; + } + // Include the root even if we won't suspend it. + // You should be able to see what suspended the shell. + suspenseTreeList.push(current.id); + // Add children in reverse order to maintain document order + for (let j = current.children.length - 1; j >= 0; j--) { + const childSuspense = this.getSuspenseByID(current.children[j]); + if (childSuspense !== null) { + stack.push(childSuspense); + } + } + } + } + + return suspenseTreeList; + } + getRendererIDForElement(id: number): number | null { let current = this._idToElement.get(id); while (current !== undefined) { @@ -1030,6 +1100,8 @@ export default class Store extends EventEmitter<{ const addedElementIDs: Array = []; // This is a mapping of removed ID -> parent ID: const removedElementIDs: Map = new Map(); + const removedSuspenseIDs: Map = + new Map(); // We'll use the parent ID to adjust selection if it gets deleted. let i = 2; @@ -1541,6 +1613,7 @@ export default class Store extends EventEmitter<{ } this._idToSuspense.delete(id); + removedSuspenseIDs.set(id, parentID); let parentSuspense: ?SuspenseNode = null; if (parentID === 0) { @@ -1748,7 +1821,7 @@ export default class Store extends EventEmitter<{ } if (hasSuspenseTreeChanged) { - this.emit('suspenseTreeMutated'); + this.emit('suspenseTreeMutated', [removedSuspenseIDs]); } if (__DEBUG__) { diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css index 324a95c5a58..1e1544b477c 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css @@ -19,6 +19,9 @@ background: var(--color-button-background); border: none; border-radius: 0.25rem; + color: var(--color-button); + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); padding: 0.25rem; white-space: nowrap; } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js index 704a2dd4415..b49d0b5eb9a 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js @@ -12,68 +12,52 @@ import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/Syntheti import * as React from 'react'; import {useContext} from 'react'; -import { - TreeDispatcherContext, - TreeStateContext, -} from '../Components/TreeContext'; +import {TreeDispatcherContext} from '../Components/TreeContext'; +import {StoreContext} from '../context'; import {useHighlightHostInstance} from '../hooks'; import styles from './SuspenseBreadcrumbs.css'; -import {useSuspenseStore} from './SuspenseTreeContext'; +import { + SuspenseTreeStateContext, + SuspenseTreeDispatcherContext, +} from './SuspenseTreeContext'; export default function SuspenseBreadcrumbs(): React$Node { - const store = useSuspenseStore(); - const dispatch = useContext(TreeDispatcherContext); - const {inspectedElementID} = useContext(TreeStateContext); + const store = useContext(StoreContext); + const treeDispatch = useContext(TreeDispatcherContext); + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); + const {selectedSuspenseID, lineage} = useContext(SuspenseTreeStateContext); const {highlightHostInstance, clearHighlightHostInstance} = useHighlightHostInstance(); - // TODO: Use the nearest Suspense boundary - const inspectedSuspenseID = inspectedElementID; - if (inspectedSuspenseID === null) { - return null; - } - - const suspense = store.getSuspenseByID(inspectedSuspenseID); - if (suspense === null) { - return null; - } - - const lineage: SuspenseNode[] = []; - let next: null | SuspenseNode = suspense; - while (next !== null) { - if (next.parentID === 0) { - next = null; - } else { - lineage.unshift(next); - next = store.getSuspenseByID(next.parentID); - } - } - - function handleClick(node: SuspenseNode, event: SyntheticMouseEvent) { + function handleClick(id: SuspenseNode['id'], event: SyntheticMouseEvent) { event.preventDefault(); - dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: node.id}); + treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id}); + suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id}); } return (
    - {lineage.map((node, index) => { - return ( -
  1. - -
  2. - ); - })} + {lineage !== null && + lineage.map((id, index) => { + const node = store.getSuspenseByID(id); + + return ( +
  3. + +
  4. + ); + })}
); } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index ab1b6276b75..a03439c07d9 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -19,9 +19,13 @@ import { TreeDispatcherContext, TreeStateContext, } from '../Components/TreeContext'; +import {StoreContext} from '../context'; import {useHighlightHostInstance} from '../hooks'; import styles from './SuspenseRects.css'; -import {useSuspenseStore} from './SuspenseTreeContext'; +import { + SuspenseTreeStateContext, + SuspenseTreeDispatcherContext, +} from './SuspenseTreeContext'; import typeof { SyntheticMouseEvent, SyntheticPointerEvent, @@ -44,8 +48,9 @@ function SuspenseRects({ }: { suspenseID: SuspenseNode['id'], }): React$Node { - const dispatch = useContext(TreeDispatcherContext); - const store = useSuspenseStore(); + const store = useContext(StoreContext); + const treeDispatch = useContext(TreeDispatcherContext); + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); const {inspectedElementID} = useContext(TreeStateContext); @@ -64,7 +69,11 @@ function SuspenseRects({ return; } event.preventDefault(); - dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID}); + treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID}); + suspenseTreeDispatch({ + type: 'SET_SUSPENSE_LINEAGE', + payload: suspenseID, + }); } function handlePointerOver(event: SyntheticPointerEvent) { @@ -157,7 +166,7 @@ function SuspenseRectsShell({ }: { rootID: SuspenseNode['id'], }): React$Node { - const store = useSuspenseStore(); + const store = useContext(StoreContext); const root = store.getSuspenseByID(rootID); if (root === null) { console.warn(` Could not find suspense node id ${rootID}`); @@ -174,9 +183,9 @@ function SuspenseRectsShell({ } function SuspenseRectsContainer(): React$Node { - const store = useSuspenseStore(); + const store = useContext(StoreContext); // TODO: This relies on a full re-render of all children when the Suspense tree changes. - const roots = store.roots; + const {roots} = useContext(SuspenseTreeStateContext); const boundingRect = getDocumentBoundingRect(store, roots); diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index bfd90d0dd75..65f83d72bd2 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -7,70 +7,34 @@ * @flow */ -import type {Element, SuspenseNode} from '../../../frontend/types'; -import type Store from '../../store'; - import * as React from 'react'; -import {useContext, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import {BridgeContext} from '../context'; +import {useContext, useLayoutEffect, useRef} from 'react'; +import {BridgeContext, StoreContext} from '../context'; import {TreeDispatcherContext} from '../Components/TreeContext'; import {useHighlightHostInstance} from '../hooks'; -import {useSuspenseStore} from './SuspenseTreeContext'; +import { + SuspenseTreeDispatcherContext, + SuspenseTreeStateContext, +} from './SuspenseTreeContext'; import styles from './SuspenseTimeline.css'; import typeof { SyntheticEvent, SyntheticPointerEvent, } from 'react-dom-bindings/src/events/SyntheticEvent'; -function getSuspendableDocumentOrderSuspense( - store: Store, - rootID: Element['id'] | void, -): Array { - if (rootID === undefined) { - return []; - } - const root = store.getElementByID(rootID); - if (root === null) { - return []; - } - if (!store.supportsTogglingSuspense(root.id)) { - return []; - } - const suspenseTreeList: SuspenseNode[] = []; - const suspense = store.getSuspenseByID(root.id); - if (suspense !== null) { - const stack = [suspense]; - while (stack.length > 0) { - const current = stack.pop(); - if (current === undefined) { - continue; - } - // Include the root even if we won't suspend it. - // You should be able to see what suspended the shell. - suspenseTreeList.push(current); - // Add children in reverse order to maintain document order - for (let j = current.children.length - 1; j >= 0; j--) { - const childSuspense = store.getSuspenseByID(current.children[j]); - if (childSuspense !== null) { - stack.push(childSuspense); - } - } - } - } - - return suspenseTreeList; -} - -function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { +function SuspenseTimelineInput() { const bridge = useContext(BridgeContext); - const store = useSuspenseStore(); - const dispatch = useContext(TreeDispatcherContext); + const store = useContext(StoreContext); + const treeDispatch = useContext(TreeDispatcherContext); + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); const {highlightHostInstance, clearHighlightHostInstance} = useHighlightHostInstance(); - const timeline = useMemo(() => { - return getSuspendableDocumentOrderSuspense(store, rootID); - }, [store, store.revisionSuspense, rootID]); + const { + selectedRootID: rootID, + timeline, + timelineIndex, + } = useContext(SuspenseTreeStateContext); const inputRef = useRef(null); const inputBBox = useRef(null); @@ -97,15 +61,11 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { const min = 0; const max = timeline.length > 0 ? timeline.length - 1 : 0; - const [value, setValue] = useState(max); - - if (value > max) { - // TODO: Handle timeline changes - setValue(max); - } - if (rootID === undefined) { - return
Root not found.
; + if (rootID === null) { + return ( +
No root selected.
+ ); } if (!store.supportsTogglingSuspense(rootID)) { @@ -124,8 +84,21 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { ); } + function switchSuspenseNode(nextTimelineIndex: number) { + const nextSelectedSuspenseID = timeline[nextTimelineIndex]; + highlightHostInstance(nextSelectedSuspenseID); + treeDispatch({ + type: 'SELECT_ELEMENT_BY_ID', + payload: nextSelectedSuspenseID, + }); + suspenseTreeDispatch({ + type: 'SUSPENSE_SET_TIMELINE_INDEX', + payload: nextTimelineIndex, + }); + } + function handleChange(event: SyntheticEvent) { - if (rootID === undefined) { + if (rootID === null) { return; } const rendererID = store.getRendererIDForElement(rootID); @@ -136,10 +109,8 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { return; } - const pendingValue = +event.currentTarget.value; - const suspendedSet = timeline - .slice(pendingValue) - .map(suspense => suspense.id); + const pendingTimelineIndex = +event.currentTarget.value; + const suspendedSet = timeline.slice(pendingTimelineIndex); bridge.send('overrideSuspenseMilestone', { rendererID, @@ -147,11 +118,7 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { suspendedSet, }); - const suspense = timeline[pendingValue]; - const elementID = suspense.id; - highlightHostInstance(elementID); - dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: elementID}); - setValue(pendingValue); + switchSuspenseNode(pendingTimelineIndex); } function handleBlur() { @@ -159,10 +126,7 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { } function handleFocus() { - const suspense = timeline[value]; - - dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspense.id}); - highlightHostInstance(suspense.id); + switchSuspenseNode(timelineIndex); } function handlePointerMove(event: SyntheticPointerEvent) { @@ -180,19 +144,19 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { max, ), ); - const suspense = timeline[hoveredValue]; - if (suspense === undefined) { + const suspenseID = timeline[hoveredValue]; + if (suspenseID === undefined) { throw new Error( `Suspense node not found for value ${hoveredValue} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`, ); } - highlightHostInstance(suspense.id); + highlightHostInstance(suspenseID); } return ( <>
- {value}/{max} + {timelineIndex}/{max}
{ - const suspense = store.getSuspenseByID(rootID); - return ( - store.supportsTogglingSuspense(rootID) && - suspense !== null && - suspense.children.length > 1 - ); - }); - const [selectedRootID, setSelectedRootID] = useState(defaultSelectedRootID); - - if (selectedRootID === undefined && defaultSelectedRootID !== undefined) { - setSelectedRootID(defaultSelectedRootID); - } + const store = useContext(StoreContext); + const {roots, selectedRootID} = useContext(SuspenseTreeStateContext); + const treeDispatch = useContext(TreeDispatcherContext); + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); function handleChange(event: SyntheticEvent) { const newRootID = +event.currentTarget.value; // TODO: scrollIntoView both suspense rects and host instance. - setSelectedRootID(newRootID); + const nextTimeline = store.getSuspendableDocumentOrderSuspense(newRootID); + suspenseTreeDispatch({ + type: 'SET_SUSPENSE_TIMELINE', + payload: [nextTimeline, newRootID], + }); + if (nextTimeline.length > 0) { + const milestone = nextTimeline[nextTimeline.length - 1]; + treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: milestone}); + } } return (
- + {roots.length > 0 && (
+ + + ); } export default function SuspenseTimeline(): React$Node { const store = useContext(StoreContext); - const {roots, selectedRootID} = useContext(SuspenseTreeStateContext); + const {roots, selectedRootID, uniqueSuspendersOnly} = useContext( + SuspenseTreeStateContext, + ); const treeDispatch = useContext(TreeDispatcherContext); const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); function handleChange(event: SyntheticEvent) { const newRootID = +event.currentTarget.value; // TODO: scrollIntoView both suspense rects and host instance. - const nextTimeline = store.getSuspendableDocumentOrderSuspense(newRootID); + const nextTimeline = store.getSuspendableDocumentOrderSuspense( + newRootID, + uniqueSuspendersOnly, + ); suspenseTreeDispatch({ type: 'SET_SUSPENSE_TIMELINE', - payload: [nextTimeline, newRootID], + payload: [nextTimeline, newRootID, uniqueSuspendersOnly], }); if (nextTimeline.length > 0) { const milestone = nextTimeline[nextTimeline.length - 1]; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js index 9d55b3f76ce..3f0c5fd41ae 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js @@ -31,6 +31,7 @@ export type SuspenseTreeState = { selectedSuspenseID: SuspenseNode['id'] | null, timeline: $ReadOnlyArray, timelineIndex: number | -1, + uniqueSuspendersOnly: boolean, }; type ACTION_SUSPENSE_TREE_MUTATION = { @@ -51,6 +52,8 @@ type ACTION_SET_SUSPENSE_TIMELINE = { $ReadOnlyArray, // The next Suspense ID to select in the timeline SuspenseNode['id'] | null, + // Whether this timeline includes only unique suspenders + boolean, ], }; type ACTION_SUSPENSE_SET_TIMELINE_INDEX = { @@ -92,6 +95,7 @@ function getDefaultRootID(store: Store): Element['id'] | null { function getInitialState(store: Store): SuspenseTreeState { let initialState: SuspenseTreeState; + const uniqueSuspendersOnly = true; const selectedRootID = getDefaultRootID(store); // TODO: Default to nearest from inspected if (selectedRootID === null) { @@ -102,9 +106,13 @@ function getInitialState(store: Store): SuspenseTreeState { selectedRootID, timeline: [], timelineIndex: -1, + uniqueSuspendersOnly, }; } else { - const timeline = store.getSuspendableDocumentOrderSuspense(selectedRootID); + const timeline = store.getSuspendableDocumentOrderSuspense( + selectedRootID, + uniqueSuspendersOnly, + ); const timelineIndex = timeline.length - 1; const selectedSuspenseID = timelineIndex === -1 ? null : timeline[timelineIndex]; @@ -119,6 +127,7 @@ function getInitialState(store: Store): SuspenseTreeState { selectedRootID, timeline, timelineIndex, + uniqueSuspendersOnly, }; } @@ -182,7 +191,10 @@ function SuspenseTreeContextController({children}: Props): React.Node { nextRootID === null ? [] : // TODO: Handle different timeline modes (e.g. random order) - store.getSuspendableDocumentOrderSuspense(nextRootID); + store.getSuspendableDocumentOrderSuspense( + nextRootID, + state.uniqueSuspendersOnly, + ); let nextTimelineIndex = selectedTimelineID === null || nextTimeline.length === 0 @@ -242,6 +254,7 @@ function SuspenseTreeContextController({children}: Props): React.Node { const previousTimeline = state.timeline; const nextTimeline = action.payload[0]; const nextRootID: SuspenseNode['id'] | null = action.payload[1]; + const nextUniqueSuspendersOnly = action.payload[2]; let nextLineage = state.lineage; let nextMilestoneIndex: number | -1 = -1; let nextSelectedSuspenseID = state.selectedSuspenseID; @@ -255,8 +268,10 @@ function SuspenseTreeContextController({children}: Props): React.Node { const previousMilestoneID = previousTimeline[previousMilestoneIndex]; nextMilestoneIndex = nextTimeline.indexOf(previousMilestoneID); - if (nextMilestoneIndex === -1) { + if (nextMilestoneIndex === -1 && nextTimeline.length > 0) { nextMilestoneIndex = nextTimeline.length - 1; + nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex]; + nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID); } } else if (nextRootID !== null) { nextMilestoneIndex = nextTimeline.length - 1; @@ -272,6 +287,7 @@ function SuspenseTreeContextController({children}: Props): React.Node { nextRootID === null ? state.selectedRootID : nextRootID, timeline: nextTimeline, timelineIndex: nextMilestoneIndex, + uniqueSuspendersOnly: nextUniqueSuspendersOnly, }; } case 'SUSPENSE_SET_TIMELINE_INDEX': { diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index 8e6a3394f91..7762af43e00 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -199,6 +199,7 @@ export type SuspenseNode = { children: Array, name: string | null, rects: null | Array, + hasUniqueSuspenders: boolean, }; // Serialized version of ReactIOInfo diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index b404608e557..7e256febea0 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -44,6 +44,7 @@ import { SUSPENSE_TREE_OPERATION_REMOVE, SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, SUSPENSE_TREE_OPERATION_RESIZE, + SUSPENSE_TREE_OPERATION_SUSPENDERS, } from './constants'; import { ComponentFilterElementType, @@ -424,6 +425,16 @@ export function printOperationsArray(operations: Array) { break; } + case SUSPENSE_TREE_OPERATION_SUSPENDERS: { + const changeLength = operations[i + 1]; + i += 2; + const changes = operations.slice(i, i + changeLength * 2); + i += changeLength; + + logs.push(`Suspense node suspender changes ${changes.join(',')}`); + + break; + } default: throw Error(`Unsupported Bridge operation "${operation}"`); }