desktop/plugins/public/fresco/index.tsx (418 lines of code) (raw):

/** * Copyright (c) Meta Platforms, Inc. and 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, ImagesMap, } from './api'; import { PluginClient, createState, usePlugin, useValue, DetailSidebar, Layout, Tabs, Tab, } from 'flipper-plugin'; import React from 'react'; import ImagesCacheOverview from './ImagesCacheOverview'; import ImagesMemoryOverview from './ImagesMemoryOverview'; import {isProduction} from 'flipper'; import {Typography} from 'antd'; import ImagesSidebar from './ImagesSidebar'; import ImagePool from './ImagePool'; export type ImageEventWithId = ImageEvent & {eventId: number}; export type AllImageEventsInfo = { events: Array<ImageEventWithId>; }; const surfaceDefaultText = 'SELECT ALL SURFACES'; const debugLog = (...args: any[]) => { if (!isProduction()) { // eslint-disable-next-line no-console console.log(...args); } }; type Methods = { getAllImageEventsInfo(params: {}): Promise<AllImageEventsInfo>; listImages(params: {showDiskImages: boolean}): Promise<ImagesListResponse>; getImage(params: {imageId: string}): Promise<ImageData>; clear(params: {type: string}): Promise<void>; trimMemory(params: {}): Promise<void>; enableDebugOverlay(params: {enabled: boolean}): Promise<void>; }; type Events = { closeable_reference_leak_event: AndroidCloseableReferenceLeakEvent; events: ImageEvent; debug_overlay_event: FrescoDebugOverlayEvent; }; export function plugin(client: PluginClient<Events, Methods>) { const selectedSurfaces = createState<Set<string>>( new Set([surfaceDefaultText]), ); const currentSelectedImage = createState<ImageId | null>(null); const isDebugOverlayEnabled = createState<boolean>(false); const isAutoRefreshEnabled = createState<boolean>(false); const currentImages = createState<ImagesList>([]); const coldStartFilter = createState<boolean>(false); const imagePool = createState<ImagePool | null>(null); const surfaceList = createState<Set<string>>(new Set(), { persist: 'surfaceList', }); const images = createState<ImagesList>([], {persist: 'images'}); const events = createState<Array<ImageEventWithId>>([], {persist: 'events'}); const imagesMap = createState<ImagesMap>({}, {persist: 'imagesMap'}); const isLeakTrackingEnabled = createState<boolean>(false, { persist: 'isLeakTrackingEnabled', }); const showDiskImages = createState<boolean>(false, { persist: 'showDiskImages', }); const nextEventId = createState<number>(0, {persist: 'nextEventId'}); client.onConnect(() => { init(); }); 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: ( <Layout.Container> <Typography.Text>CloseableReference leaked for </Typography.Text> <Typography.Text code>{event.className}</Typography.Text> <Typography.Text> (identity hashcode: {event.identityHashCode}). </Typography.Text> <Typography.Text strong>Stacktrace:</Typography.Text> <Typography.Text code> {event.stacktrace || '<unavailable>'} </Typography.Text> </Layout.Container> ), severity: 'error', category: 'closeablereference_leak', }); } }); 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<ImageEventWithId> = responseEvents.events; 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<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 client.send('getImage', { imageId: id, }); imageDataList.push(imageData); } catch (e) { console.error('[fresco] getImage failed:', 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); }) .catch((e) => console.error('[fresco] getImage failed:', e)); } function onImageSelected(selectedImage: ImageId) { currentSelectedImage.set(selectedImage); } function onSurfaceChange(surfaces: Set<string>) { 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<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; } function updateImagesOnUI( newImages: ImagesList, surfaces: Set<string>, coldStart: boolean, ) { const filteredImages = filterImages( newImages, events.get(), surfaces, coldStart, ); selectedSurfaces.set(surfaces); images.set(filteredImages); coldStartFilter.set(coldStart); } function updateCaches(reason: string) { debugLog('Requesting images list (reason=' + reason + ')'); client .send('listImages', { showDiskImages: showDiskImages.get(), }) .then((response: ImagesListResponse) => { response.levels.forEach((data) => imagePool?.get()?.fetchImages(data.imageIds), ); images.set(response.levels); updateImagesOnUI( images.get(), selectedSurfaces.get(), coldStartFilter.get(), ); }) .catch((e) => console.error('[fresco] listImages failed:', e)); } 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, }; } export function Component() { const instance = usePlugin(plugin); let selectedSurfaces = useValue(instance.selectedSurfaces); const isDebugOverlayEnabled = useValue(instance.isDebugOverlayEnabled); const isAutoRefreshEnabled = useValue(instance.isAutoRefreshEnabled); const coldStartFilter = useValue(instance.coldStartFilter); 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); const options = [...surfaceList].reduce( (acc, item) => { return [...acc, item]; }, [surfaceDefaultText], ); if (selectedSurfaces.has(surfaceDefaultText)) { selectedSurfaces = new Set(options); } return ( <Tabs defaultActiveKey="images" grow> <Tab tab="Images" key="images"> <ImagesCacheOverview allSurfacesOption={surfaceDefaultText} surfaceOptions={new Set(options)} selectedSurfaces={selectedSurfaces} onChangeSurface={instance.onSurfaceChange} coldStartFilter={coldStartFilter} onColdStartChange={instance.onColdStartChange} images={images} onClear={instance.onClear} onTrimMemory={instance.onTrimMemory} onRefresh={() => 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} /> <DetailSidebar> <Sidebar /> </DetailSidebar> </Tab> <Tab tab="Memory" key="memory"> <ImagesMemoryOverview images={images} imagesMap={imagesMap} /> </Tab> </Tabs> ); } 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 ( <Layout.Container pad> <Typography.Text> Select an image to see the events associated with it. </Typography.Text> </Layout.Container> ); } const maybeImage = imagesMap[currentSelectedImage]; const filteredEvents = events.filter((e) => e.imageIds.includes(currentSelectedImage), ); return <ImagesSidebar image={maybeImage} events={filteredEvents} />; }