diff --git a/apps/examples/src/hooks/usePerformance.ts b/apps/examples/src/hooks/usePerformance.ts new file mode 100644 index 000000000..8b626ddf5 --- /dev/null +++ b/apps/examples/src/hooks/usePerformance.ts @@ -0,0 +1,23 @@ +import { TLUiEventSource, TLUiOverrides, debugFlags, measureCbDuration, useValue } from 'tldraw' + +export function usePerformance(): TLUiOverrides { + const measurePerformance = useValue( + 'measurePerformance', + () => debugFlags.measurePerformance.get(), + [debugFlags] + ) + if (!measurePerformance) return {} + return { + actions(_editor, actions) { + Object.keys(actions).forEach((key) => { + const action = actions[key] + const cb = action.onSelect + action.onSelect = (source: TLUiEventSource) => { + return measureCbDuration(`Action ${key}`, () => cb(source)) + } + }) + + return actions + }, + } +} diff --git a/apps/examples/src/misc/develop.tsx b/apps/examples/src/misc/develop.tsx index db14ab8cd..bd7206163 100644 --- a/apps/examples/src/misc/develop.tsx +++ b/apps/examples/src/misc/develop.tsx @@ -1,10 +1,13 @@ import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' +import { usePerformance } from '../hooks/usePerformance' export default function Develop() { + const performanceOverrides = usePerformance() return (
{ ;(window as any).app = editor diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 1367f1294..611ff56ef 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -23,6 +23,7 @@ import { JSX as JSX_2 } from 'react/jsx-runtime'; import { LegacyMigrations } from '@tldraw/store'; import { MigrationSequence } from '@tldraw/store'; import { NamedExoticComponent } from 'react'; +import { PerformanceTracker } from '@tldraw/utils'; import { PointerEventHandler } from 'react'; import { react } from '@tldraw/state'; import { default as React_2 } from 'react'; @@ -450,6 +451,7 @@ export const debugFlags: { readonly logElementRemoves: DebugFlag; readonly logPointerCaptures: DebugFlag; readonly logPreventDefaults: DebugFlag; + readonly measurePerformance: DebugFlag; readonly reconnectOnPing: DebugFlag; readonly showFps: DebugFlag; readonly throwToBlob: DebugFlag; @@ -1924,6 +1926,8 @@ export abstract class StateNode implements Partial { // (undocumented) _path: Computed; // (undocumented) + performanceTracker: PerformanceTracker; + // (undocumented) setCurrentToolIdMask(id: string | undefined): void; // (undocumented) shapeType?: string; diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index b85d05399..949ffecaf 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -41,6 +41,7 @@ import { import { IndexKey, JsonObject, + PerformanceTracker, annotateError, assert, compact, @@ -98,6 +99,7 @@ import { PI2, approximately, areAnglesCompatible, clamp, pointInPolygon } from ' import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap' import { WeakMapCache } from '../utils/WeakMapCache' import { dataUrlToFile } from '../utils/assets' +import { debugFlags } from '../utils/debug-flags' import { getIncrementedName } from '../utils/getIncrementedName' import { getReorderingShapesChanges } from '../utils/reorderShapes' import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation' @@ -676,6 +678,8 @@ export class Editor extends EventEmitter { requestAnimationFrame(() => { this._tickManager.start() }) + + this.performanceTracker = new PerformanceTracker() } /** @@ -8232,6 +8236,12 @@ export class Editor extends EventEmitter { /** @internal */ capturedPointerId: number | null = null + /** @internal */ + private readonly performanceTracker: PerformanceTracker + + /** @internal */ + private performanceTrackerTimeout = -1 as any + /** * Dispatch an event to the editor. * @@ -8318,7 +8328,7 @@ export class Editor extends EventEmitter { if (info.ctrlKey) { clearInterval(this._ctrlKeyTimeout) this._ctrlKeyTimeout = -1 - inputs.ctrlKey = true /** @internal */ /** @internal */ /** @internal */ + inputs.ctrlKey = true } else if (!info.ctrlKey && inputs.ctrlKey && this._ctrlKeyTimeout === -1) { this._ctrlKeyTimeout = setTimeout(this._setCtrlKeyTimeout, 150) } @@ -8472,6 +8482,7 @@ export class Editor extends EventEmitter { ), { immediate: true } ) + this.maybeTrackPerformance('Zooming') return } case 'pan': { @@ -8479,6 +8490,7 @@ export class Editor extends EventEmitter { this._setCamera(new Vec(cx + (dx * panSpeed) / cz, cy + (dy * panSpeed) / cz, cz), { immediate: true, }) + this.maybeTrackPerformance('Panning') return } } @@ -8552,7 +8564,6 @@ export class Editor extends EventEmitter { // If the user is in pen mode, but the pointer is not a pen, stop here. if (!isPen && isPenMode) return - // If we've started panning, then clear any long press timeout if (this.inputs.isPanning && this.inputs.isPointing) { // Handle spacebar / middle mouse button panning const { currentScreenPoint, previousScreenPoint } = this.inputs @@ -8563,6 +8574,7 @@ export class Editor extends EventEmitter { new Vec(cx + (offset.x * panSpeed) / cz, cy + (offset.y * panSpeed) / cz, cz), { immediate: true } ) + this.maybeTrackPerformance('Panning') return } @@ -8719,6 +8731,20 @@ export class Editor extends EventEmitter { return this } + + /** @internal */ + private maybeTrackPerformance(name: string) { + if (debugFlags.measurePerformance.get()) { + if (this.performanceTracker.isStarted()) { + clearTimeout(this.performanceTrackerTimeout) + } else { + this.performanceTracker.start(name) + } + this.performanceTrackerTimeout = setTimeout(() => { + this.performanceTracker.stop() + }, 50) + } + } } function alertMaxShapes(editor: Editor, pageId = editor.getCurrentPageId()) { diff --git a/packages/editor/src/lib/editor/tools/StateNode.ts b/packages/editor/src/lib/editor/tools/StateNode.ts index ed09dc469..3089be5e8 100644 --- a/packages/editor/src/lib/editor/tools/StateNode.ts +++ b/packages/editor/src/lib/editor/tools/StateNode.ts @@ -1,4 +1,6 @@ import { Atom, Computed, atom, computed } from '@tldraw/state' +import { PerformanceTracker } from '@tldraw/utils' +import { debugFlags } from '../../utils/debug-flags' import type { Editor } from '../Editor' import { EVENT_NAME_MAP, @@ -10,6 +12,19 @@ import { } from '../types/event-types' type TLStateNodeType = 'branch' | 'leaf' | 'root' +const STATE_NODES_TO_MEASURE = [ + 'brushing', + 'cropping', + 'dragging', + 'dragging_handle', + 'drawing', + 'erasing', + 'lasering', + 'resizing', + 'rotating', + 'scribble_brushing', + 'translating', +] /** @public */ export interface TLStateNodeConstructor { @@ -21,6 +36,7 @@ export interface TLStateNodeConstructor { /** @public */ export abstract class StateNode implements Partial { + performanceTracker: PerformanceTracker constructor( public editor: Editor, parent?: StateNode @@ -60,6 +76,7 @@ export abstract class StateNode implements Partial { this._current.set(this.children[this.initial]) } } + this.performanceTracker = new PerformanceTracker() } static id: string @@ -159,6 +176,10 @@ export abstract class StateNode implements Partial { // todo: move this logic into transition enter = (info: any, from: string) => { + if (debugFlags.measurePerformance.get() && STATE_NODES_TO_MEASURE.includes(this.id)) { + this.performanceTracker.start(this.id) + } + this._isActive.set(true) this.onEnter?.(info, from) @@ -171,6 +192,9 @@ export abstract class StateNode implements Partial { // todo: move this logic into transition exit = (info: any, from: string) => { + if (debugFlags.measurePerformance.get() && this.performanceTracker.isStarted()) { + this.performanceTracker.stop() + } this._isActive.set(false) this.onExit?.(info, from) diff --git a/packages/editor/src/lib/utils/debug-flags.ts b/packages/editor/src/lib/utils/debug-flags.ts index 19d801de7..88081f052 100644 --- a/packages/editor/src/lib/utils/debug-flags.ts +++ b/packages/editor/src/lib/utils/debug-flags.ts @@ -41,6 +41,7 @@ export const debugFlags = { showFps: createDebugValue('showFps', { defaults: { all: false }, }), + measurePerformance: createDebugValue('measurePerformance', { defaults: { all: false } }), throwToBlob: createDebugValue('throwToBlob', { defaults: { all: false }, }), diff --git a/packages/utils/api-report.md b/packages/utils/api-report.md index a88194d0e..f206fe4f5 100644 --- a/packages/utils/api-report.md +++ b/packages/utils/api-report.md @@ -242,6 +242,18 @@ export function omitFromStackTrace, Return>(fn: (... // @internal export function partition(arr: T[], predicate: (item: T) => boolean): [T[], T[]]; +// @public (undocumented) +export class PerformanceTracker { + // (undocumented) + isStarted(): boolean; + // (undocumented) + recordFrame: () => void; + // (undocumented) + start(name: string): void; + // (undocumented) + stop(): void; +} + // @public (undocumented) export class PngHelpers { // (undocumented) diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 90273933b..b3b2ce6d8 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,4 @@ +export { PerformanceTracker } from './lib/PerformanceTracker' export { areArraysShallowEqual, compact, diff --git a/packages/utils/src/lib/PerformanceTracker.ts b/packages/utils/src/lib/PerformanceTracker.ts new file mode 100644 index 000000000..1c15f9512 --- /dev/null +++ b/packages/utils/src/lib/PerformanceTracker.ts @@ -0,0 +1,52 @@ +import { PERFORMANCE_COLORS, PERFORMANCE_PREFIX_COLOR } from './perf' + +/** @public */ +export class PerformanceTracker { + private startTime = 0 + private name = '' + private frames = 0 + private started = false + private frame: number | null = null + + recordFrame = () => { + this.frames++ + if (!this.started) return + this.frame = requestAnimationFrame(this.recordFrame) + } + + start(name: string) { + this.name = name + this.frames = 0 + this.started = true + if (this.frame !== null) cancelAnimationFrame(this.frame) + this.frame = requestAnimationFrame(this.recordFrame) + this.startTime = performance.now() + } + + stop() { + this.started = false + if (this.frame !== null) cancelAnimationFrame(this.frame) + const duration = (performance.now() - this.startTime) / 1000 + const fps = duration === 0 ? 0 : Math.floor(this.frames / duration) + const background = + fps > 55 + ? PERFORMANCE_COLORS.Good + : fps > 30 + ? PERFORMANCE_COLORS.Mid + : PERFORMANCE_COLORS.Poor + const color = background === PERFORMANCE_COLORS.Mid ? 'black' : 'white' + const capitalized = this.name[0].toUpperCase() + this.name.slice(1) + // eslint-disable-next-line no-console + console.debug( + `%cPerf%c ${capitalized} %c${fps}%c fps`, + `color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`, + 'font-weight: normal', + `font-weight: bold; padding: 2px; background: ${background};color: ${color};`, + 'font-weight: normal' + ) + } + + isStarted() { + return this.started + } +} diff --git a/packages/utils/src/lib/perf.ts b/packages/utils/src/lib/perf.ts index 2f9283fd9..b97e50ef3 100644 --- a/packages/utils/src/lib/perf.ts +++ b/packages/utils/src/lib/perf.ts @@ -1,9 +1,21 @@ +export const PERFORMANCE_COLORS = { + Good: '#40C057', + Mid: '#FFC078', + Poor: '#E03131', +} + +export const PERFORMANCE_PREFIX_COLOR = PERFORMANCE_COLORS.Good + /** @internal */ export function measureCbDuration(name: string, cb: () => any) { - const now = performance.now() + const start = performance.now() const result = cb() // eslint-disable-next-line no-console - console.log(`${name} took`, performance.now() - now, 'ms') + console.debug( + `%cPerf%c ${name} took ${performance.now() - start}ms`, + `color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`, + 'font-weight: normal' + ) return result } @@ -13,9 +25,12 @@ export function measureDuration(_target: any, propertyKey: string, descriptor: P descriptor.value = function (...args: any[]) { const start = performance.now() const result = originalMethod.apply(this, args) - const end = performance.now() // eslint-disable-next-line no-console - console.log(`${propertyKey} took ${end - start}ms `) + console.debug( + `%cPerf%c ${propertyKey} took: ${performance.now() - start}ms`, + `color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`, + 'font-weight: normal' + ) return result } return descriptor @@ -41,8 +56,10 @@ export function measureAverageDuration( const count = value.count + 1 averages.set(descriptor.value, { total, count }) // eslint-disable-next-line no-console - console.log( - `${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms` + console.debug( + `%cPerf%c ${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms`, + `color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`, + 'font-weight: normal' ) } return result