/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow */ import EventEmitter from 'events'; import {inspect} from 'util'; import { TREE_OPERATION_ADD, TREE_OPERATION_REMOVE, TREE_OPERATION_REORDER_CHILDREN, TREE_OPERATION_UPDATE_TREE_BASE_DURATION, } from '../constants'; import {ElementTypeRoot} from '../types'; import { getSavedComponentFilters, saveComponentFilters, separateDisplayNameAndHOCs, shallowDiffers, utfDecodeString, } from '../utils'; import {localStorageGetItem, localStorageSetItem} from '../storage'; import {__DEBUG__} from '../constants'; import {printStore} from './utils'; import ProfilerStore from './ProfilerStore'; import type {Element} from './views/Components/types'; import type {ComponentFilter, ElementType} from '../types'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; const debug = (methodName, ...args) => { if (__DEBUG__) { console.log( `%cStore %c${methodName}`, 'color: green; font-weight: bold;', 'font-weight: bold;', ...args, ); } }; const LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY = 'React::DevTools::collapseNodesByDefault'; const LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = 'React::DevTools::recordChangeDescriptions'; type Config = {| isProfiling?: boolean, supportsNativeInspection?: boolean, supportsReloadAndProfile?: boolean, supportsProfiling?: boolean, supportsTraceUpdates?: boolean, |}; export type Capabilities = {| hasOwnerMetadata: boolean, supportsProfiling: boolean, |}; /** * The store is the single source of truth for updates from the backend. * ContextProviders can subscribe to the Store for specific things they want to provide. */ export default class Store extends EventEmitter<{| collapseNodesByDefault: [], componentFilters: [], mutated: [[Array, Map]], recordChangeDescriptions: [], roots: [], supportsNativeStyleEditor: [], supportsProfiling: [], supportsReloadAndProfile: [], unsupportedRendererVersionDetected: [], |}> { _bridge: FrontendBridge; // Should new nodes be collapsed by default when added to the tree? _collapseNodesByDefault: boolean = true; _componentFilters: Array; // At least one of the injected renderers contains (DEV only) owner metadata. _hasOwnerMetadata: boolean = false; // Map of ID to (mutable) Element. // Elements are mutated to avoid excessive cloning during tree updates. // The InspectedElementContext also relies on this mutability for its WeakMap usage. _idToElement: Map = new Map(); // Should the React Native style editor panel be shown? _isNativeStyleEditorSupported: boolean = false; // Can the backend use the Storage API (e.g. localStorage)? // If not, features like reload-and-profile will not work correctly and must be disabled. _isBackendStorageAPISupported: boolean = false; _nativeStyleEditorValidAttributes: $ReadOnlyArray | null = null; // Map of element (id) to the set of elements (ids) it owns. // This map enables getOwnersListForElement() to avoid traversing the entire tree. _ownersMap: Map> = new Map(); _profilerStore: ProfilerStore; _recordChangeDescriptions: boolean = false; // Incremented each time the store is mutated. // This enables a passive effect to detect a mutation between render and commit phase. _revision: number = 0; // This Array must be treated as immutable! // Passive effects will check it for changes between render and mount. _roots: $ReadOnlyArray = []; _rootIDToCapabilities: Map = new Map(); // Renderer ID is needed to support inspection fiber props, state, and hooks. _rootIDToRendererID: Map = new Map(); // These options may be initially set by a confiugraiton option when constructing the Store. // In the case of "supportsProfiling", the option may be updated based on the injected renderers. _supportsNativeInspection: boolean = true; _supportsProfiling: boolean = false; _supportsReloadAndProfile: boolean = false; _supportsTraceUpdates: boolean = false; _unsupportedRendererVersionDetected: boolean = false; // Total number of visible elements (within all roots). // Used for windowing purposes. _weightAcrossRoots: number = 0; constructor(bridge: FrontendBridge, config?: Config) { super(); if (__DEBUG__) { debug('constructor', 'subscribing to Bridge'); } this._collapseNodesByDefault = localStorageGetItem(LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY) === 'true'; this._recordChangeDescriptions = localStorageGetItem(LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) === 'true'; this._componentFilters = getSavedComponentFilters(); let isProfiling = false; if (config != null) { isProfiling = config.isProfiling === true; const { supportsNativeInspection, supportsProfiling, supportsReloadAndProfile, supportsTraceUpdates, } = config; this._supportsNativeInspection = supportsNativeInspection !== false; if (supportsProfiling) { this._supportsProfiling = true; } if (supportsReloadAndProfile) { this._supportsReloadAndProfile = true; } if (supportsTraceUpdates) { this._supportsTraceUpdates = true; } } this._bridge = bridge; bridge.addListener('operations', this.onBridgeOperations); bridge.addListener( 'overrideComponentFilters', this.onBridgeOverrideComponentFilters, ); bridge.addListener('shutdown', this.onBridgeShutdown); bridge.addListener( 'isBackendStorageAPISupported', this.onBridgeStorageSupported, ); bridge.addListener( 'isNativeStyleEditorSupported', this.onBridgeNativeStyleEditorSupported, ); bridge.addListener( 'unsupportedRendererVersion', this.onBridgeUnsupportedRendererVersion, ); this._profilerStore = new ProfilerStore(bridge, this, isProfiling); } // This is only used in tests to avoid memory leaks. assertExpectedRootMapSizes() { if (this.roots.length === 0) { // The only safe time to assert these maps are empty is when the store is empty. this.assertMapSizeMatchesRootCount(this._idToElement, '_idToElement'); this.assertMapSizeMatchesRootCount(this._ownersMap, '_ownersMap'); } // These maps should always be the same size as the number of roots this.assertMapSizeMatchesRootCount( this._rootIDToCapabilities, '_rootIDToCapabilities', ); this.assertMapSizeMatchesRootCount( this._rootIDToRendererID, '_rootIDToRendererID', ); } // This is only used in tests to avoid memory leaks. assertMapSizeMatchesRootCount(map: Map, mapName: string) { const expectedSize = this.roots.length; if (map.size !== expectedSize) { throw new Error( `Expected ${mapName} to contain ${expectedSize} items, but it contains ${ map.size } items\n\n${inspect(map, { depth: 20, })}`, ); } } get collapseNodesByDefault(): boolean { return this._collapseNodesByDefault; } set collapseNodesByDefault(value: boolean): void { this._collapseNodesByDefault = value; localStorageSetItem( LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY, value ? 'true' : 'false', ); this.emit('collapseNodesByDefault'); } get componentFilters(): Array { return this._componentFilters; } set componentFilters(value: Array): void { if (this._profilerStore.isProfiling) { // Re-mounting a tree while profiling is in progress might break a lot of assumptions. // If necessary, we could support this- but it doesn't seem like a necessary use case. throw Error('Cannot modify filter preferences while profiling'); } // Filter updates are expensive to apply (since they impact the entire tree). // Let's determine if they've changed and avoid doing this work if they haven't. const prevEnabledComponentFilters = this._componentFilters.filter( filter => filter.isEnabled, ); const nextEnabledComponentFilters = value.filter( filter => filter.isEnabled, ); let haveEnabledFiltersChanged = prevEnabledComponentFilters.length !== nextEnabledComponentFilters.length; if (!haveEnabledFiltersChanged) { for (let i = 0; i < nextEnabledComponentFilters.length; i++) { const prevFilter = prevEnabledComponentFilters[i]; const nextFilter = nextEnabledComponentFilters[i]; if (shallowDiffers(prevFilter, nextFilter)) { haveEnabledFiltersChanged = true; break; } } } this._componentFilters = value; // Update persisted filter preferences stored in localStorage. saveComponentFilters(value); // Notify the renderer that filter prefernces have changed. // This is an expensive opreation; it unmounts and remounts the entire tree, // so only do it if the set of enabled component filters has changed. if (haveEnabledFiltersChanged) { this._bridge.send('updateComponentFilters', value); } this.emit('componentFilters'); } get hasOwnerMetadata(): boolean { return this._hasOwnerMetadata; } get nativeStyleEditorValidAttributes(): $ReadOnlyArray | null { return this._nativeStyleEditorValidAttributes; } get numElements(): number { return this._weightAcrossRoots; } get profilerStore(): ProfilerStore { return this._profilerStore; } get recordChangeDescriptions(): boolean { return this._recordChangeDescriptions; } set recordChangeDescriptions(value: boolean): void { this._recordChangeDescriptions = value; localStorageSetItem( LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, value ? 'true' : 'false', ); this.emit('recordChangeDescriptions'); } get revision(): number { return this._revision; } get rootIDToRendererID(): Map { return this._rootIDToRendererID; } get roots(): $ReadOnlyArray { return this._roots; } get supportsNativeInspection(): boolean { return this._supportsNativeInspection; } get supportsNativeStyleEditor(): boolean { return this._isNativeStyleEditorSupported; } get supportsProfiling(): boolean { return this._supportsProfiling; } get supportsReloadAndProfile(): boolean { // Does the DevTools shell support reloading and eagerly injecting the renderer interface? // And if so, can the backend use the localStorage API? // Both of these are required for the reload-and-profile feature to work. return this._supportsReloadAndProfile && this._isBackendStorageAPISupported; } get supportsTraceUpdates(): boolean { return this._supportsTraceUpdates; } get unsupportedRendererVersionDetected(): boolean { return this._unsupportedRendererVersionDetected; } containsElement(id: number): boolean { return this._idToElement.get(id) != null; } getElementAtIndex(index: number): Element | null { if (index < 0 || index >= this.numElements) { console.warn( `Invalid index ${index} specified; store contains ${this.numElements} items.`, ); return null; } // Find wich root this element is in... let rootID; let root; let rootWeight = 0; for (let i = 0; i < this._roots.length; i++) { rootID = this._roots[i]; root = ((this._idToElement.get(rootID): any): Element); if (root.children.length === 0) { continue; } else if (rootWeight + root.weight > index) { break; } else { rootWeight += root.weight; } } // Find the element in the tree using the weight of each node... // Skip over the root itself, because roots aren't visible in the Elements tree. let currentElement = ((root: any): Element); let currentWeight = rootWeight - 1; while (index !== currentWeight) { const numChildren = currentElement.children.length; for (let i = 0; i < numChildren; i++) { const childID = currentElement.children[i]; const child = ((this._idToElement.get(childID): any): Element); const childWeight = child.isCollapsed ? 1 : child.weight; if (index <= currentWeight + childWeight) { currentWeight++; currentElement = child; break; } else { currentWeight += childWeight; } } } return ((currentElement: any): Element) || null; } getElementIDAtIndex(index: number): number | null { const element: Element | null = this.getElementAtIndex(index); return element === null ? null : element.id; } getElementByID(id: number): Element | null { const element = this._idToElement.get(id); if (element == null) { console.warn(`No element found with id "${id}"`); return null; } return element; } getIndexOfElementID(id: number): number | null { const element = this.getElementByID(id); if (element === null || element.parentID === 0) { return null; } // Walk up the tree to the root. // Increment the index by one for each node we encounter, // and by the weight of all nodes to the left of the current one. // This should be a relatively fast way of determining the index of a node within the tree. let previousID = id; let currentID = element.parentID; let index = 0; while (true) { const current = ((this._idToElement.get(currentID): any): Element); const {children} = current; for (let i = 0; i < children.length; i++) { const childID = children[i]; if (childID === previousID) { break; } const child = ((this._idToElement.get(childID): any): Element); index += child.isCollapsed ? 1 : child.weight; } if (current.parentID === 0) { // We found the root; stop crawling. break; } index++; previousID = current.id; currentID = current.parentID; } // At this point, the current ID is a root (from the previous loop). // We also need to offset the index by previous root weights. for (let i = 0; i < this._roots.length; i++) { const rootID = this._roots[i]; if (rootID === currentID) { break; } const root = ((this._idToElement.get(rootID): any): Element); index += root.weight; } return index; } getOwnersListForElement(ownerID: number): Array { const list = []; let element = this._idToElement.get(ownerID); if (element != null) { list.push({ ...element, depth: 0, }); const unsortedIDs = this._ownersMap.get(ownerID); if (unsortedIDs !== undefined) { const depthMap: Map = new Map([[ownerID, 0]]); // Items in a set are ordered based on insertion. // This does not correlate with their order in the tree. // So first we need to order them. // I wish we could avoid this sorting operation; we could sort at insertion time, // but then we'd have to pay sorting costs even if the owners list was never used. // Seems better to defer the cost, since the set of ids is probably pretty small. const sortedIDs = Array.from(unsortedIDs).sort( (idA, idB) => ((this.getIndexOfElementID(idA): any): number) - ((this.getIndexOfElementID(idB): any): number), ); // Next we need to determine the appropriate depth for each element in the list. // The depth in the list may not correspond to the depth in the tree, // because the list has been filtered to remove intermediate components. // Perhaps the easiest way to do this is to walk up the tree until we reach either: // (1) another node that's already in the tree, or (2) the root (owner) // at which point, our depth is just the depth of that node plus one. sortedIDs.forEach(id => { const innerElement = this._idToElement.get(id); if (innerElement != null) { let parentID = innerElement.parentID; let depth = 0; while (parentID > 0) { if (parentID === ownerID || unsortedIDs.has(parentID)) { depth = depthMap.get(parentID) + 1; depthMap.set(id, depth); break; } const parent = this._idToElement.get(parentID); if (parent == null) { break; } parentID = parent.parentID; } if (depth === 0) { throw Error('Invalid owners list'); } list.push({...innerElement, depth}); } }); } } return list; } getRendererIDForElement(id: number): number | null { let current = this._idToElement.get(id); while (current != null) { if (current.parentID === 0) { const rendererID = this._rootIDToRendererID.get(current.id); return rendererID == null ? null : rendererID; } else { current = this._idToElement.get(current.parentID); } } return null; } getRootIDForElement(id: number): number | null { let current = this._idToElement.get(id); while (current != null) { if (current.parentID === 0) { return current.id; } else { current = this._idToElement.get(current.parentID); } } return null; } isInsideCollapsedSubTree(id: number): boolean { let current = this._idToElement.get(id); while (current != null) { if (current.parentID === 0) { return false; } else { current = this._idToElement.get(current.parentID); if (current != null && current.isCollapsed) { return true; } } } return false; } // TODO Maybe split this into two methods: expand() and collapse() toggleIsCollapsed(id: number, isCollapsed: boolean): void { let didMutate = false; const element = this.getElementByID(id); if (element !== null) { if (isCollapsed) { if (element.type === ElementTypeRoot) { throw Error('Root nodes cannot be collapsed'); } if (!element.isCollapsed) { didMutate = true; element.isCollapsed = true; const weightDelta = 1 - element.weight; let parentElement = ((this._idToElement.get( element.parentID, ): any): Element); while (parentElement != null) { // We don't need to break on a collapsed parent in the same way as the expand case below. // That's because collapsing a node doesn't "bubble" and affect its parents. parentElement.weight += weightDelta; parentElement = this._idToElement.get(parentElement.parentID); } } } else { let currentElement = element; while (currentElement != null) { const oldWeight = currentElement.isCollapsed ? 1 : currentElement.weight; if (currentElement.isCollapsed) { didMutate = true; currentElement.isCollapsed = false; const newWeight = currentElement.isCollapsed ? 1 : currentElement.weight; const weightDelta = newWeight - oldWeight; let parentElement = ((this._idToElement.get( currentElement.parentID, ): any): Element); while (parentElement != null) { parentElement.weight += weightDelta; if (parentElement.isCollapsed) { // It's important to break on a collapsed parent when expanding nodes. // That's because expanding a node "bubbles" up and expands all parents as well. // Breaking in this case prevents us from over-incrementing the expanded weights. break; } parentElement = this._idToElement.get(parentElement.parentID); } } currentElement = currentElement.parentID !== 0 ? this.getElementByID(currentElement.parentID) : null; } } // Only re-calculate weights and emit an "update" event if the store was mutated. if (didMutate) { let weightAcrossRoots = 0; this._roots.forEach(rootID => { const {weight} = ((this.getElementByID(rootID): any): Element); weightAcrossRoots += weight; }); this._weightAcrossRoots = weightAcrossRoots; // The Tree context's search reducer expects an explicit list of ids for nodes that were added or removed. // In this case, we can pass it empty arrays since nodes in a collapsed tree are still there (just hidden). // Updating the selected search index later may require auto-expanding a collapsed subtree though. this.emit('mutated', [[], new Map()]); } } } _adjustParentTreeWeight = ( parentElement: Element | null, weightDelta: number, ) => { let isInsideCollapsedSubTree = false; while (parentElement != null) { parentElement.weight += weightDelta; // Additions and deletions within a collapsed subtree should not bubble beyond the collapsed parent. // Their weight will bubble up when the parent is expanded. if (parentElement.isCollapsed) { isInsideCollapsedSubTree = true; break; } parentElement = ((this._idToElement.get( parentElement.parentID, ): any): Element); } // Additions and deletions within a collapsed subtree should not affect the overall number of elements. if (!isInsideCollapsedSubTree) { this._weightAcrossRoots += weightDelta; } }; onBridgeNativeStyleEditorSupported = ({ isSupported, validAttributes, }: {| isSupported: boolean, validAttributes: ?$ReadOnlyArray, |}) => { this._isNativeStyleEditorSupported = isSupported; this._nativeStyleEditorValidAttributes = validAttributes || null; this.emit('supportsNativeStyleEditor'); }; onBridgeOperations = (operations: Array) => { if (__DEBUG__) { console.groupCollapsed('onBridgeOperations'); debug('onBridgeOperations', operations.join(',')); } let haveRootsChanged = false; // The first two values are always rendererID and rootID const rendererID = operations[0]; const addedElementIDs: Array = []; // This is a mapping of removed ID -> parent ID: const removedElementIDs: Map = new Map(); // We'll use the parent ID to adjust selection if it gets deleted. let i = 2; // Reassemble the string table. const stringTable = [ null, // ID = 0 corresponds to the null string. ]; const stringTableSize = operations[i++]; const stringTableEnd = i + stringTableSize; while (i < stringTableEnd) { const nextLength = operations[i++]; const nextString = utfDecodeString( (operations.slice(i, i + nextLength): any), ); stringTable.push(nextString); i += nextLength; } while (i < operations.length) { const operation = operations[i]; switch (operation) { case TREE_OPERATION_ADD: { const id = ((operations[i + 1]: any): number); const type = ((operations[i + 2]: any): ElementType); i += 3; if (this._idToElement.has(id)) { throw Error( `Cannot add node ${id} because a node with that id is already in the Store.`, ); } let ownerID: number = 0; let parentID: number = ((null: any): number); if (type === ElementTypeRoot) { if (__DEBUG__) { debug('Add', `new root node ${id}`); } const supportsProfiling = operations[i] > 0; i++; const hasOwnerMetadata = operations[i] > 0; i++; this._roots = this._roots.concat(id); this._rootIDToRendererID.set(id, rendererID); this._rootIDToCapabilities.set(id, { hasOwnerMetadata, supportsProfiling, }); this._idToElement.set(id, { children: [], depth: -1, displayName: null, hocDisplayNames: null, id, isCollapsed: false, // Never collapse roots; it would hide the entire tree. key: null, ownerID: 0, parentID: 0, type, weight: 0, }); haveRootsChanged = true; } else { parentID = ((operations[i]: any): number); i++; ownerID = ((operations[i]: any): number); i++; const displayNameStringID = operations[i]; const displayName = stringTable[displayNameStringID]; i++; const keyStringID = operations[i]; const key = stringTable[keyStringID]; i++; if (__DEBUG__) { debug( 'Add', `node ${id} (${displayName || 'null'}) as child of ${parentID}`, ); } if (!this._idToElement.has(parentID)) { throw Error( `Cannot add child ${id} to parent ${parentID} because parent node was not found in the Store.`, ); } const parentElement = ((this._idToElement.get( parentID, ): any): Element); parentElement.children.push(id); const [ displayNameWithoutHOCs, hocDisplayNames, ] = separateDisplayNameAndHOCs(displayName, type); const element: Element = { children: [], depth: parentElement.depth + 1, displayName: displayNameWithoutHOCs, hocDisplayNames, id, isCollapsed: this._collapseNodesByDefault, key, ownerID, parentID: parentElement.id, type, weight: 1, }; this._idToElement.set(id, element); addedElementIDs.push(id); this._adjustParentTreeWeight(parentElement, 1); if (ownerID > 0) { let set = this._ownersMap.get(ownerID); if (set === undefined) { set = new Set(); this._ownersMap.set(ownerID, set); } set.add(id); } } break; } case TREE_OPERATION_REMOVE: { const removeLength = ((operations[i + 1]: any): number); i += 2; for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) { const id = ((operations[i]: any): number); if (!this._idToElement.has(id)) { throw Error( `Cannot remove node ${id} because no matching node was found in the Store.`, ); } i += 1; const element = ((this._idToElement.get(id): any): Element); const {children, ownerID, parentID, weight} = element; if (children.length > 0) { throw new Error(`Node ${id} was removed before its children.`); } this._idToElement.delete(id); let parentElement = null; if (parentID === 0) { if (__DEBUG__) { debug('Remove', `node ${id} root`); } this._roots = this._roots.filter(rootID => rootID !== id); this._rootIDToRendererID.delete(id); this._rootIDToCapabilities.delete(id); haveRootsChanged = true; } else { if (__DEBUG__) { debug('Remove', `node ${id} from parent ${parentID}`); } parentElement = ((this._idToElement.get(parentID): any): Element); if (parentElement === undefined) { throw Error( `Cannot remove node ${id} from parent ${parentID} because no matching node was found in the Store.`, ); } const index = parentElement.children.indexOf(id); parentElement.children.splice(index, 1); } this._adjustParentTreeWeight(parentElement, -weight); removedElementIDs.set(id, parentID); this._ownersMap.delete(id); if (ownerID > 0) { const set = this._ownersMap.get(ownerID); if (set !== undefined) { set.delete(id); } } } break; } case TREE_OPERATION_REORDER_CHILDREN: { const id = ((operations[i + 1]: any): number); const numChildren = ((operations[i + 2]: any): number); i += 3; if (!this._idToElement.has(id)) { throw Error( `Cannot reorder children for node ${id} because no matching node was found in the Store.`, ); } const element = ((this._idToElement.get(id): any): Element); const children = element.children; if (children.length !== numChildren) { throw Error( `Children cannot be added or removed during a reorder operation.`, ); } for (let j = 0; j < numChildren; j++) { const childID = operations[i + j]; children[j] = childID; if (__DEV__) { // This check is more expensive so it's gated by __DEV__. const childElement = this._idToElement.get(childID); if (childElement == null || childElement.parentID !== id) { console.error( `Children cannot be added or removed during a reorder operation.`, ); } } } i += numChildren; if (__DEBUG__) { debug('Re-order', `Node ${id} children ${children.join(',')}`); } break; } case TREE_OPERATION_UPDATE_TREE_BASE_DURATION: // Base duration updates are only sent while profiling is in progress. // We can ignore them at this point. // The profiler UI uses them lazily in order to generate the tree. i += 3; break; default: throw Error(`Unsupported Bridge operation ${operation}`); } } this._revision++; if (haveRootsChanged) { const prevSupportsProfiling = this._supportsProfiling; this._hasOwnerMetadata = false; this._supportsProfiling = false; this._rootIDToCapabilities.forEach( ({hasOwnerMetadata, supportsProfiling}) => { if (hasOwnerMetadata) { this._hasOwnerMetadata = true; } if (supportsProfiling) { this._supportsProfiling = true; } }, ); this.emit('roots'); if (this._supportsProfiling !== prevSupportsProfiling) { this.emit('supportsProfiling'); } } if (__DEBUG__) { console.log(printStore(this, true)); console.groupEnd(); } this.emit('mutated', [addedElementIDs, removedElementIDs]); }; // Certain backends save filters on a per-domain basis. // In order to prevent filter preferences and applied filters from being out of sync, // this message enables the backend to override the frontend's current ("saved") filters. // This action should also override the saved filters too, // else reloading the frontend without reloading the backend would leave things out of sync. onBridgeOverrideComponentFilters = ( componentFilters: Array, ) => { this._componentFilters = componentFilters; saveComponentFilters(componentFilters); }; onBridgeShutdown = () => { if (__DEBUG__) { debug('onBridgeShutdown', 'unsubscribing from Bridge'); } this._bridge.removeListener('operations', this.onBridgeOperations); this._bridge.removeListener('shutdown', this.onBridgeShutdown); this._bridge.removeListener( 'isBackendStorageAPISupported', this.onBridgeStorageSupported, ); }; onBridgeStorageSupported = (isBackendStorageAPISupported: boolean) => { this._isBackendStorageAPISupported = isBackendStorageAPISupported; this.emit('supportsReloadAndProfile'); }; onBridgeUnsupportedRendererVersion = () => { this._unsupportedRendererVersionDetected = true; this.emit('unsupportedRendererVersionDetected'); }; }