Files
flipper/desktop/plugins/fresco/index.tsx
Michel Weststrate 1bb1cae167 Don't send messages to disconnected clients. Make exportPersistedState compatible with disconnected devices.
Summary:
This diff addresses two problems:

1. Since clients plugins can be active beyond having a connection, we have to make it possible for plugin authors to check if they are connected before they make a call.
2. if there is a custom `exportPersistedState`, plugins should be able to skip making calls if the device has disconnected.

Introducing this change makes it possible to interact with a reasonable level with disconnected clients, and makes it possible to create Flipper traces for disconnected clients.

Note that both items were already problems before supporting offline clients; as there can be a noticeable delay between disconnecting and Flipper detecting that (i've seen up to 30 secs). What happend previously in those cases is that the export would simply hang, as would other user interactions, as loosing the connection in the middle of a process would cause the promise chains to be neither rejected or resolved, which is pretty iffy.

Before this diff, trying to export a disconnected device would hang forever like:

{F369600601}

Reviewed By: nikoant

Differential Revision: D26250895

fbshipit-source-id: 177624a116883c3cba14390cd0fe164e243bb97c
2021-02-09 04:16:26 -08:00

488 lines
14 KiB
TypeScript

/**
* 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.
*
* @format
*/
import {
ImageId,
ImageData,
ImagesList,
ImagesListResponse,
ImageEvent,
FrescoDebugOverlayEvent,
AndroidCloseableReferenceLeakEvent,
CacheInfo,
} from './api';
import {Fragment} from 'react';
import {ImagesMap} from './ImagePool';
import {ReduxState} from 'flipper';
import React from 'react';
import ImagesCacheOverview from './ImagesCacheOverview';
import {
FlipperPlugin,
FlexRow,
Text,
DetailSidebar,
colors,
styled,
isProduction,
Notification,
BaseAction,
} from 'flipper';
import ImagesSidebar from './ImagesSidebar';
import ImagePool from './ImagePool';
export type ImageEventWithId = ImageEvent & {eventId: number};
export type PersistedState = {
surfaceList: Set<string>;
images: ImagesList;
events: Array<ImageEventWithId>;
imagesMap: ImagesMap;
closeableReferenceLeaks: Array<AndroidCloseableReferenceLeakEvent>;
isLeakTrackingEnabled: boolean;
showDiskImages: boolean;
nextEventId: number;
};
type PluginState = {
selectedSurfaces: Set<string>;
selectedImage: ImageId | null;
isDebugOverlayEnabled: boolean;
isAutoRefreshEnabled: boolean;
images: ImagesList;
coldStartFilter: boolean;
};
const EmptySidebar = styled(FlexRow)({
alignItems: 'center',
justifyContent: 'center',
color: colors.light30,
padding: 15,
fontSize: 16,
});
export const InlineFlexRow = styled(FlexRow)({
display: 'inline-block',
});
const surfaceDefaultText = 'SELECT ALL SURFACES';
const debugLog = (...args: any[]) => {
if (!isProduction()) {
// eslint-disable-next-line no-console
console.log(...args);
}
};
export default class FlipperImagesPlugin extends FlipperPlugin<
PluginState,
BaseAction,
PersistedState
> {
static defaultPersistedState: PersistedState = {
images: [],
events: [],
imagesMap: {},
surfaceList: new Set(),
closeableReferenceLeaks: [],
isLeakTrackingEnabled: false,
showDiskImages: false,
nextEventId: 0,
};
static exportPersistedState = (
callClient: undefined | ((method: string, params?: any) => Promise<any>),
persistedState: PersistedState,
store?: ReduxState,
): Promise<PersistedState> => {
const defaultPromise = Promise.resolve(persistedState);
if (!persistedState) {
persistedState = FlipperImagesPlugin.defaultPersistedState;
}
if (!store || !callClient) {
return defaultPromise;
}
return Promise.all([
callClient('listImages', {showDiskImages: persistedState.showDiskImages}),
callClient('getAllImageEventsInfo'),
]).then(async ([responseImages, responseEvents]) => {
const levels: ImagesList = responseImages.levels;
const events: Array<ImageEventWithId> = responseEvents.events;
let pluginData: PersistedState = {
...persistedState,
images: persistedState ? [...persistedState.images, ...levels] : levels,
closeableReferenceLeaks:
(persistedState && persistedState.closeableReferenceLeaks) || [],
};
events.forEach((event: ImageEventWithId, index) => {
if (!event) {
return;
}
const {attribution} = event;
if (
attribution &&
attribution instanceof Array &&
attribution.length > 0
) {
const surface = attribution[0] ? attribution[0].trim() : undefined;
if (surface && surface.length > 0) {
pluginData.surfaceList = new Set([
...pluginData.surfaceList,
surface,
]);
}
}
pluginData = {
...pluginData,
events: [{...event, eventId: index}, ...pluginData.events],
};
});
const idSet: Set<string> = levels.reduce((acc, level: CacheInfo) => {
level.imageIds.forEach((id) => {
acc.add(id);
});
return acc;
}, new Set<string>());
const imageDataList: Array<ImageData> = [];
for (const id of idSet) {
try {
const imageData: ImageData = await callClient('getImage', {
imageId: id,
});
imageDataList.push(imageData);
} catch (e) {
console.error(e);
}
}
imageDataList.forEach((data: ImageData) => {
const imagesMap = {...pluginData.imagesMap};
imagesMap[data.imageId] = data;
pluginData.imagesMap = imagesMap;
});
return pluginData;
});
};
static persistedStateReducer = (
persistedState: PersistedState,
method: string,
data: AndroidCloseableReferenceLeakEvent | ImageEvent,
): PersistedState => {
if (method == 'closeable_reference_leak_event') {
const event: AndroidCloseableReferenceLeakEvent = data as AndroidCloseableReferenceLeakEvent;
return {
...persistedState,
closeableReferenceLeaks: persistedState.closeableReferenceLeaks.concat(
event,
),
};
} else if (method == 'events') {
const event: ImageEvent = data as ImageEvent;
debugLog('Received events', event);
let {surfaceList} = persistedState;
const {attribution} = event;
if (attribution instanceof Array && attribution.length > 0) {
const surface = attribution[0] ? attribution[0].trim() : undefined;
if (surface && surface.length > 0) {
surfaceList = new Set([...surfaceList, surface]);
}
}
return {
...persistedState,
surfaceList,
events: [
{eventId: persistedState.nextEventId, ...event},
...persistedState.events,
],
nextEventId: persistedState.nextEventId + 1,
};
}
return persistedState;
};
static getActiveNotifications = ({
closeableReferenceLeaks = [],
isLeakTrackingEnabled = false,
}: PersistedState): Array<Notification> =>
closeableReferenceLeaks
.filter((_) => isLeakTrackingEnabled)
.map((event: AndroidCloseableReferenceLeakEvent) => ({
id: event.identityHashCode,
title: `Leaked CloseableReference: ${event.className}`,
message: (
<Fragment>
<InlineFlexRow>
CloseableReference leaked for{' '}
<Text code={true}>{event.className}</Text>
(identity hashcode: {event.identityHashCode}).
</InlineFlexRow>
<InlineFlexRow>
<Text bold={true}>Stacktrace:</Text>
</InlineFlexRow>
<InlineFlexRow>
<Text code={true}>{event.stacktrace || '<unavailable>'}</Text>
</InlineFlexRow>
</Fragment>
),
severity: 'error',
category: 'closeablereference_leak',
}));
state: PluginState = {
selectedSurfaces: new Set([surfaceDefaultText]),
selectedImage: null,
isDebugOverlayEnabled: false,
isAutoRefreshEnabled: false,
images: [],
coldStartFilter: false,
};
imagePool: ImagePool | undefined;
nextEventId: number = 1;
filterImages = (
images: ImagesList,
events: Array<ImageEventWithId>,
surfaces: Set<string>,
coldStart: boolean,
): ImagesList => {
if (!surfaces || (surfaces.has(surfaceDefaultText) && !coldStart)) {
return images;
}
const imageList = images.map((image: CacheInfo) => {
const imageIdList = image.imageIds.filter((imageID) => {
const filteredEvents = events.filter((event: ImageEventWithId) => {
const output =
event.attribution &&
event.attribution.length > 0 &&
event.imageIds &&
event.imageIds.includes(imageID);
if (surfaces.has(surfaceDefaultText)) {
return output && coldStart && event.coldStart;
}
return (
(!coldStart || (coldStart && event.coldStart)) &&
output &&
surfaces.has(event.attribution[0])
);
});
return filteredEvents.length > 0;
});
return {...image, imageIds: imageIdList};
});
return imageList;
};
init() {
debugLog('init()');
this.updateCaches('init');
this.client.subscribe(
'debug_overlay_event',
(event: FrescoDebugOverlayEvent) => {
this.setState({isDebugOverlayEnabled: event.enabled});
},
);
this.imagePool = new ImagePool(this.getImage, (images: ImagesMap) =>
this.props.setPersistedState({imagesMap: images}),
);
const images = this.filterImages(
this.props.persistedState.images,
this.props.persistedState.events,
this.state.selectedSurfaces,
this.state.coldStartFilter,
);
this.setState({images});
}
teardown() {
this.imagePool ? this.imagePool.clear() : undefined;
}
updateImagesOnUI = (
images: ImagesList,
surfaces: Set<string>,
coldStart: boolean,
) => {
const filteredImages = this.filterImages(
images,
this.props.persistedState.events,
surfaces,
coldStart,
);
this.setState({
selectedSurfaces: surfaces,
images: filteredImages,
coldStartFilter: coldStart,
});
};
updateCaches = (reason: string) => {
debugLog('Requesting images list (reason=' + reason + ')');
this.client
.call('listImages', {
showDiskImages: this.props.persistedState.showDiskImages,
})
.then((response: ImagesListResponse) => {
response.levels.forEach((data) =>
this.imagePool
? this.imagePool.fetchImages(data.imageIds)
: undefined,
);
this.props.setPersistedState({images: response.levels});
this.updateImagesOnUI(
this.props.persistedState.images,
this.state.selectedSurfaces,
this.state.coldStartFilter,
);
});
};
onClear = (type: string) => {
this.client.call('clear', {type});
setTimeout(() => this.updateCaches('onClear'), 1000);
};
onTrimMemory = () => {
this.client.call('trimMemory', {});
setTimeout(() => this.updateCaches('onTrimMemory'), 1000);
};
onEnableDebugOverlay = (enabled: boolean) => {
this.client.call('enableDebugOverlay', {enabled});
};
onEnableAutoRefresh = (enabled: boolean) => {
this.setState({isAutoRefreshEnabled: enabled});
if (enabled) {
// Delay the call just enough to allow the state change to complete.
setTimeout(() => this.onAutoRefresh());
}
};
onAutoRefresh = () => {
this.updateCaches('auto-refresh');
if (this.state.isAutoRefreshEnabled) {
setTimeout(() => this.onAutoRefresh(), 1000);
}
};
getImage = (imageId: string) => {
debugLog('<- getImage requested for ' + imageId);
this.client.call('getImage', {imageId}).then((image: ImageData) => {
debugLog('-> getImage ' + imageId + ' returned');
this.imagePool ? this.imagePool._fetchCompleted(image) : undefined;
});
};
onImageSelected = (selectedImage: ImageId) => this.setState({selectedImage});
renderSidebar = () => {
const {selectedImage} = this.state;
if (selectedImage == null) {
return (
<EmptySidebar grow={true}>
<Text align="center">
Select an image to see the events associated with it.
</Text>
</EmptySidebar>
);
}
const maybeImage = this.props.persistedState.imagesMap[selectedImage];
const events = this.props.persistedState.events.filter((e) =>
e.imageIds.includes(selectedImage),
);
return <ImagesSidebar image={maybeImage} events={events} />;
};
onSurfaceChange = (surfaces: Set<string>) => {
this.updateImagesOnUI(
this.props.persistedState.images,
surfaces,
this.state.coldStartFilter,
);
};
onColdStartChange = (checked: boolean) => {
this.updateImagesOnUI(
this.props.persistedState.images,
this.state.selectedSurfaces,
checked,
);
};
onTrackLeaks = (checked: boolean) => {
this.props.logger.track('usage', 'fresco:onTrackLeaks', {enabled: checked});
this.props.setPersistedState({
isLeakTrackingEnabled: checked,
});
};
onShowDiskImages = (checked: boolean) => {
this.props.logger.track('usage', 'fresco:onShowDiskImages', {
enabled: checked,
});
this.props.setPersistedState({
showDiskImages: checked,
});
this.updateCaches('refresh');
};
render() {
const options = [...this.props.persistedState.surfaceList].reduce(
(acc, item) => {
return [...acc, item];
},
[surfaceDefaultText],
);
let {selectedSurfaces} = this.state;
if (selectedSurfaces.has(surfaceDefaultText)) {
selectedSurfaces = new Set(options);
}
return (
<React.Fragment>
<ImagesCacheOverview
allSurfacesOption={surfaceDefaultText}
surfaceOptions={new Set(options)}
selectedSurfaces={selectedSurfaces}
onChangeSurface={this.onSurfaceChange}
coldStartFilter={this.state.coldStartFilter}
onColdStartChange={this.onColdStartChange}
images={this.state.images}
onClear={this.onClear}
onTrimMemory={this.onTrimMemory}
onRefresh={() => this.updateCaches('refresh')}
onEnableDebugOverlay={this.onEnableDebugOverlay}
onEnableAutoRefresh={this.onEnableAutoRefresh}
isDebugOverlayEnabled={this.state.isDebugOverlayEnabled}
isAutoRefreshEnabled={this.state.isAutoRefreshEnabled}
onImageSelected={this.onImageSelected}
imagesMap={this.props.persistedState.imagesMap}
events={this.props.persistedState.events}
isLeakTrackingEnabled={
this.props.persistedState.isLeakTrackingEnabled
}
onTrackLeaks={this.onTrackLeaks}
showDiskImages={this.props.persistedState.showDiskImages}
onShowDiskImages={this.onShowDiskImages}
/>
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
</React.Fragment>
);
}
}