[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 { TLShape, TLShapeId } from '@tldraw/tlschema'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { ShapeUtil } from '../editor/shapes/ShapeUtil'
|
import { ShapeUtil } from '../editor/shapes/ShapeUtil'
|
||||||
|
@ -49,36 +49,16 @@ export const Shape = track(function Shape({
|
||||||
backgroundContainerRef.current?.style.setProperty(property, value)
|
backgroundContainerRef.current?.style.setProperty(property, value)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useQuickReactor(
|
useLayoutReaction('set shape stuff', () => {
|
||||||
'set shape container transform position',
|
|
||||||
() => {
|
|
||||||
const shape = editor.getShape(id)
|
const shape = editor.getShape(id)
|
||||||
if (!shape) return // probably the shape was just deleted
|
if (!shape) return // probably the shape was just deleted
|
||||||
|
|
||||||
const pageTransform = editor.getShapePageTransform(id)
|
const pageTransform = editor.getShapePageTransform(id)
|
||||||
const transform = Mat.toCssString(pageTransform)
|
const transform = Mat.toCssString(pageTransform)
|
||||||
setProperty('transform', transform)
|
setProperty('transform', transform)
|
||||||
},
|
|
||||||
[editor, setProperty]
|
|
||||||
)
|
|
||||||
|
|
||||||
useQuickReactor(
|
|
||||||
'set shape container clip path',
|
|
||||||
() => {
|
|
||||||
const shape = editor.getShape(id)
|
|
||||||
if (!shape) return null
|
|
||||||
|
|
||||||
const clipPath = editor.getShapeClipPath(id)
|
const clipPath = editor.getShapeClipPath(id)
|
||||||
setProperty('clip-path', clipPath ?? 'none')
|
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 bounds = editor.getShapeGeometry(shape).bounds
|
||||||
const dpr = Math.floor(editor.getInstanceState().devicePixelRatio * 100) / 100
|
const dpr = Math.floor(editor.getInstanceState().devicePixelRatio * 100) / 100
|
||||||
|
@ -93,9 +73,7 @@ export const Shape = track(function Shape({
|
||||||
const height = heightRemainder === 0 ? bounds.h : bounds.h + (dprMultiple - heightRemainder)
|
const height = heightRemainder === 0 ? bounds.h : bounds.h + (dprMultiple - heightRemainder)
|
||||||
setProperty('width', Math.max(width, dprMultiple) + 'px')
|
setProperty('width', Math.max(width, dprMultiple) + 'px')
|
||||||
setProperty('height', Math.max(height, dprMultiple) + 'px')
|
setProperty('height', Math.max(height, dprMultiple) + 'px')
|
||||||
},
|
})
|
||||||
[editor]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Set the opacity of the container when the opacity changes
|
// Set the opacity of the container when the opacity changes
|
||||||
React.useLayoutEffect(() => {
|
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 { TLHandle, TLShapeId } from '@tldraw/tlschema'
|
||||||
import { dedupe, modulate, objectMapValues } from '@tldraw/utils'
|
import { dedupe, modulate, objectMapValues } from '@tldraw/utils'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
@ -41,9 +41,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
|
||||||
useGestureEvents(rCanvas)
|
useGestureEvents(rCanvas)
|
||||||
useFixSafariDoubleTapZoomPencilEvents(rCanvas)
|
useFixSafariDoubleTapZoomPencilEvents(rCanvas)
|
||||||
|
|
||||||
useQuickReactor(
|
useLayoutReaction('position layers', () => {
|
||||||
'position layers',
|
|
||||||
() => {
|
|
||||||
const htmlElm = rHtmlLayer.current
|
const htmlElm = rHtmlLayer.current
|
||||||
if (!htmlElm) return
|
if (!htmlElm) return
|
||||||
const htmlElm2 = rHtmlLayer2.current
|
const htmlElm2 = rHtmlLayer2.current
|
||||||
|
@ -62,9 +60,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
|
||||||
)}px,${toDomPrecision(y + offset)}px)`
|
)}px,${toDomPrecision(y + offset)}px)`
|
||||||
htmlElm.style.setProperty('transform', transform)
|
htmlElm.style.setProperty('transform', transform)
|
||||||
htmlElm2.style.setProperty('transform', transform)
|
htmlElm2.style.setProperty('transform', transform)
|
||||||
},
|
})
|
||||||
[editor]
|
|
||||||
)
|
|
||||||
|
|
||||||
const events = useCanvasEvents()
|
const events = useCanvasEvents()
|
||||||
|
|
||||||
|
|
|
@ -128,6 +128,9 @@ export function useComputed<Value>(name: string, compute: () => Value, deps: any
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export function useComputed<Value, Diff = unknown>(name: string, compute: () => Value, opts: ComputedOptions<Value, Diff>, deps: any[]): Computed<Value>;
|
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)
|
// @public (undocumented)
|
||||||
export function useQuickReactor(name: string, reactFn: () => void, deps?: any[]): void;
|
export function useQuickReactor(name: string, reactFn: () => void, deps?: any[]): void;
|
||||||
|
|
||||||
|
|
|
@ -52,6 +52,9 @@
|
||||||
"node_modules/(?!(nanoid)/)"
|
"node_modules/(?!(nanoid)/)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tldraw/utils": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lodash": "^4.14.188",
|
"@types/lodash": "^4.14.188",
|
||||||
"@types/react": "^18.2.47",
|
"@types/react": "^18.2.47",
|
||||||
|
|
|
@ -51,6 +51,7 @@ class __EffectScheduler__<Result> {
|
||||||
lastTraversedEpoch = GLOBAL_START_EPOCH
|
lastTraversedEpoch = GLOBAL_START_EPOCH
|
||||||
|
|
||||||
private lastReactedEpoch = GLOBAL_START_EPOCH
|
private lastReactedEpoch = GLOBAL_START_EPOCH
|
||||||
|
private hasPendingEffect = true
|
||||||
private _scheduleCount = 0
|
private _scheduleCount = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -94,6 +95,7 @@ class __EffectScheduler__<Result> {
|
||||||
/** @internal */
|
/** @internal */
|
||||||
scheduleEffect() {
|
scheduleEffect() {
|
||||||
this._scheduleCount++
|
this._scheduleCount++
|
||||||
|
this.hasPendingEffect = true
|
||||||
if (this._scheduleEffect) {
|
if (this._scheduleEffect) {
|
||||||
// if the effect should be deferred (e.g. until a react render), do so
|
// if the effect should be deferred (e.g. until a react render), do so
|
||||||
this._scheduleEffect(this.maybeExecute)
|
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
|
// bail out if we have been detached before this runs
|
||||||
if (!this._isActivelyListening) return
|
if (!this._isActivelyListening || !this.hasPendingEffect) return
|
||||||
this.execute()
|
this.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -141,6 +144,7 @@ class __EffectScheduler__<Result> {
|
||||||
try {
|
try {
|
||||||
startCapturingParents(this)
|
startCapturingParents(this)
|
||||||
const result = this.runEffect(this.lastReactedEpoch)
|
const result = this.runEffect(this.lastReactedEpoch)
|
||||||
|
this.hasPendingEffect = false
|
||||||
this.lastReactedEpoch = getGlobalEpoch()
|
this.lastReactedEpoch = getGlobalEpoch()
|
||||||
return result
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
export { track } from './track'
|
export { track } from './track'
|
||||||
export { useAtom } from './useAtom'
|
export { useAtom } from './useAtom'
|
||||||
export { useComputed } from './useComputed'
|
export { useComputed } from './useComputed'
|
||||||
|
export { useLayoutReaction } from './useLayoutReaction'
|
||||||
export { useQuickReactor } from './useQuickReactor'
|
export { useQuickReactor } from './useQuickReactor'
|
||||||
export { useReactor } from './useReactor'
|
export { useReactor } from './useReactor'
|
||||||
export { useStateTracking } from './useStateTracking'
|
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 { useTrackedScheduler } from './useTrackedScheduler'
|
||||||
import { EffectScheduler } from '../core'
|
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export function useStateTracking<T>(name: string, render: () => T): T {
|
export function useStateTracking<T>(name: string, render: () => T): T {
|
||||||
// user render is only called at the bottom of this function, indirectly via scheduler.execute()
|
return useTrackedScheduler(name, render).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()
|
|
||||||
}
|
}
|
||||||
|
|
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 */
|
/* eslint-disable prefer-rest-params */
|
||||||
|
import { throttleToNextFrame } from '@tldraw/utils'
|
||||||
import { useMemo, useRef, useSyncExternalStore } from 'react'
|
import { useMemo, useRef, useSyncExternalStore } from 'react'
|
||||||
import { Signal, computed, react } from '../core'
|
import { Signal, computed, react } from '../core'
|
||||||
|
|
||||||
|
@ -81,10 +82,16 @@ export function useValue() {
|
||||||
const { subscribe, getSnapshot } = useMemo(() => {
|
const { subscribe, getSnapshot } = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
subscribe: (listen: () => void) => {
|
subscribe: (listen: () => void) => {
|
||||||
return react(`useValue(${name})`, () => {
|
return react(
|
||||||
|
`useValue(${name})`,
|
||||||
|
() => {
|
||||||
$val.get()
|
$val.get()
|
||||||
listen()
|
listen()
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
scheduleEffect: throttleToNextFrame,
|
||||||
|
}
|
||||||
|
)
|
||||||
},
|
},
|
||||||
getSnapshot: () => $val.get(),
|
getSnapshot: () => $val.get(),
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
"extends": "../../config/tsconfig.base.json",
|
"extends": "../../config/tsconfig.base.json",
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||||
|
"references": [{ "path": "../utils" }],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./.tsbuild",
|
"outDir": "./.tsbuild",
|
||||||
"rootDir": "src"
|
"rootDir": "src"
|
||||||
|
|
|
@ -7380,6 +7380,7 @@ __metadata:
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@tldraw/state@workspace:packages/state"
|
resolution: "@tldraw/state@workspace:packages/state"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@tldraw/utils": "workspace:*"
|
||||||
"@types/lodash": "npm:^4.14.188"
|
"@types/lodash": "npm:^4.14.188"
|
||||||
"@types/react": "npm:^18.2.47"
|
"@types/react": "npm:^18.2.47"
|
||||||
"@types/react-test-renderer": "npm:^18.0.0"
|
"@types/react-test-renderer": "npm:^18.0.0"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue