[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:
parent
79d6058d3c
commit
8e23a253fc
12 changed files with 140 additions and 127 deletions
|
@ -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(() => {
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -52,6 +52,9 @@
|
|||
"node_modules/(?!(nanoid)/)"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@tldraw/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.188",
|
||||
"@types/react": "^18.2.47",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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'
|
||||
|
|
10
packages/state/src/lib/react/useLayoutReaction.ts
Normal file
10
packages/state/src/lib/react/useLayoutReaction.ts
Normal 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)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
61
packages/state/src/lib/react/useTrackedScheduler.ts
Normal file
61
packages/state/src/lib/react/useTrackedScheduler.ts
Normal 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
|
||||
}
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"extends": "../../config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"references": [{ "path": "../utils" }],
|
||||
"compilerOptions": {
|
||||
"outDir": "./.tsbuild",
|
||||
"rootDir": "src"
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue