Merge pull request #3685 from iamakulov/perf/tests
[Perf] Add performance tests for selecting, dragging, and remounting
This commit is contained in:
@@ -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<FrameRecorder | null>(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 (
|
||||
<ReactFlow
|
||||
key={key}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onConnect={onConnect}
|
||||
@@ -68,6 +230,10 @@ const StressFlow = () => {
|
||||
<Background />
|
||||
|
||||
<Panel position="top-right">
|
||||
<button onClick={selectNode}>select node</button>
|
||||
<button onClick={dragInViewport}>drag node within the viewport</button>
|
||||
<button onClick={dragOutsideViewport}>drag node outside of the viewport</button>
|
||||
<button onClick={remount}>re-mount</button>
|
||||
<button onClick={updatePos}>change pos</button>
|
||||
<button onClick={updateElements}>update elements</button>
|
||||
</Panel>
|
||||
|
||||
150
examples/react/src/examples/Stress/performanceUtils.ts
Normal file
150
examples/react/src/examples/Stress/performanceUtils.ts
Normal file
@@ -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 = '<no stage>';
|
||||
|
||||
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<string, number[]> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user