[perf] Reinstate render throttling (#3160)

Follow up to #3129 

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK

- [x] `improvement` — Improving existing features



### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.
This commit is contained in:
David Sheldrick 2024-03-15 16:18:23 +00:00 committed by GitHub
parent 79d6058d3c
commit 8e23a253fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 140 additions and 127 deletions

View file

@ -1,4 +1,4 @@
import { track, useQuickReactor, useStateTracking } from '@tldraw/state'
import { track, useLayoutReaction, useStateTracking } from '@tldraw/state'
import { TLShape, TLShapeId } from '@tldraw/tlschema'
import * as React from 'react'
import { ShapeUtil } from '../editor/shapes/ShapeUtil'
@ -49,53 +49,31 @@ export const Shape = track(function Shape({
backgroundContainerRef.current?.style.setProperty(property, value)
}, [])
useQuickReactor(
'set shape container transform position',
() => {
const shape = editor.getShape(id)
if (!shape) return // probably the shape was just deleted
useLayoutReaction('set shape stuff', () => {
const shape = editor.getShape(id)
if (!shape) return // probably the shape was just deleted
const pageTransform = editor.getShapePageTransform(id)
const transform = Mat.toCssString(pageTransform)
setProperty('transform', transform)
},
[editor, setProperty]
)
const pageTransform = editor.getShapePageTransform(id)
const transform = Mat.toCssString(pageTransform)
setProperty('transform', transform)
useQuickReactor(
'set shape container clip path',
() => {
const shape = editor.getShape(id)
if (!shape) return null
const clipPath = editor.getShapeClipPath(id)
setProperty('clip-path', clipPath ?? 'none')
const clipPath = editor.getShapeClipPath(id)
setProperty('clip-path', clipPath ?? 'none')
},
[editor, setProperty]
)
useQuickReactor(
'set shape height and width',
() => {
const shape = editor.getShape(id)
if (!shape) return null
const bounds = editor.getShapeGeometry(shape).bounds
const dpr = Math.floor(editor.getInstanceState().devicePixelRatio * 100) / 100
// dprMultiple is the smallest number we can multiply dpr by to get an integer
// it's usually 1, 2, or 4 (for e.g. dpr of 2, 2.5 and 2.25 respectively)
const dprMultiple = nearestMultiple(dpr)
// We round the shape width and height up to the nearest multiple of dprMultiple to avoid the browser
// making miscalculations when applying the transform.
const widthRemainder = bounds.w % dprMultiple
const width = widthRemainder === 0 ? bounds.w : bounds.w + (dprMultiple - widthRemainder)
const heightRemainder = bounds.h % dprMultiple
const height = heightRemainder === 0 ? bounds.h : bounds.h + (dprMultiple - heightRemainder)
setProperty('width', Math.max(width, dprMultiple) + 'px')
setProperty('height', Math.max(height, dprMultiple) + 'px')
},
[editor]
)
const bounds = editor.getShapeGeometry(shape).bounds
const dpr = Math.floor(editor.getInstanceState().devicePixelRatio * 100) / 100
// dprMultiple is the smallest number we can multiply dpr by to get an integer
// it's usually 1, 2, or 4 (for e.g. dpr of 2, 2.5 and 2.25 respectively)
const dprMultiple = nearestMultiple(dpr)
// We round the shape width and height up to the nearest multiple of dprMultiple to avoid the browser
// making miscalculations when applying the transform.
const widthRemainder = bounds.w % dprMultiple
const width = widthRemainder === 0 ? bounds.w : bounds.w + (dprMultiple - widthRemainder)
const heightRemainder = bounds.h % dprMultiple
const height = heightRemainder === 0 ? bounds.h : bounds.h + (dprMultiple - heightRemainder)
setProperty('width', Math.max(width, dprMultiple) + 'px')
setProperty('height', Math.max(height, dprMultiple) + 'px')
})
// Set the opacity of the container when the opacity changes
React.useLayoutEffect(() => {

View file

@ -1,4 +1,4 @@
import { react, track, useQuickReactor, useValue } from '@tldraw/state'
import { react, track, useLayoutReaction, useValue } from '@tldraw/state'
import { TLHandle, TLShapeId } from '@tldraw/tlschema'
import { dedupe, modulate, objectMapValues } from '@tldraw/utils'
import classNames from 'classnames'
@ -41,30 +41,26 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
useGestureEvents(rCanvas)
useFixSafariDoubleTapZoomPencilEvents(rCanvas)
useQuickReactor(
'position layers',
() => {
const htmlElm = rHtmlLayer.current
if (!htmlElm) return
const htmlElm2 = rHtmlLayer2.current
if (!htmlElm2) return
useLayoutReaction('position layers', () => {
const htmlElm = rHtmlLayer.current
if (!htmlElm) return
const htmlElm2 = rHtmlLayer2.current
if (!htmlElm2) return
const { x, y, z } = editor.getCamera()
const { x, y, z } = editor.getCamera()
// Because the html container has a width/height of 1px, we
// need to create a small offset when zoomed to ensure that
// the html container and svg container are lined up exactly.
const offset =
z >= 1 ? modulate(z, [1, 8], [0.125, 0.5], true) : modulate(z, [0.1, 1], [-2, 0.125], true)
// Because the html container has a width/height of 1px, we
// need to create a small offset when zoomed to ensure that
// the html container and svg container are lined up exactly.
const offset =
z >= 1 ? modulate(z, [1, 8], [0.125, 0.5], true) : modulate(z, [0.1, 1], [-2, 0.125], true)
const transform = `scale(${toDomPrecision(z)}) translate(${toDomPrecision(
x + offset
)}px,${toDomPrecision(y + offset)}px)`
htmlElm.style.setProperty('transform', transform)
htmlElm2.style.setProperty('transform', transform)
},
[editor]
)
const transform = `scale(${toDomPrecision(z)}) translate(${toDomPrecision(
x + offset
)}px,${toDomPrecision(y + offset)}px)`
htmlElm.style.setProperty('transform', transform)
htmlElm2.style.setProperty('transform', transform)
})
const events = useCanvasEvents()

View file

@ -128,6 +128,9 @@ export function useComputed<Value>(name: string, compute: () => Value, deps: any
// @public (undocumented)
export function useComputed<Value, Diff = unknown>(name: string, compute: () => Value, opts: ComputedOptions<Value, Diff>, deps: any[]): Computed<Value>;
// @internal (undocumented)
export function useLayoutReaction(name: string, effect: () => void): void;
// @public (undocumented)
export function useQuickReactor(name: string, reactFn: () => void, deps?: any[]): void;

View file

@ -52,6 +52,9 @@
"node_modules/(?!(nanoid)/)"
]
},
"dependencies": {
"@tldraw/utils": "workspace:*"
},
"devDependencies": {
"@types/lodash": "^4.14.188",
"@types/react": "^18.2.47",

View file

@ -51,6 +51,7 @@ class __EffectScheduler__<Result> {
lastTraversedEpoch = GLOBAL_START_EPOCH
private lastReactedEpoch = GLOBAL_START_EPOCH
private hasPendingEffect = true
private _scheduleCount = 0
/**
@ -94,6 +95,7 @@ class __EffectScheduler__<Result> {
/** @internal */
scheduleEffect() {
this._scheduleCount++
this.hasPendingEffect = true
if (this._scheduleEffect) {
// if the effect should be deferred (e.g. until a react render), do so
this._scheduleEffect(this.maybeExecute)
@ -103,9 +105,10 @@ class __EffectScheduler__<Result> {
}
}
private maybeExecute = () => {
/** @internal */
readonly maybeExecute = () => {
// bail out if we have been detached before this runs
if (!this._isActivelyListening) return
if (!this._isActivelyListening || !this.hasPendingEffect) return
this.execute()
}
@ -141,6 +144,7 @@ class __EffectScheduler__<Result> {
try {
startCapturingParents(this)
const result = this.runEffect(this.lastReactedEpoch)
this.hasPendingEffect = false
this.lastReactedEpoch = getGlobalEpoch()
return result
} finally {

View file

@ -1,6 +1,7 @@
export { track } from './track'
export { useAtom } from './useAtom'
export { useComputed } from './useComputed'
export { useLayoutReaction } from './useLayoutReaction'
export { useQuickReactor } from './useQuickReactor'
export { useReactor } from './useReactor'
export { useStateTracking } from './useStateTracking'

View file

@ -0,0 +1,10 @@
import React from 'react'
import { useTrackedScheduler } from './useTrackedScheduler'
/** @internal */
export function useLayoutReaction(name: string, effect: () => void): void {
const scheduler = useTrackedScheduler(name, effect)
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(scheduler.maybeExecute)
}

View file

@ -1,58 +1,6 @@
import React from 'react'
import { EffectScheduler } from '../core'
import { useTrackedScheduler } from './useTrackedScheduler'
/** @internal */
export function useStateTracking<T>(name: string, render: () => T): T {
// user render is only called at the bottom of this function, indirectly via scheduler.execute()
// we need it to always be up-to-date when calling scheduler.execute() but it'd be wasteful to
// instantiate a new EffectScheduler on every render, so we use an immediately-updated ref
// to wrap it
const renderRef = React.useRef(render)
renderRef.current = render
const [scheduler, subscribe, getSnapshot] = React.useMemo(() => {
let scheduleUpdate = null as null | (() => void)
// useSyncExternalStore requires a subscribe function that returns an unsubscribe function
const subscribe = (cb: () => void) => {
scheduleUpdate = cb
return () => {
scheduleUpdate = null
}
}
const scheduler = new EffectScheduler(
`useStateTracking(${name})`,
// this is what `scheduler.execute()` will call
() => renderRef.current?.(),
// this is what will be invoked when @tldraw/state detects a change in an upstream reactive value
{
scheduleEffect() {
scheduleUpdate?.()
},
}
)
// we use an incrementing number based on when this
const getSnapshot = () => scheduler.scheduleCount
return [scheduler, subscribe, getSnapshot]
}, [name])
React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
// reactive dependencies are captured when `scheduler.execute()` is called
// and then to make it reactive we wait for a `useEffect` to 'attach'
// this allows us to avoid rendering outside of React's render phase
// and avoid 'zombie' components that try to render with bad/deleted data before
// react has a chance to umount them.
React.useEffect(() => {
scheduler.attach()
// do not execute, we only do that in render
scheduler.maybeScheduleEffect()
return () => {
scheduler.detach()
}
}, [scheduler])
return scheduler.execute()
return useTrackedScheduler(name, render).execute()
}

View file

@ -0,0 +1,61 @@
import { fpsThrottle } from '@tldraw/utils'
import React from 'react'
import { EffectScheduler } from '../core'
/** @internal */
export function useTrackedScheduler<T>(name: string, exec: () => T): EffectScheduler<T> {
// This hook creates an effect scheduler that will trigger re-renders when its reactive dependencies change, but it
// defers the actual execution of the effect to the consumer of this hook.
// We need the exec fn to always be up-to-date when calling scheduler.execute() but it'd be wasteful to
// instantiate a new EffectScheduler on every render, so we use an immediately-updated ref
// to wrap it
const execRef = React.useRef(exec)
execRef.current = exec
const [scheduler, subscribe, getSnapshot] = React.useMemo(() => {
let scheduleUpdate = null as null | (() => void)
// useSyncExternalStore requires a subscribe function that returns an unsubscribe function
const subscribe = (cb: () => void) => {
scheduleUpdate = cb
return () => {
scheduleUpdate = null
}
}
const scheduler = new EffectScheduler(
`useStateTracking(${name})`,
// this is what `scheduler.execute()` will call
() => execRef.current?.(),
// this is what will be invoked when @tldraw/state detects a change in an upstream reactive value
{
scheduleEffect: fpsThrottle(() => {
scheduleUpdate?.()
}),
}
)
// we use an incrementing number based on when this
const getSnapshot = () => scheduler.scheduleCount
return [scheduler, subscribe, getSnapshot]
}, [name])
React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
// reactive dependencies are captured when `scheduler.execute()` is called
// and then to make it reactive we wait for a `useEffect` to 'attach'
// this allows us to avoid rendering outside of React's render phase
// and avoid 'zombie' components that try to render with bad/deleted data before
// react has a chance to umount them.
React.useEffect(() => {
scheduler.attach()
// do not execute, we only do that in render
scheduler.maybeScheduleEffect()
return () => {
scheduler.detach()
}
}, [scheduler])
return scheduler
}

View file

@ -1,4 +1,5 @@
/* eslint-disable prefer-rest-params */
import { throttleToNextFrame } from '@tldraw/utils'
import { useMemo, useRef, useSyncExternalStore } from 'react'
import { Signal, computed, react } from '../core'
@ -81,10 +82,16 @@ export function useValue() {
const { subscribe, getSnapshot } = useMemo(() => {
return {
subscribe: (listen: () => void) => {
return react(`useValue(${name})`, () => {
$val.get()
listen()
})
return react(
`useValue(${name})`,
() => {
$val.get()
listen()
},
{
scheduleEffect: throttleToNextFrame,
}
)
},
getSnapshot: () => $val.get(),
}

View file

@ -2,6 +2,7 @@
"extends": "../../config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"references": [{ "path": "../utils" }],
"compilerOptions": {
"outDir": "./.tsbuild",
"rootDir": "src"

View file

@ -7380,6 +7380,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@tldraw/state@workspace:packages/state"
dependencies:
"@tldraw/utils": "workspace:*"
"@types/lodash": "npm:^4.14.188"
"@types/react": "npm:^18.2.47"
"@types/react-test-renderer": "npm:^18.0.0"