diff --git a/desktop/plugins/public/fresco/__tests__/__snapshots__/index.node.tsx.snap b/desktop/plugins/public/fresco/__tests__/__snapshots__/index.node.tsx.snap deleted file mode 100644 index ed6cc0b24..000000000 --- a/desktop/plugins/public/fresco/__tests__/__snapshots__/index.node.tsx.snap +++ /dev/null @@ -1,32 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`notifications for leaks 1`] = ` - - - CloseableReference leaked for - - - com.facebook.imagepipeline.memory.NativeMemoryChunk - - (identity hashcode: - deadbeef - ). - - - - Stacktrace: - - - - - <unavailable> - - - -`; diff --git a/desktop/plugins/public/fresco/__tests__/index.node.tsx b/desktop/plugins/public/fresco/__tests__/index.node.tsx deleted file mode 100644 index fd3d5ed31..000000000 --- a/desktop/plugins/public/fresco/__tests__/index.node.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/** - * 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 FrescoPlugin from '../index'; -import {PersistedState, ImageEventWithId} from '../index'; -import {AndroidCloseableReferenceLeakEvent} from '../api'; -import {Notification} from 'flipper'; -import {ImagesMap} from '../ImagePool'; - -type ScanDisplayTime = {[scan_number: number]: number}; - -function mockPersistedState( - imageSizes: Array<{ - width: number; - height: number; - }> = [], - viewport: { - width: number; - height: number; - } = {width: 150, height: 150}, -): PersistedState { - const scanDisplayTime: ScanDisplayTime = {}; - scanDisplayTime[1] = 3; - const events: Array = [ - { - imageIds: [...Array(imageSizes.length).keys()].map(String), - eventId: 0, - attribution: [], - startTime: 1, - endTime: 2, - source: 'source', - coldStart: true, - viewport: {...viewport, scanDisplayTime}, - }, - ]; - - const imagesMap = imageSizes.reduce((acc, val, index) => { - acc[index] = { - imageId: String(index), - width: val.width, - height: val.height, - sizeBytes: 10, - data: 'undefined', - }; - return acc; - }, {} as ImagesMap); - - return { - surfaceList: new Set(), - images: [], - events, - imagesMap, - closeableReferenceLeaks: [], - isLeakTrackingEnabled: false, - nextEventId: 0, - showDiskImages: false, - }; -} - -test('notifications for leaks', () => { - const notificationReducer: ( - persistedState: PersistedState, - ) => Array = FrescoPlugin.getActiveNotifications; - const closeableReferenceLeaks: Array = [ - { - identityHashCode: 'deadbeef', - className: 'com.facebook.imagepipeline.memory.NativeMemoryChunk', - stacktrace: null, - }, - { - identityHashCode: 'f4c3b00c', - className: 'com.facebook.flipper.SomeMemoryAbstraction', - stacktrace: null, - }, - ]; - const persistedStateWithoutTracking = { - ...mockPersistedState(), - closeableReferenceLeaks, - isLeakTrackingEnabled: false, - }; - const emptyNotifs = notificationReducer(persistedStateWithoutTracking); - expect(emptyNotifs).toHaveLength(0); - - const persistedStateWithTracking = { - ...mockPersistedState(), - closeableReferenceLeaks, - isLeakTrackingEnabled: true, - }; - const notifs = notificationReducer(persistedStateWithTracking); - expect(notifs).toHaveLength(2); - expect(notifs[0].message).toMatchSnapshot(); - expect(notifs[1].title).toContain('SomeMemoryAbstraction'); -}); diff --git a/desktop/plugins/public/fresco/index.tsx b/desktop/plugins/public/fresco/index.tsx index fc78b70a2..96ca15dab 100644 --- a/desktop/plugins/public/fresco/index.tsx +++ b/desktop/plugins/public/fresco/index.tsx @@ -19,43 +19,23 @@ import { } from './api'; import {Fragment} from 'react'; import {ImagesMap} from './ImagePool'; -import {ReduxState} from 'flipper'; +import {PluginClient, createState, usePlugin, useValue} from 'flipper-plugin'; 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; - images: ImagesList; +export type AllImageEventsInfo = { events: Array; - imagesMap: ImagesMap; - closeableReferenceLeaks: Array; - isLeakTrackingEnabled: boolean; - showDiskImages: boolean; - nextEventId: number; -}; - -type PluginState = { - selectedSurfaces: Set; - selectedImage: ImageId | null; - isDebugOverlayEnabled: boolean; - isAutoRefreshEnabled: boolean; - images: ImagesList; - coldStartFilter: boolean; }; const EmptySidebar = styled(FlexRow)({ @@ -79,141 +59,57 @@ const debugLog = (...args: any[]) => { } }; -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, - }; +type Methods = { + getAllImageEventsInfo(params: {}): Promise; + listImages(params: {showDiskImages: boolean}): Promise; + getImage(params: {imageId: string}): Promise; + clear(params: {type: string}): Promise; + trimMemory(params: {}): Promise; + enableDebugOverlay(params: {enabled: boolean}): Promise; +}; - static exportPersistedState = ( - callClient: undefined | ((method: string, params?: any) => Promise), - persistedState: PersistedState, - store?: ReduxState, - ): Promise => { - 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 = responseEvents.events; - let pluginData: PersistedState = { - ...persistedState, - images: persistedState ? [...persistedState.images, ...levels] : levels, - closeableReferenceLeaks: - (persistedState && persistedState.closeableReferenceLeaks) || [], - }; +type Events = { + closeable_reference_leak_event: AndroidCloseableReferenceLeakEvent; + events: ImageEvent; + debug_overlay_event: FrescoDebugOverlayEvent; +}; - 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 = levels.reduce((acc, level: CacheInfo) => { - level.imageIds.forEach((id) => { - acc.add(id); - }); - return acc; - }, new Set()); - const imageDataList: Array = []; - 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; - }); - }; +export function plugin(client: PluginClient) { + const selectedSurfaces = createState>( + new Set([surfaceDefaultText]), + ); + const currentSelectedImage = createState(null); + const isDebugOverlayEnabled = createState(false); + const isAutoRefreshEnabled = createState(false); + const currentImages = createState([]); + const coldStartFilter = createState(false); + const imagePool = createState(null); - 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, - }; - } + const surfaceList = createState>(new Set(), { + persist: 'surfaceList', + }); + const images = createState([], {persist: 'images'}); + const events = createState>([], {persist: 'events'}); + const imagesMap = createState({}, {persist: 'imagesMap'}); + const isLeakTrackingEnabled = createState(false, { + persist: 'isLeakTrackingEnabled', + }); + const showDiskImages = createState(false, { + persist: 'showDiskImages', + }); + const nextEventId = createState(0, {persist: 'nextEventId'}); - return persistedState; - }; + client.onConnect(() => { + init(); + }); - static getActiveNotifications = ({ - closeableReferenceLeaks = [], - isLeakTrackingEnabled = false, - }: PersistedState): Array => - closeableReferenceLeaks - .filter((_) => isLeakTrackingEnabled) - .map((event: AndroidCloseableReferenceLeakEvent) => ({ + client.onDestroy(() => { + imagePool?.get()?.clear(); + }); + + client.onMessage('closeable_reference_leak_event', (event) => { + if (isLeakTrackingEnabled) { + client.showNotification({ id: event.identityHashCode, title: `Leaked CloseableReference: ${event.className}`, message: ( @@ -233,25 +129,183 @@ export default class FlipperImagesPlugin extends FlipperPlugin< ), 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; + client.onExport(async () => { + const [responseImages, responseEvents] = await Promise.all([ + client.send('listImages', {showDiskImages: showDiskImages.get()}), + client.send('getAllImageEventsInfo', {}), + ]); + const levels: ImagesList = responseImages.levels; + const newEvents: Array = responseEvents.events; - filterImages = ( + images.set([...images.get(), ...levels]); + + newEvents.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) { + surfaceList.set(new Set([...surfaceList.get(), surface])); + } + } + events.set([{...event, eventId: index}, ...events.get()]); + }); + const idSet: Set = levels.reduce((acc, level: CacheInfo) => { + level.imageIds.forEach((id) => { + acc.add(id); + }); + return acc; + }, new Set()); + const imageDataList: Array = []; + for (const id of idSet) { + try { + const imageData: ImageData = await client.send('getImage', { + imageId: id, + }); + imageDataList.push(imageData); + } catch (e) { + console.error(e); + } + } + + const imagesMapCopy = {...imagesMap.get()}; + imageDataList.forEach((data: ImageData) => { + imagesMapCopy[data.imageId] = data; + }); + imagesMap.set(imagesMapCopy); + }); + + client.onMessage('debug_overlay_event', (event) => { + isDebugOverlayEnabled.set(event.enabled); + }); + + client.onMessage('events', (event) => { + debugLog('Received events', event); + const {attribution} = event; + if (attribution instanceof Array && attribution.length > 0) { + const surface = attribution[0] ? attribution[0].trim() : undefined; + if (surface && surface.length > 0) { + surfaceList.update((draft) => (draft = new Set([...draft, surface]))); + } + } + events.update((draft) => { + draft.unshift({ + eventId: nextEventId.get(), + ...event, + }); + }); + + nextEventId.set(nextEventId.get() + 1); + }); + + function onClear(type: string) { + client.send('clear', {type}); + setTimeout(() => updateCaches('onClear'), 1000); + } + + function onTrimMemory() { + client.send('trimMemory', {}); + setTimeout(() => updateCaches('onTrimMemory'), 1000); + } + + function onEnableDebugOverlay(enabled: boolean) { + client.send('enableDebugOverlay', {enabled}); + } + + function onEnableAutoRefresh(enabled: boolean) { + isAutoRefreshEnabled.set(enabled); + + if (enabled) { + // Delay the call just enough to allow the state change to complete. + setTimeout(() => onAutoRefresh()); + } + } + + function onAutoRefresh() { + updateCaches('auto-refresh'); + if (isAutoRefreshEnabled.get()) { + setTimeout(() => onAutoRefresh(), 1000); + } + } + + function getImage(imageId: string) { + if (!client.isConnected) { + debugLog(`Cannot fetch image ${imageId}: disconnected`); + return; + } + debugLog('<- getImage requested for ' + imageId); + client.send('getImage', {imageId}).then((image: ImageData) => { + debugLog('-> getImage ' + imageId + ' returned'); + imagePool.get()?._fetchCompleted(image); + }); + } + + function onImageSelected(selectedImage: ImageId) { + currentSelectedImage.set(selectedImage); + } + + function onSurfaceChange(surfaces: Set) { + updateImagesOnUI(images.get(), surfaces, coldStartFilter.get()); + } + + function onColdStartChange(checked: boolean) { + updateImagesOnUI(images.get(), selectedSurfaces.get(), checked); + } + + function onTrackLeaks(checked: boolean) { + client.logger.track('usage', 'fresco:onTrackLeaks', { + enabled: checked, + }); + + isLeakTrackingEnabled.set(checked); + } + + function onShowDiskImages(checked: boolean) { + client.logger.track('usage', 'fresco:onShowDiskImages', { + enabled: checked, + }); + + showDiskImages.set(checked); + updateCaches('refresh'); + } + + function init() { + debugLog('init()'); + if (client.isConnected) { + updateCaches('init'); + } else { + debugLog(`not connected)`); + } + imagePool.set( + new ImagePool(getImage, (images: ImagesMap) => imagesMap.set(images)), + ); + + const filteredImages = filterImages( + images.get(), + events.get(), + selectedSurfaces.get(), + coldStartFilter.get(), + ); + + images.set(filteredImages); + } + + function filterImages( images: ImagesList, events: Array, surfaces: Set, coldStart: boolean, - ): ImagesList => { + ): ImagesList { if (!surfaces || (surfaces.has(surfaceDefaultText) && !coldStart)) { return images; } @@ -280,216 +334,149 @@ export default class FlipperImagesPlugin extends FlipperPlugin< return {...image, imageIds: imageIdList}; }); return imageList; - }; - - init() { - debugLog('init()'); - if (this.client.isConnected) { - this.updateCaches('init'); - this.client.subscribe( - 'debug_overlay_event', - (event: FrescoDebugOverlayEvent) => { - this.setState({isDebugOverlayEnabled: event.enabled}); - }, - ); - } else { - debugLog(`not connected)`); - } - 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, + function updateImagesOnUI( + newImages: ImagesList, surfaces: Set, coldStart: boolean, - ) => { - const filteredImages = this.filterImages( - images, - this.props.persistedState.events, + ) { + const filteredImages = filterImages( + newImages, + events.get(), surfaces, coldStart, ); - this.setState({ - selectedSurfaces: surfaces, - images: filteredImages, - coldStartFilter: coldStart, - }); - }; - updateCaches = (reason: string) => { + selectedSurfaces.set(surfaces); + images.set(filteredImages); + coldStartFilter.set(coldStart); + } + + function updateCaches(reason: string) { debugLog('Requesting images list (reason=' + reason + ')'); - this.client - .call('listImages', { - showDiskImages: this.props.persistedState.showDiskImages, + client + .send('listImages', { + showDiskImages: showDiskImages.get(), }) .then((response: ImagesListResponse) => { response.levels.forEach((data) => - this.imagePool - ? this.imagePool.fetchImages(data.imageIds) - : undefined, + imagePool?.get()?.fetchImages(data.imageIds), ); - this.props.setPersistedState({images: response.levels}); - this.updateImagesOnUI( - this.props.persistedState.images, - this.state.selectedSurfaces, - this.state.coldStartFilter, + images.set(response.levels); + updateImagesOnUI( + images.get(), + selectedSurfaces.get(), + coldStartFilter.get(), ); }); + } + + return { + selectedSurfaces, + currentSelectedImage, + isDebugOverlayEnabled, + isAutoRefreshEnabled, + currentImages, + coldStartFilter, + surfaceList, + images, + events, + imagesMap, + isLeakTrackingEnabled, + showDiskImages, + nextEventId, + imagePool, + onSurfaceChange, + onColdStartChange, + onClear, + onTrimMemory, + updateCaches, + onEnableDebugOverlay, + onEnableAutoRefresh, + onImageSelected, + onTrackLeaks, + onShowDiskImages, }; +} - onClear = (type: string) => { - this.client.call('clear', {type}); - setTimeout(() => this.updateCaches('onClear'), 1000); - }; +export function Component() { + const instance = usePlugin(plugin); - onTrimMemory = () => { - this.client.call('trimMemory', {}); - setTimeout(() => this.updateCaches('onTrimMemory'), 1000); - }; + let selectedSurfaces = useValue(instance.selectedSurfaces); + const isDebugOverlayEnabled = useValue(instance.isDebugOverlayEnabled); + const isAutoRefreshEnabled = useValue(instance.isAutoRefreshEnabled); + const coldStartFilter = useValue(instance.coldStartFilter); - onEnableDebugOverlay = (enabled: boolean) => { - this.client.call('enableDebugOverlay', {enabled}); - }; + const surfaceList = useValue(instance.surfaceList); + const images = useValue(instance.images); + const events = useValue(instance.events); + const imagesMap = useValue(instance.imagesMap); + const isLeakTrackingEnabled = useValue(instance.isLeakTrackingEnabled); + const showDiskImages = useValue(instance.showDiskImages); - onEnableAutoRefresh = (enabled: boolean) => { - this.setState({isAutoRefreshEnabled: enabled}); - if (enabled) { - // Delay the call just enough to allow the state change to complete. - setTimeout(() => this.onAutoRefresh()); - } - }; + const options = [...surfaceList].reduce( + (acc, item) => { + return [...acc, item]; + }, + [surfaceDefaultText], + ); - onAutoRefresh = () => { - this.updateCaches('auto-refresh'); - if (this.state.isAutoRefreshEnabled) { - setTimeout(() => this.onAutoRefresh(), 1000); - } - }; + if (selectedSurfaces.has(surfaceDefaultText)) { + selectedSurfaces = new Set(options); + } - getImage = (imageId: string) => { - if (!this.client.isConnected) { - debugLog(`Cannot fetch image ${imageId}: disconnected`); - return; - } - debugLog('<- getImage requested for ' + imageId); - this.client.call('getImage', {imageId}).then((image: ImageData) => { - debugLog('-> getImage ' + imageId + ' returned'); - this.imagePool ? this.imagePool._fetchCompleted(image) : undefined; - }); - }; + return ( + + instance.updateCaches('refresh')} + onEnableDebugOverlay={instance.onEnableDebugOverlay} + onEnableAutoRefresh={instance.onEnableAutoRefresh} + isDebugOverlayEnabled={isDebugOverlayEnabled} + isAutoRefreshEnabled={isAutoRefreshEnabled} + onImageSelected={instance.onImageSelected} + imagesMap={imagesMap} + events={events} + isLeakTrackingEnabled={isLeakTrackingEnabled} + onTrackLeaks={instance.onTrackLeaks} + showDiskImages={showDiskImages} + onShowDiskImages={instance.onShowDiskImages} + /> + + + + + ); +} - onImageSelected = (selectedImage: ImageId) => this.setState({selectedImage}); - - renderSidebar = () => { - const {selectedImage} = this.state; - - if (selectedImage == null) { - return ( - - - Select an image to see the events associated with it. - - - ); - } - - const maybeImage = this.props.persistedState.imagesMap[selectedImage]; - const events = this.props.persistedState.events.filter((e) => - e.imageIds.includes(selectedImage), - ); - return ; - }; - - onSurfaceChange = (surfaces: Set) => { - 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); - } +function Sidebar() { + const instance = usePlugin(plugin); + const events = useValue(instance.events); + const imagesMap = useValue(instance.imagesMap); + const currentSelectedImage = useValue(instance.currentSelectedImage); + if (currentSelectedImage == null) { return ( - - 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} - /> - {this.renderSidebar()} - + + + Select an image to see the events associated with it. + + ); } + + const maybeImage = imagesMap[currentSelectedImage]; + const filteredEvents = events.filter((e) => + e.imageIds.includes(currentSelectedImage), + ); + return ; } diff --git a/desktop/plugins/public/fresco/package.json b/desktop/plugins/public/fresco/package.json index 62c11a757..280747ef4 100644 --- a/desktop/plugins/public/fresco/package.json +++ b/desktop/plugins/public/fresco/package.json @@ -13,5 +13,8 @@ "icon": "profile", "bugs": { "email": "oncall+fresco@xmail.facebook.com" + }, + "peerDependencies": { + "flipper-plugin": "*" } }