diff --git a/examples/react/src/examples/Stress/index.tsx b/examples/react/src/examples/Stress/index.tsx index 9091a687..14b56b09 100644 --- a/examples/react/src/examples/Stress/index.tsx +++ b/examples/react/src/examples/Stress/index.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { ReactFlow, Edge, @@ -16,6 +16,7 @@ import { } from '@xyflow/react'; import { getNodesAndEdges } from './utils'; +import { FrameRecorder, generateMouseEventParamsTargetingNode, nextFrame } from './performanceUtils'; const { nodes: initialNodes, edges: initialEdges } = getNodesAndEdges(25, 25); @@ -25,6 +26,166 @@ const StressFlow = () => { const onConnect = useCallback((connection: Connection) => { setEdges((eds) => addEdge(connection, eds)); }, []); + + const dragInViewport = async () => { + // Note: selecting specifically node 18, as it’s normally located in the right part of the viewport – + // which means dragging it left is safe to do without scrolling the viewport. + const nodeElement = document.querySelector('.react-flow__node[data-id="18"]'); + if (!nodeElement) throw new Error('Node with id 18 not found'); + + const frameRecorder = new FrameRecorder(); + + // Hold down the mouse + frameRecorder.setStage('mousedown'); + const mouseDownEvent = generateMouseEventParamsTargetingNode(nodeElement); + nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseDownEvent)); + await nextFrame(); + + // Start at the node position and move the mouse 5px to the left on every frame + frameRecorder.setStage('mousemove'); + let currentXPosition = mouseDownEvent.clientX; + for (let iteration = 0; iteration < 20; ++iteration) { + const movementX = -5; + currentXPosition += movementX; + + nodeElement.dispatchEvent( + new MouseEvent('mousemove', { + ...mouseDownEvent, + clientX: currentXPosition, + screenX: currentXPosition, + movementX, + }) + ); + await nextFrame(); + } + + // Release the mouse + frameRecorder.setStage('mouseup'); + nodeElement.dispatchEvent( + new MouseEvent('mouseup', { + ...mouseDownEvent, + clientX: currentXPosition, + screenX: currentXPosition, + }) + ); + await nextFrame(); + + // Log the results + await frameRecorder.endRecordingAsync(); + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + }; + + const dragOutsideViewport = async () => { + const randomNodeIndex = Math.floor(Math.random() * nodes.length); + const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodes[randomNodeIndex].id}"]`); + if (!nodeElement) throw new Error('Node not found'); + + const frameRecorder = new FrameRecorder(); + + // Hold down the mouse + frameRecorder.setStage('mousedown'); + const mouseDownEvent = generateMouseEventParamsTargetingNode(nodeElement); + nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseDownEvent)); + await nextFrame(); + + // Move the mouse to the top of the viewport (so that the viewport starts + // scrolling up). Then, wiggle the mouse up and down to keep the viewport + // scrolling. + frameRecorder.setStage('mousemove'); + let currentYPosition = 50; + for (let iteration = 0; iteration < 20; ++iteration) { + const movementY = Math.random() > 0.5 ? +2 : -2; + currentYPosition += movementY; + + nodeElement.dispatchEvent( + new MouseEvent('mousemove', { + ...mouseDownEvent, + clientY: currentYPosition, + screenY: currentYPosition, + movementY, + }) + ); + await nextFrame(); + } + + // Release the mouse + frameRecorder.setStage('mouseup'); + nodeElement.dispatchEvent( + new MouseEvent('mouseup', { + ...mouseDownEvent, + clientY: currentYPosition, + screenY: currentYPosition, + }) + ); + await nextFrame(); + + // Log the results + await frameRecorder.endRecordingAsync(); + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + }; + + const selectNode = async () => { + const randomNodeIndex = Math.floor(Math.random() * nodes.length); + const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodes[randomNodeIndex].id}"]`); + if (!nodeElement) throw new Error('Node not found'); + + const frameRecorder = new FrameRecorder(); + + const mouseEvent = generateMouseEventParamsTargetingNode(nodeElement); + + // mousedown + frameRecorder.setStage('mousedown'); + nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseEvent)); + await nextFrame(); + + // click + frameRecorder.setStage('click'); + nodeElement.dispatchEvent(new MouseEvent('click', mouseEvent)); + await nextFrame(); + + // mouseup + frameRecorder.setStage('mouseup'); + nodeElement.dispatchEvent(new MouseEvent('mouseup', mouseEvent)); + await nextFrame(); + + // Log the results + await frameRecorder.endRecordingAsync(); + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + }; + + const [key, setKey] = useState(0); + const frameRecorderRef = useRef(null); + function remount() { + frameRecorderRef.current = new FrameRecorder(); + setKey((k) => k + 1); + } + useEffect(() => { + const frameRecorder = frameRecorderRef.current; + if (!frameRecorder) return; + + frameRecorder.endRecordingAsync().then(() => { + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + + frameRecorderRef.current = null; + }); + }, [key]); + const updatePos = () => { setNodes((nds) => { return nds.map((n) => { @@ -56,6 +217,7 @@ const StressFlow = () => { return ( { + + + + diff --git a/examples/react/src/examples/Stress/performanceUtils.ts b/examples/react/src/examples/Stress/performanceUtils.ts new file mode 100644 index 00000000..f14f8711 --- /dev/null +++ b/examples/react/src/examples/Stress/performanceUtils.ts @@ -0,0 +1,150 @@ +type Frame = { + duration: number; + stage: string; +}; + +/** + * Measures and outputs the duration of every frame that happens between the + * instance is created and `endRecording()` is called. + * + * Usage: + * + * ```ts + * const recorder = new FrameRecorder(); + * + * // Do some performance-intensive stuff + * + * await recorder.endRecordingAsync(); + * + * console.log(recorder.getFrames()); + * console.log(recorder.getFramesForObservable()); // → paste into https://observablehq.com/@iamakulov/long-frame-visualizer + * ``` + */ +export class FrameRecorder { + private frames: Frame[] = []; + private animationFrameId: number; + private stage: string = ''; + + constructor() { + let lastFrameTimestamp = performance.now(); + + const measureFrame = () => { + const timestamp = performance.now(); + + // Visualize the frames in the Performance pane (see the collapsed + // “Timings” section) – so it’s easier to see what exactly each frame + // captured + performance.measure(`frame (${this.stage})`, { + start: lastFrameTimestamp, + end: timestamp, + }); + + this.frames.push({ + duration: timestamp - lastFrameTimestamp, + stage: this.stage, + }); + + lastFrameTimestamp = timestamp; + + this.animationFrameId = requestAnimationFrame(measureFrame); + }; + + this.animationFrameId = requestAnimationFrame(measureFrame); + } + + // The method is explicitly marked `async` in its name to make sure the caller + // doesn’t forget to `await` it. (Otherwise, some events might be lost.) + async endRecordingAsync() { + this.setStage('waiting for idle'); + await new Promise((resolve) => requestIdleCallback(resolve)); + requestAnimationFrame(() => { + cancelAnimationFrame(this.animationFrameId); + }); + } + + /** + * Adds an optional annotation to all subsequent frames. Useful to + * differentiate frames from different events – e.g. you can call + * `setState("mousedown")` before dispatching a mousedown event, and then + * `setState("mouseup")` before a mouseup one. + * + * When used, will affect both `getFramesForObservable()` and `getFrames()`. + */ + setStage(stage: string) { + this.stage = stage; + } + + getFramesForObservable() { + return this.frames.map((frame, index) => ({ ...frame, index })); + } + + getFrames() { + // Group frames by stage – so you could see which frames originated from `mousedown` vs `mousemove` vs `mouseup` events + const framesPerStage: Record = {}; + for (const frame of this.frames) { + const stage = frame.stage; + if (!framesPerStage[stage]) { + framesPerStage[stage] = []; + } + framesPerStage[stage].push(frame.duration); + } + + // If there’s only one stage, return the frames directly + return framesPerStage; + } +} + +/** + * Returns a promise that resolves when the next frame starts, and everything + * that was already scheduled in the event queue has been processed. + */ +export function nextFrame() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * Generates params for a new MouseEvent() that will target the given node. + */ +export function generateMouseEventParamsTargetingNode(node: Element) { + const nodePosition = node.getBoundingClientRect(); + + // Let’s make the event (eg click) happen 5px to the right and 5px to the + // bottom of the node’s top-left corner + const positionRelativeToNode = { + left: 5, + top: 5, + }; + + return { + clientX: Math.round(nodePosition.left + positionRelativeToNode.left), + clientY: Math.round(nodePosition.top + positionRelativeToNode.top), + movementX: 0, + movementY: 0, + offsetX: positionRelativeToNode.left, + offsetY: positionRelativeToNode.top, + screenX: Math.round(nodePosition.left + positionRelativeToNode.left), + screenY: Math.round(nodePosition.top + positionRelativeToNode.top), + + // Required boilerplate + altKey: false, + bubbles: true, + button: 0, + buttons: 1, + cancelBubble: false, + cancelable: true, + composed: true, + ctrlKey: false, + currentTarget: null, + defaultPrevented: false, + detail: 1, + eventPhase: 0, + fromElement: null, + isTrusted: true, + metaKey: false, + relatedTarget: null, + returnValue: true, + shiftKey: false, + view: window, + which: 1, + }; +}