From 40342c1bd10454ad9d2677fbfabb3e558c9838fd Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 9 Aug 2021 09:25:44 -0700 Subject: [PATCH] Lifted view state into context so it persists between tabs This almost works perfectly, except for a small problem restoring vertical scroll position. Will address in a follow up. --- .../src/CanvasPage.js | 122 +++++++++------ .../src/SchedulingProfiler.js | 14 +- .../src/SchedulingProfilerContext.js | 39 ++++- .../src/types.js | 18 ++- .../src/view-base/HorizontalPanAndZoomView.js | 148 +++++++----------- .../src/view-base/ResizableView.js | 56 +++++-- .../src/view-base/VerticalScrollView.js | 93 +++++++---- .../src/view-base/utils/scrollState.js | 4 +- 8 files changed, 298 insertions(+), 196 deletions(-) diff --git a/packages/react-devtools-scheduling-profiler/src/CanvasPage.js b/packages/react-devtools-scheduling-profiler/src/CanvasPage.js index 470e39efac8f..59939cd94869 100644 --- a/packages/react-devtools-scheduling-profiler/src/CanvasPage.js +++ b/packages/react-devtools-scheduling-profiler/src/CanvasPage.js @@ -7,14 +7,12 @@ * @flow */ -import type { - Point, - HorizontalPanAndZoomViewOnChangeCallback, -} from './view-base'; +import type {Point} from './view-base'; import type { ReactHoverContextInfo, ReactProfilerData, ReactMeasure, + ViewState, } from './types'; import * as React from 'react'; @@ -55,13 +53,14 @@ import { UserTimingMarksView, } from './content-views'; import {COLORS} from './content-views/constants'; - +import {clampState, moveStateToRange} from './view-base/utils/scrollState'; import EventTooltip from './EventTooltip'; import {RegistryContext} from 'react-devtools-shared/src/devtools/ContextMenu/Contexts'; import ContextMenu from 'react-devtools-shared/src/devtools/ContextMenu/ContextMenu'; import ContextMenuItem from 'react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem'; import useContextMenu from 'react-devtools-shared/src/devtools/ContextMenu/useContextMenu'; import {getBatchRange} from './utils/getBatchRange'; +import {MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL} from './view-base/constants'; import styles from './CanvasPage.css'; @@ -69,16 +68,22 @@ const CONTEXT_MENU_ID = 'canvas'; type Props = {| profilerData: ReactProfilerData, + viewState: ViewState, |}; -function CanvasPage({profilerData}: Props) { +function CanvasPage({profilerData, viewState}: Props) { return (
{({height, width}: {height: number, width: number}) => ( - + )}
@@ -103,23 +108,40 @@ const copySummary = (data: ReactProfilerData, measure: ReactMeasure) => { const zoomToBatch = ( data: ReactProfilerData, measure: ReactMeasure, - syncedHorizontalPanAndZoomViews: HorizontalPanAndZoomView[], + viewState: ViewState, + width: number, ) => { const {batchUID} = measure; - const [startTime, stopTime] = getBatchRange(batchUID, data); - syncedHorizontalPanAndZoomViews.forEach(syncedView => - // Using time as range works because the views' intrinsic content size is based on time. - syncedView.zoomToRange(startTime, stopTime), - ); + const [rangeStart, rangeEnd] = getBatchRange(batchUID, data); + + // Convert from time range to ScrollState + const scrollState = moveStateToRange({ + state: viewState.horizontalScrollState, + rangeStart, + rangeEnd, + contentLength: data.duration, + + minContentLength: data.duration * MIN_ZOOM_LEVEL, + maxContentLength: data.duration * MAX_ZOOM_LEVEL, + containerLength: width, + }); + + viewState.updateHorizontalScrollState(scrollState); }; type AutoSizedCanvasProps = {| data: ReactProfilerData, height: number, + viewState: ViewState, width: number, |}; -function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { +function AutoSizedCanvas({ + data, + height, + viewState, + width, +}: AutoSizedCanvasProps) { const canvasRef = useRef(null); const [isContextMenuShown, setIsContextMenuShown] = useState(false); @@ -137,9 +159,6 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { const componentMeasuresViewRef = useRef(null); const reactMeasuresViewRef = useRef(null); const flamechartViewRef = useRef(null); - const syncedHorizontalPanAndZoomViewsRef = useRef( - [], - ); const {hideMenu: hideContextMenu} = useContext(RegistryContext); @@ -147,25 +166,24 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { const surface = surfaceRef.current; const defaultFrame = {origin: zeroPoint, size: {width, height}}; - // Clear synced views - syncedHorizontalPanAndZoomViewsRef.current = []; - - const syncAllHorizontalPanAndZoomViewStates: HorizontalPanAndZoomViewOnChangeCallback = ( - newState, - triggeringView?: HorizontalPanAndZoomView, - ) => { - // Hide context menu when panning. + // Auto hide context menu when panning. + viewState.onHorizontalScrollStateChange(scrollState => { hideContextMenu(); + }); - syncedHorizontalPanAndZoomViewsRef.current.forEach( - syncedView => - triggeringView !== syncedView && syncedView.setScrollState(newState), - ); - }; + // Initialize horizontal view state + viewState.updateHorizontalScrollState( + clampState({ + state: viewState.horizontalScrollState, + minContentLength: data.duration * MIN_ZOOM_LEVEL, + maxContentLength: data.duration * MAX_ZOOM_LEVEL, + containerLength: defaultFrame.size.width, + }), + ); function createViewHelper( view: View, - resizeLabel: string = '', + label: string, shouldScrollVertically: boolean = false, shouldResizeVertically: boolean = false, ): View { @@ -175,6 +193,8 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { surface, defaultFrame, view, + viewState, + label, ); } @@ -183,23 +203,22 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { defaultFrame, verticalScrollView !== null ? verticalScrollView : view, data.duration, - syncAllHorizontalPanAndZoomViewStates, + viewState, ); - syncedHorizontalPanAndZoomViewsRef.current.push(horizontalPanAndZoomView); - - let viewToReturn = horizontalPanAndZoomView; + let resizableView = null; if (shouldResizeVertically) { - viewToReturn = new ResizableView( + resizableView = new ResizableView( surface, defaultFrame, horizontalPanAndZoomView, + viewState, canvasRef, - resizeLabel, + label, ); } - return viewToReturn; + return resizableView || horizontalPanAndZoomView; } const axisMarkersView = new TimeAxisMarkersView( @@ -207,7 +226,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { defaultFrame, data.duration, ); - const axisMarkersViewWrapper = createViewHelper(axisMarkersView); + const axisMarkersViewWrapper = createViewHelper(axisMarkersView, 'time'); let userTimingMarksViewWrapper = null; if (data.otherUserTimingMarks.length > 0) { @@ -218,7 +237,10 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { data.duration, ); userTimingMarksViewRef.current = userTimingMarksView; - userTimingMarksViewWrapper = createViewHelper(userTimingMarksView); + userTimingMarksViewWrapper = createViewHelper( + userTimingMarksView, + 'user timing api', + ); } const nativeEventsView = new NativeEventsView(surface, defaultFrame, data); @@ -236,7 +258,10 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { data, ); schedulingEventsViewRef.current = schedulingEventsView; - const schedulingEventsViewWrapper = createViewHelper(schedulingEventsView); + const schedulingEventsViewWrapper = createViewHelper( + schedulingEventsView, + 'react updates', + ); let suspenseEventsViewWrapper = null; if (data.suspenseEvents.length > 0) { @@ -262,7 +287,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { reactMeasuresViewRef.current = reactMeasuresView; const reactMeasuresViewWrapper = createViewHelper( reactMeasuresView, - 'react', + 'react scheduling', true, true, ); @@ -275,7 +300,10 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { data, ); componentMeasuresViewRef.current = componentMeasuresView; - componentMeasuresViewWrapper = createViewHelper(componentMeasuresView); + componentMeasuresViewWrapper = createViewHelper( + componentMeasuresView, + 'react components', + ); } const flamechartView = new FlamechartView( @@ -335,7 +363,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { return; } - // Wheel events should always hide the current toolltip. + // Wheel events should always hide the current tooltip. switch (interaction.type) { case 'wheel-control': case 'wheel-meta': @@ -623,11 +651,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) { {measure !== null && ( - zoomToBatch( - contextData.data, - measure, - syncedHorizontalPanAndZoomViewsRef.current, - ) + zoomToBatch(contextData.data, measure, viewState, width) } title="Zoom to batch"> Zoom to batch diff --git a/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.js b/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.js index 9b3220c5ae1b..98cd61d31244 100644 --- a/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.js +++ b/packages/react-devtools-scheduling-profiler/src/SchedulingProfiler.js @@ -8,6 +8,7 @@ */ import type {DataResource} from './createDataResourceFromImportedFile'; +import type {ViewState} from './types'; import * as React from 'react'; import { @@ -27,9 +28,11 @@ import CanvasPage from './CanvasPage'; import styles from './SchedulingProfiler.css'; export function SchedulingProfiler(_: {||}) { - const {importSchedulingProfilerData, schedulingProfilerData} = useContext( - SchedulingProfilerContext, - ); + const { + importSchedulingProfilerData, + schedulingProfilerData, + viewState, + } = useContext(SchedulingProfilerContext); const ref = useRef(null); @@ -66,6 +69,7 @@ export function SchedulingProfiler(_: {||}) { dataResource={schedulingProfilerData} key={key} onFileSelect={importSchedulingProfilerData} + viewState={viewState} /> ) : ( @@ -130,9 +134,11 @@ const CouldNotLoadProfile = ({error, onFileSelect}) => ( const DataResourceComponent = ({ dataResource, onFileSelect, + viewState, }: {| dataResource: DataResource, onFileSelect: (file: File) => void, + viewState: ViewState, |}) => { const dataOrError = dataResource.read(); if (dataOrError instanceof Error) { @@ -140,5 +146,5 @@ const DataResourceComponent = ({ ); } - return ; + return ; }; diff --git a/packages/react-devtools-scheduling-profiler/src/SchedulingProfilerContext.js b/packages/react-devtools-scheduling-profiler/src/SchedulingProfilerContext.js index dc95fc31a9da..ff81482c09e7 100644 --- a/packages/react-devtools-scheduling-profiler/src/SchedulingProfilerContext.js +++ b/packages/react-devtools-scheduling-profiler/src/SchedulingProfilerContext.js @@ -11,12 +11,14 @@ import * as React from 'react'; import {createContext, useCallback, useMemo, useState} from 'react'; import createDataResourceFromImportedFile from './createDataResourceFromImportedFile'; +import type {HorizontalScrollStateChangeCallback, ViewState} from './types'; import type {DataResource} from './createDataResourceFromImportedFile'; export type Context = {| clearSchedulingProfilerData: () => void, importSchedulingProfilerData: (file: File) => void, schedulingProfilerData: DataResource | null, + viewState: ViewState, |}; const SchedulingProfilerContext = createContext( @@ -42,20 +44,51 @@ function SchedulingProfilerContextController({children}: Props) { setSchedulingProfilerData(createDataResourceFromImportedFile(file)); }, []); - // TODO (scheduling profiler) Start/stop time ref here? + // Recreate view state any time new profiling data is imported. + const viewState = useMemo(() => { + const horizontalScrollStateChangeCallbacks: Set = new Set(); + + const horizontalScrollState = { + offset: 0, + length: 0, + }; + + return { + horizontalScrollState, + onHorizontalScrollStateChange: callback => { + horizontalScrollStateChangeCallbacks.add(callback); + }, + updateHorizontalScrollState: scrollState => { + if ( + horizontalScrollState.offset === scrollState.offset && + horizontalScrollState.length === scrollState.length + ) { + return; + } + + horizontalScrollState.offset = scrollState.offset; + horizontalScrollState.length = scrollState.length; + + horizontalScrollStateChangeCallbacks.forEach(callback => { + callback(scrollState); + }); + }, + viewToMutableViewStateMap: new Map(), + }; + }, [schedulingProfilerData]); const value = useMemo( () => ({ clearSchedulingProfilerData, importSchedulingProfilerData, schedulingProfilerData, - // TODO (scheduling profiler) + viewState, }), [ clearSchedulingProfilerData, importSchedulingProfilerData, schedulingProfilerData, - // TODO (scheduling profiler) + viewState, ], ); diff --git a/packages/react-devtools-scheduling-profiler/src/types.js b/packages/react-devtools-scheduling-profiler/src/types.js index 0891e6c607b1..761033f2cdda 100644 --- a/packages/react-devtools-scheduling-profiler/src/types.js +++ b/packages/react-devtools-scheduling-profiler/src/types.js @@ -7,7 +7,7 @@ * @flow */ -// Type utilities +import type {ScrollState} from './view-base/utils/scrollState'; // Source: https://github.com/facebook/flow/issues/4002#issuecomment-323612798 // eslint-disable-next-line no-unused-vars @@ -123,6 +123,22 @@ export type FlamechartStackLayer = FlamechartStackFrame[]; export type Flamechart = FlamechartStackLayer[]; +export type HorizontalScrollStateChangeCallback = ( + scrollState: ScrollState, +) => void; + +// Imperative view state that corresponds to profiler data. +// This state lives outside of React's lifecycle +// and should be erased/reset whenever new profiler data is loaded. +export type ViewState = {| + horizontalScrollState: ScrollState, + onHorizontalScrollStateChange: ( + callback: HorizontalScrollStateChangeCallback, + ) => void, + updateHorizontalScrollState: (scrollState: ScrollState) => void, + viewToMutableViewStateMap: Map, +|}; + export type ReactProfilerData = {| batchUIDToMeasuresMap: Map, componentMeasures: ReactComponentMeasure[], diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js b/packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js index 62fca51be1ef..5519599f12dc 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js @@ -18,6 +18,7 @@ import type { import type {Rect} from './geometry'; import type {ScrollState} from './utils/scrollState'; import type {ViewRefs} from './Surface'; +import type {ViewState} from '../types'; import {Surface} from './Surface'; import {View} from './View'; @@ -30,97 +31,84 @@ import { zoomState, } from './utils/scrollState'; import { - DEFAULT_ZOOM_LEVEL, MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, MOVE_WHEEL_DELTA_THRESHOLD, } from './constants'; -export type HorizontalPanAndZoomViewOnChangeCallback = ( - state: ScrollState, - view: HorizontalPanAndZoomView, -) => void; - export class HorizontalPanAndZoomView extends View { + _contentView: View; _intrinsicContentWidth: number; _isPanning = false; - _scrollState: ScrollState = {offset: 0, length: 0}; - _onStateChange: HorizontalPanAndZoomViewOnChangeCallback = () => {}; + _viewState: ViewState; constructor( surface: Surface, frame: Rect, contentView: View, intrinsicContentWidth: number, - onStateChange: HorizontalPanAndZoomViewOnChangeCallback, + viewState: ViewState, ) { super(surface, frame); - this.addSubview(contentView); - this._intrinsicContentWidth = intrinsicContentWidth; - this._setScrollState({ - offset: 0, - length: intrinsicContentWidth * DEFAULT_ZOOM_LEVEL, - }); - - this._onStateChange = onStateChange; - } - setFrame(newFrame: Rect) { - super.setFrame(newFrame); + this._contentView = contentView; + this._intrinsicContentWidth = intrinsicContentWidth; + this._viewState = viewState; - // Revalidate scrollState - this._setStateAndInformCallbacksIfChanged(this._scrollState); - } + viewState.onHorizontalScrollStateChange(scrollState => { + this.zoomToRange(scrollState.offset, scrollState.length); + }); - setScrollState(proposedState: ScrollState) { - this._setScrollState(proposedState); + this.addSubview(contentView); } /** - * Just sets scroll state. Use `_setStateAndInformCallbacksIfChanged` if this - * view's callbacks should also be called. + * Just sets scroll state. + * Use `_setStateAndInformCallbacksIfChanged` if this view's callbacks should also be called. * * @returns Whether state was changed * @private */ - _setScrollState(proposedState: ScrollState): boolean { + setScrollState(proposedState: ScrollState) { const clampedState = clampState({ state: proposedState, minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL, maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL, containerLength: this.frame.size.width, }); - if (areScrollStatesEqual(clampedState, this._scrollState)) { - return false; + if ( + !areScrollStatesEqual(clampedState, this._viewState.horizontalScrollState) + ) { + this.setNeedsDisplay(); } - this._scrollState = clampedState; - this.setNeedsDisplay(); - return true; } /** - * @private + * Zoom to a specific range of the content specified as a range of the + * content view's intrinsic content size. + * + * Does not inform callbacks of state change since this is a public API. */ - _setStateAndInformCallbacksIfChanged(proposedState: ScrollState) { - if (this._setScrollState(proposedState)) { - this._onStateChange(this._scrollState, this); - } + zoomToRange(rangeStart: number, rangeEnd: number) { + const newState = moveStateToRange({ + state: this._viewState.horizontalScrollState, + rangeStart, + rangeEnd, + contentLength: this._intrinsicContentWidth, + + minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL, + maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL, + containerLength: this.frame.size.width, + }); + this.setScrollState(newState); } desiredSize() { return this._contentView.desiredSize(); } - /** - * Reference to the content view. This view is also the only view in - * `this.subviews`. - */ - get _contentView() { - return this.subviews[0]; - } - layoutSubviews() { - const {offset, length} = this._scrollState; + const {offset, length} = this._viewState.horizontalScrollState; const proposedFrame = { origin: { x: this.frame.origin.x + offset, @@ -135,24 +123,22 @@ export class HorizontalPanAndZoomView extends View { super.layoutSubviews(); } - /** - * Zoom to a specific range of the content specified as a range of the - * content view's intrinsic content size. - * - * Does not inform callbacks of state change since this is a public API. - */ - zoomToRange(rangeStart: number, rangeEnd: number) { - const newState = moveStateToRange({ - state: this._scrollState, - rangeStart, - rangeEnd, - contentLength: this._intrinsicContentWidth, - - minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL, - maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL, - containerLength: this.frame.size.width, - }); - this._setScrollState(newState); + handleInteraction(interaction: Interaction, viewRefs: ViewRefs) { + switch (interaction.type) { + case 'mousedown': + this._handleMouseDown(interaction, viewRefs); + break; + case 'mousemove': + this._handleMouseMove(interaction, viewRefs); + break; + case 'mouseup': + this._handleMouseUp(interaction, viewRefs); + break; + case 'wheel-plain': + case 'wheel-shift': + this._handleWheel(interaction); + break; + } } _handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) { @@ -184,11 +170,11 @@ export class HorizontalPanAndZoomView extends View { return; } const newState = translateState({ - state: this._scrollState, + state: this._viewState.horizontalScrollState, delta: interaction.payload.event.movementX, containerLength: this.frame.size.width, }); - this._setStateAndInformCallbacksIfChanged(newState); + this._viewState.updateHorizontalScrollState(newState); } _handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) { @@ -227,44 +213,26 @@ export class HorizontalPanAndZoomView extends View { } const newState = zoomState({ - state: this._scrollState, + state: this._viewState.horizontalScrollState, multiplier: 1 + 0.005 * -deltaY, - fixedPoint: location.x - this._scrollState.offset, + fixedPoint: location.x - this._viewState.horizontalScrollState.offset, minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL, maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL, containerLength: this.frame.size.width, }); - this._setStateAndInformCallbacksIfChanged(newState); + this._viewState.updateHorizontalScrollState(newState); } else { if (absDeltaX < MOVE_WHEEL_DELTA_THRESHOLD) { return; } const newState = translateState({ - state: this._scrollState, + state: this._viewState.horizontalScrollState, delta: -deltaX, containerLength: this.frame.size.width, }); - this._setStateAndInformCallbacksIfChanged(newState); - } - } - - handleInteraction(interaction: Interaction, viewRefs: ViewRefs) { - switch (interaction.type) { - case 'mousedown': - this._handleMouseDown(interaction, viewRefs); - break; - case 'mousemove': - this._handleMouseMove(interaction, viewRefs); - break; - case 'mouseup': - this._handleMouseUp(interaction, viewRefs); - break; - case 'wheel-plain': - case 'wheel-shift': - this._handleWheel(interaction); - break; + this._viewState.updateHorizontalScrollState(newState); } } } diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/ResizableView.js b/packages/react-devtools-scheduling-profiler/src/view-base/ResizableView.js index 625e71e24089..1f0f890ce38e 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/ResizableView.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/ResizableView.js @@ -17,6 +17,7 @@ import type { } from './useCanvasInteraction'; import type {Rect} from './geometry'; import type {ViewRefs} from './Surface'; +import type {ViewState} from '../types'; import {BORDER_SIZE, COLORS} from '../content-views/constants'; import {drawText} from '../content-views/utils/text'; @@ -35,10 +36,10 @@ type ResizingState = $ReadOnly<{| mouseY: number, |}>; -type LayoutState = $ReadOnly<{| +type LayoutState = {| /** Resize bar's vertical position relative to resize view's frame.origin.y */ barOffsetY: number, -|}>; +|}; const RESIZE_BAR_DOT_RADIUS = 1; const RESIZE_BAR_DOT_SPACING = 4; @@ -217,36 +218,33 @@ class ResizeBar extends View { export class ResizableView extends View { _canvasRef: {current: HTMLCanvasElement | null}; _layoutState: LayoutState; + _mutableViewStateKey: string; _resizeBar: ResizeBar; _resizingState: ResizingState | null = null; _subview: View; + _viewState: ViewState; constructor( surface: Surface, frame: Rect, subview: View, + viewState: ViewState, canvasRef: {current: HTMLCanvasElement | null}, label: string, ) { super(surface, frame, noopLayout); this._canvasRef = canvasRef; - + this._layoutState = {barOffsetY: 0}; + this._mutableViewStateKey = label + ':ResizableView'; this._subview = subview; this._resizeBar = new ResizeBar(surface, frame, label); + this._viewState = viewState; this.addSubview(this._subview); this.addSubview(this._resizeBar); - const subviewDesiredSize = subview.desiredSize(); - this._updateLayoutStateAndResizeBar( - subviewDesiredSize.maxInitialHeight != null - ? Math.min( - subviewDesiredSize.maxInitialHeight, - subviewDesiredSize.height, - ) - : subviewDesiredSize.height, - ); + this._restoreMutableViewState(); } desiredSize() { @@ -274,6 +272,35 @@ export class ResizableView extends View { super.layoutSubviews(); } + _restoreMutableViewState() { + if ( + this._viewState.viewToMutableViewStateMap.has(this._mutableViewStateKey) + ) { + this._layoutState = ((this._viewState.viewToMutableViewStateMap.get( + this._mutableViewStateKey, + ): any): LayoutState); + + this._updateLayoutStateAndResizeBar(this._layoutState.barOffsetY); + } else { + this._viewState.viewToMutableViewStateMap.set( + this._mutableViewStateKey, + this._layoutState, + ); + + const subviewDesiredSize = this._subview.desiredSize(); + this._updateLayoutStateAndResizeBar( + subviewDesiredSize.maxInitialHeight != null + ? Math.min( + subviewDesiredSize.maxInitialHeight, + subviewDesiredSize.height, + ) + : subviewDesiredSize.height, + ); + } + + this.setNeedsDisplay(); + } + _shouldRenderResizeBar() { const subviewDesiredSize = this._subview.desiredSize(); return subviewDesiredSize.hideScrollBarIfLessThanHeight != null @@ -287,10 +314,7 @@ export class ResizableView extends View { barOffsetY = 0; } - this._layoutState = { - ...this._layoutState, - barOffsetY, - }; + this._layoutState.barOffsetY = barOffsetY; this._resizeBar.showLabel = barOffsetY === 0; } diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js b/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js index 245b53aff718..3f2f77591216 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js @@ -17,6 +17,7 @@ import type { import type {Rect} from './geometry'; import type {ScrollState} from './utils/scrollState'; import type {ViewRefs} from './Surface'; +import type {ViewState} from '../types'; import {Surface} from './Surface'; import {View} from './View'; @@ -34,13 +35,33 @@ const CARET_WIDTH = 5; const CARET_HEIGHT = 3; export class VerticalScrollView extends View { - _scrollState: ScrollState = {offset: 0, length: 0}; - _isPanning = false; - - constructor(surface: Surface, frame: Rect, contentView: View) { + _contentView: View; + _isPanning: boolean; + _mutableViewStateKey: string; + _scrollState: ScrollState; + _viewState: ViewState; + + constructor( + surface: Surface, + frame: Rect, + contentView: View, + viewState: ViewState, + label: string, + ) { super(surface, frame); + + this._contentView = contentView; + this._isPanning = false; + this._mutableViewStateKey = label + ':VerticalScrollView'; + this._scrollState = { + offset: 0, + length: 0, + }; + this._viewState = viewState; + this.addSubview(contentView); - this._setScrollState(this._scrollState); + + this._restoreMutableViewState(); } setFrame(newFrame: Rect) { @@ -102,14 +123,6 @@ export class VerticalScrollView extends View { } } - /** - * Reference to the content view. This view is also the only view in - * `this.subviews`. - */ - get _contentView() { - return this.subviews[0]; - } - layoutSubviews() { const {offset} = this._scrollState; const desiredSize = this._contentView.desiredSize(); @@ -133,6 +146,23 @@ export class VerticalScrollView extends View { super.layoutSubviews(); } + handleInteraction(interaction: Interaction) { + switch (interaction.type) { + case 'mousedown': + this._handleMouseDown(interaction); + break; + case 'mousemove': + this._handleMouseMove(interaction); + break; + case 'mouseup': + this._handleMouseUp(interaction); + break; + case 'wheel-shift': + this._handleWheelShift(interaction); + break; + } + } + _handleMouseDown(interaction: MouseDownInteraction) { if (rectContainsPoint(interaction.payload.location, this.frame)) { this._isPanning = true; @@ -184,21 +214,21 @@ export class VerticalScrollView extends View { this._setScrollState(newState); } - handleInteraction(interaction: Interaction) { - switch (interaction.type) { - case 'mousedown': - this._handleMouseDown(interaction); - break; - case 'mousemove': - this._handleMouseMove(interaction); - break; - case 'mouseup': - this._handleMouseUp(interaction); - break; - case 'wheel-shift': - this._handleWheelShift(interaction); - break; + _restoreMutableViewState() { + if ( + this._viewState.viewToMutableViewStateMap.has(this._mutableViewStateKey) + ) { + this._scrollState = ((this._viewState.viewToMutableViewStateMap.get( + this._mutableViewStateKey, + ): any): ScrollState); + } else { + this._viewState.viewToMutableViewStateMap.set( + this._mutableViewStateKey, + this._scrollState, + ); } + + this.setNeedsDisplay(); } /** @@ -212,10 +242,11 @@ export class VerticalScrollView extends View { maxContentLength: height, containerLength: this.frame.size.height, }); - if (areScrollStatesEqual(clampedState, this._scrollState)) { - return; + if (!areScrollStatesEqual(clampedState, this._scrollState)) { + this._scrollState.offset = clampedState.offset; + this._scrollState.length = clampedState.length; + + this.setNeedsDisplay(); } - this._scrollState = clampedState; - this.setNeedsDisplay(); } } diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/utils/scrollState.js b/packages/react-devtools-scheduling-profiler/src/view-base/utils/scrollState.js index e7f73a86d33f..9e9bdb4919f3 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/utils/scrollState.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/utils/scrollState.js @@ -18,10 +18,10 @@ import {clamp} from './clamp'; * |<-------------------length------------------->| * ``` */ -export type ScrollState = $ReadOnly<{| +export type ScrollState = {| offset: number, length: number, -|}>; +|}; function clampOffset(state: ScrollState, containerLength: number): ScrollState { return {