editor: register timeouts/intervals/rafs for disposal (#3852)

We have a lot of events that fire in the editor and, technically, they
can fire after the Editor is long gone.
This adds a registry/manager to track those timeout/interval/raf IDs
(and some eslint rules to enforce it).

Some other cleanups:
- `requestAnimationFrame.polyfill.ts` looks like it's unused now (it
used to be used in a prev. revision)
- @ds300 I could use your feedback on the `EffectScheduler` tweak. in
`useReactor` we do: `() => new EffectScheduler(name, reactFn, {
scheduleEffect: (cb) => requestAnimationFrame(cb) }),`
and that looks like it doesn't currently get disposed of properly.
thoughts? happy to do that separately from this PR if you think that's a
trickier thing.

### Change Type

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

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

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

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Test async operations and make sure they don't fire after disposal.

### Release Notes

- Editor: add registry of timeouts/intervals/rafs

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Mime Čuvalo 2024-06-04 09:50:40 +01:00 committed by GitHub
parent 23cf8729bc
commit aadc0aab4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 185 additions and 76 deletions

View file

@ -85,6 +85,44 @@ module.exports = {
'no-console': ['error', { allow: ['warn', 'error'] }],
},
},
{
files: ['packages/editor/**/*', 'packages/tldraw/**/*'],
rules: {
'no-restricted-globals': [
'error',
{
name: 'setInterval',
message: 'Use the timers from @tldraw/util instead.',
},
{
name: 'setTimeout',
message: 'Use the timers from @tldraw/util instead.',
},
{
name: 'requestAnimationFrame',
message: 'Use the timers from @tldraw/util instead.',
},
],
'no-restricted-properties': [
'error',
{
object: 'window',
property: 'setTimeout',
message: 'Use the timers from @tldraw/util instead.',
},
{
object: 'window',
property: 'setInterval',
message: 'Use the timers from @tldraw/util instead.',
},
{
object: 'window',
property: 'requestAnimationFrame',
message: 'Use the timers from @tldraw/util instead.',
},
],
},
},
{
files: ['e2e/**/*'],
rules: {
@ -97,6 +135,13 @@ module.exports = {
'import/no-extraneous-dependencies': 'off',
},
},
{
files: ['*.test.ts', '*.spec.ts'],
rules: {
'no-restricted-properties': 'off',
'no-restricted-globals': 'off',
},
},
{
files: ['apps/examples/**/*'],
rules: {

View file

@ -19,10 +19,9 @@ export function CustomRenderer() {
const ctx = canvas.getContext('2d')!
let isCancelled = false
let raf = -1
function render() {
if (isCancelled) return
if (!canvas) return
ctx.resetTransform()
@ -94,13 +93,13 @@ export function CustomRenderer() {
ctx.restore()
}
requestAnimationFrame(render)
raf = requestAnimationFrame(render)
}
render()
return () => {
isCancelled = true
cancelAnimationFrame(raf)
}
}, [editor])

View file

@ -80,15 +80,15 @@ export default function UserPresenceExample() {
])
})
rRaf.current = requestAnimationFrame(loop)
rRaf.current = editor.timers.requestAnimationFrame(loop)
}
rRaf.current = requestAnimationFrame(loop)
rRaf.current = editor.timers.requestAnimationFrame(loop)
} else {
editor.store.mergeRemoteChanges(() => {
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
})
rRaf.current = setInterval(() => {
rRaf.current = editor.timers.setInterval(() => {
editor.store.mergeRemoteChanges(() => {
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
})

View file

@ -40,6 +40,7 @@ import { StoreSchema } from '@tldraw/store';
import { StoreSideEffects } from '@tldraw/store';
import { StyleProp } from '@tldraw/tlschema';
import { StylePropValue } from '@tldraw/tlschema';
import { Timers } from '@tldraw/utils';
import { TLAsset } from '@tldraw/tlschema';
import { TLAssetId } from '@tldraw/tlschema';
import { TLAssetPartial } from '@tldraw/tlschema';
@ -1107,6 +1108,7 @@ export class Editor extends EventEmitter<TLEventMap> {
[key: string]: Map<StyleProp<any>, string>;
};
readonly textMeasure: TextManager;
readonly timers: Timers;
toggleLock(shapes: TLShape[] | TLShapeId[]): this;
undo(): this;
ungroupShapes(ids: TLShapeId[], options?: Partial<{

View file

@ -165,7 +165,7 @@ function useCollaboratorState(editor: Editor, latestPresence: TLInstancePresence
)
useEffect(() => {
const interval = setInterval(() => {
const interval = editor.timers.setInterval(() => {
setState(getStateFromElapsedTime(editor, Date.now() - rLastActivityTimestamp.current))
}, editor.options.collaboratorCheckIntervalMs)

View file

@ -82,12 +82,12 @@ export const DefaultErrorFallback: TLErrorFallbackComponent = ({ error, editor }
useEffect(() => {
if (didCopy) {
const timeout = setTimeout(() => {
const timeout = editor?.timers.setTimeout(() => {
setDidCopy(false)
}, 2000)
return () => clearTimeout(timeout)
}
}, [didCopy])
}, [didCopy, editor])
const copyError = () => {
const textarea = document.createElement('textarea')

View file

@ -57,6 +57,7 @@ import {
JsonObject,
PerformanceTracker,
Result,
Timers,
annotateError,
assert,
assertExists,
@ -240,6 +241,9 @@ export class Editor extends EventEmitter<TLEventMap> {
this.snaps = new SnapManager(this)
this.timers = new Timers()
this.disposables.add(this.timers.dispose.bind(this.timers))
this._cameraOptions.set({ ...DEFAULT_CAMERA_OPTIONS, ...cameraOptions })
this.user = new UserPreferencesManager(user ?? createTLUser(), inferDarkMode ?? false)
@ -687,7 +691,7 @@ export class Editor extends EventEmitter<TLEventMap> {
this.on('tick', this._flushEventsForTick)
requestAnimationFrame(() => {
this.timers.requestAnimationFrame(() => {
this._tickManager.start()
})
@ -727,6 +731,14 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
readonly snaps: SnapManager
/**
* A manager for the any asynchronous events and making sure they're
* cleaned up upon disposal.
*
* @public
*/
readonly timers: Timers
/**
* A manager for the user and their preferences.
*
@ -1246,7 +1258,7 @@ export class Editor extends EventEmitter<TLEventMap> {
clearTimeout(this._isChangingStyleTimeout)
if (partial.isChangingStyle === true) {
// If we've set to true, set a new reset timeout to change the value back to false after 2 seconds
this._isChangingStyleTimeout = setTimeout(() => {
this._isChangingStyleTimeout = this.timers.setTimeout(() => {
this._updateInstanceState({ isChangingStyle: false }, { history: 'ignore' })
}, 2000)
}
@ -2958,7 +2970,7 @@ export class Editor extends EventEmitter<TLEventMap> {
this.updateInstanceState({ highlightedUserIds: [...highlightedUserIds, userId] })
// Unhighlight the user's cursor after a few seconds
setTimeout(() => {
this.timers.setTimeout(() => {
const highlightedUserIds = [...this.getInstanceState().highlightedUserIds]
const index = highlightedUserIds.indexOf(userId)
if (index < 0) return
@ -8440,27 +8452,27 @@ export class Editor extends EventEmitter<TLEventMap> {
}
if (info.shiftKey) {
clearInterval(this._shiftKeyTimeout)
clearTimeout(this._shiftKeyTimeout)
this._shiftKeyTimeout = -1
inputs.shiftKey = true
} else if (!info.shiftKey && inputs.shiftKey && this._shiftKeyTimeout === -1) {
this._shiftKeyTimeout = setTimeout(this._setShiftKeyTimeout, 150)
this._shiftKeyTimeout = this.timers.setTimeout(this._setShiftKeyTimeout, 150)
}
if (info.altKey) {
clearInterval(this._altKeyTimeout)
clearTimeout(this._altKeyTimeout)
this._altKeyTimeout = -1
inputs.altKey = true
} else if (!info.altKey && inputs.altKey && this._altKeyTimeout === -1) {
this._altKeyTimeout = setTimeout(this._setAltKeyTimeout, 150)
this._altKeyTimeout = this.timers.setTimeout(this._setAltKeyTimeout, 150)
}
if (info.ctrlKey) {
clearInterval(this._ctrlKeyTimeout)
clearTimeout(this._ctrlKeyTimeout)
this._ctrlKeyTimeout = -1
inputs.ctrlKey = true
} else if (!info.ctrlKey && inputs.ctrlKey && this._ctrlKeyTimeout === -1) {
this._ctrlKeyTimeout = setTimeout(this._setCtrlKeyTimeout, 150)
this._ctrlKeyTimeout = this.timers.setTimeout(this._setCtrlKeyTimeout, 150)
}
const { originPagePoint, currentPagePoint } = inputs
@ -8645,7 +8657,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (!this.inputs.isPanning) {
// Start a long press timeout
this._longPressTimeout = setTimeout(() => {
this._longPressTimeout = this.timers.setTimeout(() => {
this.dispatch({
...info,
point: this.inputs.currentScreenPoint,
@ -8879,7 +8891,7 @@ export class Editor extends EventEmitter<TLEventMap> {
} else {
this.performanceTracker.start(name)
}
this.performanceTrackerTimeout = setTimeout(() => {
this.performanceTrackerTimeout = this.timers.setTimeout(() => {
this.performanceTracker.stop()
}, 50)
}

View file

@ -27,7 +27,7 @@ export class ClickManager {
private _getClickTimeout = (state: TLClickState, id = uniqueId()) => {
this._clickId = id
clearTimeout(this._clickTimeout)
this._clickTimeout = setTimeout(
this._clickTimeout = this.editor.timers.setTimeout(
() => {
if (this._clickState === state && this._clickId === id) {
switch (this._clickState) {

View file

@ -7,6 +7,7 @@ const throttleToNextFrame =
? // At test time we should use actual raf and not throttle, because throttle was set up to evaluate immediately during tests, which causes stack overflow
// for the tick manager since it sets up a raf loop.
function mockThrottle(cb: any) {
// eslint-disable-next-line no-restricted-globals
const frame = requestAnimationFrame(cb)
return () => cancelAnimationFrame(frame)
}

View file

@ -273,7 +273,7 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
pinchState = 'not sure'
requestAnimationFrame(() => {
editor.timers.requestAnimationFrame(() => {
editor.dispatch({
type: 'pinch',
name: 'pinch_end',

View file

@ -37,7 +37,7 @@ export function useScreenBounds(ref: React.RefObject<HTMLElement>) {
// Rather than running getClientRects on every frame, we'll
// run it once a second or when the window resizes.
const interval = setInterval(updateBounds, 1000)
const interval = editor.timers.setInterval(updateBounds, 1000)
window.addEventListener('resize', updateBounds)
const resizeObserver = new ResizeObserver((entries) => {

View file

@ -266,6 +266,7 @@ export class TLLocalSyncClient {
private isPersisting = false
private didLastWriteError = false
// eslint-disable-next-line no-restricted-globals
private scheduledPersistTimeout: ReturnType<typeof setTimeout> | null = null
/**
@ -277,6 +278,7 @@ export class TLLocalSyncClient {
private schedulePersist() {
this.debug('schedulePersist', this.scheduledPersistTimeout)
if (this.scheduledPersistTimeout) return
// eslint-disable-next-line no-restricted-globals
this.scheduledPersistTimeout = setTimeout(
() => {
this.scheduledPersistTimeout = null

View file

@ -35,7 +35,7 @@ interface EffectSchedulerOptions {
* @param execute - A function that will execute the effect.
* @returns
*/
scheduleEffect?: (execute: () => void) => void
scheduleEffect?: (execute: () => void) => number | void | (() => void)
}
class __EffectScheduler__<Result> {
@ -63,13 +63,15 @@ class __EffectScheduler__<Result> {
return this._scheduleCount
}
/** @internal */
private maybeRaf: number | void | (() => void) = -1
/** @internal */
readonly parentSet = new ArraySet<Signal<any, any>>()
/** @internal */
readonly parentEpochs: number[] = []
/** @internal */
readonly parents: Signal<any, any>[] = []
private readonly _scheduleEffect?: (execute: () => void) => void
private readonly _scheduleEffect?: (execute: () => void) => number | void | (() => void)
constructor(
public readonly name: string,
private readonly runEffect: (lastReactedEpoch: number) => Result,
@ -99,7 +101,7 @@ class __EffectScheduler__<Result> {
this._scheduleCount++
if (this._scheduleEffect) {
// if the effect should be deferred (e.g. until a react render), do so
this._scheduleEffect(this.maybeExecute)
this.maybeRaf = this._scheduleEffect(this.maybeExecute)
} else {
// otherwise execute right now!
this.execute()
@ -135,6 +137,7 @@ class __EffectScheduler__<Result> {
for (let i = 0, n = this.parents.length; i < n; i++) {
detach(this.parents[i], this)
}
typeof this.maybeRaf === 'number' && cancelAnimationFrame(this.maybeRaf)
}
/**

View file

@ -841,7 +841,7 @@ export function getPerfectDashProps(totalLength: number, strokeWidth: number, op
};
// @public (undocumented)
export function getSvgAsImage(svgString: string, isSafari: boolean, options: {
export function getSvgAsImage(editor: Editor, svgString: string, options: {
height: number;
quality: number;
scale: number;

View file

@ -137,7 +137,7 @@ export const TldrawImage = memo(function TldrawImage(props: TldrawImageProps) {
setUrl(url)
}
} else if (format === 'png') {
const blob = await getSvgAsImage(svgResult.svg, editor.environment.isSafari, {
const blob = await getSvgAsImage(editor, svgResult.svg, {
type: format,
quality: 1,
scale: 2,

View file

@ -170,7 +170,7 @@ export class Pointing extends StateNode {
private preciseTimeout = -1
private didTimeout = false
private startPreciseTimeout() {
this.preciseTimeout = window.setTimeout(() => {
this.preciseTimeout = this.editor.timers.setTimeout(() => {
if (!this.getIsActive()) return
this.didTimeout = true
}, 320)

View file

@ -280,11 +280,11 @@ function PatternFillDefForCanvas() {
const htmlLayer = findHtmlLayerParent(containerRef.current!)
if (htmlLayer) {
// Wait for `patternContext` to be picked up
requestAnimationFrame(() => {
editor.timers.requestAnimationFrame(() => {
htmlLayer.style.display = 'none'
// Wait for 'display = "none"' to take effect
requestAnimationFrame(() => {
editor.timers.requestAnimationFrame(() => {
htmlLayer.style.display = ''
})
})

View file

@ -12,7 +12,7 @@ export class DragAndDropManager {
prevDroppingShapeId: TLShapeId | null = null
droppingNodeTimer: ReturnType<typeof setTimeout> | null = null
droppingNodeTimer: number | null = null
first = true
@ -33,13 +33,13 @@ export class DragAndDropManager {
if (this.droppingNodeTimer === null) {
this.setDragTimer(movingShapes, INITIAL_POINTER_LAG_DURATION, cb)
} else if (this.editor.inputs.pointerVelocity.len() > 0.5) {
clearInterval(this.droppingNodeTimer)
clearTimeout(this.droppingNodeTimer)
this.setDragTimer(movingShapes, FAST_POINTER_LAG_DURATION, cb)
}
}
private setDragTimer(movingShapes: TLShape[], duration: number, cb: () => void) {
this.droppingNodeTimer = setTimeout(() => {
this.droppingNodeTimer = this.editor.timers.setTimeout(() => {
this.editor.batch(() => {
this.handleDrag(this.editor.inputs.currentPagePoint, movingShapes, cb)
})
@ -129,7 +129,7 @@ export class DragAndDropManager {
this.prevDroppingShapeId = null
if (this.droppingNodeTimer !== null) {
clearInterval(this.droppingNodeTimer)
clearTimeout(this.droppingNodeTimer)
}
this.droppingNodeTimer = null

View file

@ -145,7 +145,7 @@ export class DraggingHandle extends StateNode {
this.clearExactTimeout()
}
this.exactTimeout = setTimeout(() => {
this.exactTimeout = this.editor.timers.setTimeout(() => {
if (this.getIsActive() && !this.isPrecise) {
this.isPrecise = true
this.isPreciseId = this.pointingId

View file

@ -54,6 +54,7 @@ const CurrentState = track(function CurrentState() {
})
function FPS() {
const editor = useEditor()
const showFps = useValue('show_fps', () => debugFlags.showFps.get(), [debugFlags])
const fpsRef = useRef<HTMLDivElement>(null)
@ -63,7 +64,7 @@ function FPS() {
const TICK_LENGTH = 250
let maxKnownFps = 0
let cancelled = false
let raf = -1
let start = performance.now()
let currentTickLength = 0
@ -78,8 +79,6 @@ function FPS() {
// of frames that we've seen since the last time we rendered,
// and the actual time since the last render.
function loop() {
if (cancelled) return
// Count the frame
framesInCurrentTick++
@ -111,15 +110,15 @@ function FPS() {
start = performance.now()
}
requestAnimationFrame(loop)
raf = editor.timers.requestAnimationFrame(loop)
}
loop()
return () => {
cancelled = true
cancelAnimationFrame(raf)
}
}, [showFps])
}, [showFps, editor])
if (!showFps) return null

View file

@ -51,8 +51,8 @@ export const EditLinkDialogInner = track(function EditLinkDialogInner({
const rInput = useRef<HTMLInputElement>(null)
useEffect(() => {
requestAnimationFrame(() => rInput.current?.focus())
}, [])
editor.timers.requestAnimationFrame(() => rInput.current?.focus())
}, [editor])
const rInitialValue = useRef(selectedShape.props.url)

View file

@ -69,7 +69,10 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
// has not yet entered a valid URL.
setShowError(false)
clearTimeout(rShowErrorTimeout.current)
rShowErrorTimeout.current = setTimeout(() => setShowError(!embedInfo), 320)
rShowErrorTimeout.current = editor.timers.setTimeout(
() => setShowError(!embedInfo),
320
)
}}
/>
{url === '' ? (

View file

@ -33,7 +33,7 @@ export function DefaultMinimap() {
origin: 'minimap',
willCrashApp: false,
})
setTimeout(() => {
editor.timers.setTimeout(() => {
throw e
})
}
@ -188,11 +188,11 @@ export function DefaultMinimap() {
React.useEffect(() => {
// need to wait a tick for next theme css to be applied
// otherwise the minimap will render with the wrong colors
setTimeout(() => {
editor.timers.setTimeout(() => {
minimapRef.current?.updateColors()
minimapRef.current?.render()
})
}, [isDarkMode])
}, [isDarkMode, editor])
return (
<div className="tlui-minimap">

View file

@ -94,7 +94,7 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() {
// Scroll the current page into view when the menu opens / when current page changes
useEffect(() => {
if (!isOpen) return
requestAnimationFrame(() => {
editor.timers.requestAnimationFrame(() => {
const elm = document.querySelector(
`[data-testid="page-menu-item-${currentPageId}"]`
) as HTMLDivElement
@ -118,7 +118,7 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() {
}
}
})
}, [ITEM_HEIGHT, currentPageId, isOpen])
}, [ITEM_HEIGHT, currentPageId, isOpen, editor])
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {

View file

@ -1,4 +1,5 @@
import * as T from '@radix-ui/react-toast'
import { useEditor } from '@tldraw/editor'
import * as React from 'react'
import { AlertSeverity, TLUiToast, useToasts } from '../context/toasts'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
@ -94,26 +95,25 @@ function _Toasts() {
export const Toasts = React.memo(_Toasts)
export function ToastViewport() {
const editor = useEditor()
const { toasts } = useToasts()
const [hasToasts, setHasToasts] = React.useState(false)
React.useEffect(() => {
let cancelled = false
let timeoutId = -1
if (toasts.length) {
setHasToasts(true)
} else {
setTimeout(() => {
if (!cancelled) {
timeoutId = editor.timers.setTimeout(() => {
setHasToasts(false)
}
}, 1000)
}
return () => {
cancelled = true
clearTimeout(timeoutId)
}
}, [toasts.length, setHasToasts])
}, [toasts.length, setHasToasts, editor])
if (!hasToasts) return null

View file

@ -74,14 +74,14 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
setIsFocused(true)
const elm = e.currentTarget as HTMLInputElement
rCurrentValue.current = elm.value
requestAnimationFrame(() => {
editor.timers.requestAnimationFrame(() => {
if (autoSelect) {
elm.select()
}
})
onFocus?.()
},
[autoSelect, onFocus]
[autoSelect, onFocus, editor.timers]
)
const handleChange = React.useCallback(
@ -134,7 +134,7 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
visualViewport.addEventListener('resize', onViewportChange)
visualViewport.addEventListener('scroll', onViewportChange)
requestAnimationFrame(() => {
editor.timers.requestAnimationFrame(() => {
rInputRef.current?.scrollIntoView({ block: 'center' })
})

View file

@ -1216,7 +1216,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
onSelect(source) {
// this needs to be deferred because it causes the menu
// UI to unmount which puts us in a dodgy state
requestAnimationFrame(() => {
editor.timers.requestAnimationFrame(() => {
editor.batch(() => {
trackEvent('toggle-focus-mode', { source })
clearDialogs()

View file

@ -25,7 +25,7 @@ export function pasteTldrawContent(editor: Editor, clipboard: TLContent, point?:
) {
// Creates a 'puff' to show a paste has happened.
editor.updateInstanceState({ isChangingStyle: true })
setTimeout(() => {
editor.timers.setTimeout(() => {
editor.updateInstanceState({ isChangingStyle: false })
}, 150)
}

View file

@ -650,7 +650,7 @@ export function useNativeClipboardEvents() {
if (e.button === 1) {
// middle mouse button
disablingMiddleClickPaste = true
requestAnimationFrame(() => {
editor.timers.requestAnimationFrame(() => {
disablingMiddleClickPaste = false
})
}

View file

@ -11,8 +11,8 @@ import { TLExportType } from './exportAs'
/** @public */
export async function getSvgAsImage(
editor: Editor,
svgString: string,
isSafari: boolean,
options: {
type: 'png' | 'jpeg' | 'webp'
quality: number
@ -42,8 +42,8 @@ export async function getSvgAsImage(
// actually loaded. just waiting around a while is brittle, but
// there doesn't seem to be any better solution for now :( see
// https://bugs.webkit.org/show_bug.cgi?id=219770
if (isSafari) {
await new Promise((resolve) => setTimeout(resolve, 250))
if (editor.environment.isSafari) {
await new Promise((resolve) => editor.timers.setTimeout(resolve, 250))
}
const canvas = document.createElement('canvas') as HTMLCanvasElement
@ -157,7 +157,7 @@ export async function exportToBlob({
case 'webp': {
const svgResult = await getSvgString(editor, ids, opts)
if (!svgResult) throw new Error('Could not construct image.')
const image = await getSvgAsImage(svgResult.svg, editor.environment.isSafari, {
const image = await getSvgAsImage(editor, svgResult.svg, {
type: format,
quality: 1,
scale: 2,

View file

@ -20,7 +20,6 @@ import {
TLSocketServerSentEvent,
getTlsyncProtocolVersion,
} from './protocol'
import './requestAnimationFrame.polyfill'
type SubscribingFn<T> = (cb: (val: T) => void) => () => void

View file

@ -1,7 +0,0 @@
globalThis.requestAnimationFrame =
globalThis.requestAnimationFrame ||
function requestAnimationFrame(cb) {
return setTimeout(cb, 1000 / 60)
}
export {}

View file

@ -363,6 +363,18 @@ export function throttle<T extends (...args: any) => any>(func: T, limit: number
// @internal
export function throttleToNextFrame(fn: () => void): () => void;
// @public (undocumented)
export class Timers {
// (undocumented)
dispose(): void;
// (undocumented)
requestAnimationFrame(callback: FrameRequestCallback): number;
// (undocumented)
setInterval(handler: TimerHandler, timeout?: number, ...args: any[]): number;
// (undocumented)
setTimeout(handler: TimerHandler, timeout?: number, ...args: any[]): number;
}
// @internal (undocumented)
export function validateIndexKey(key: string): asserts key is IndexKey;

View file

@ -70,6 +70,7 @@ export {
setInSessionStorage,
} from './lib/storage'
export { fpsThrottle, throttleToNextFrame } from './lib/throttle'
export { Timers } from './lib/timers'
export type { Expand, RecursivePartial, Required } from './lib/types'
export {
STRUCTURED_CLONE_OBJECT_PROTOTYPE,

View file

@ -0,0 +1,38 @@
/** @public */
export class Timers {
private timeouts: number[] = []
private intervals: number[] = []
private rafs: number[] = []
/** @public */
setTimeout(handler: TimerHandler, timeout?: number, ...args: any[]): number {
const id = window.setTimeout(handler, timeout, args)
this.timeouts.push(id)
return id
}
/** @public */
setInterval(handler: TimerHandler, timeout?: number, ...args: any[]): number {
const id = window.setInterval(handler, timeout, args)
this.intervals.push(id)
return id
}
/** @public */
requestAnimationFrame(callback: FrameRequestCallback): number {
const id = window.requestAnimationFrame(callback)
this.rafs.push(id)
return id
}
/** @public */
dispose() {
this.timeouts.forEach((id) => clearTimeout(id))
this.intervals.forEach((id) => clearInterval(id))
this.rafs.forEach((id) => cancelAnimationFrame(id))
this.timeouts.length = 0
this.intervals.length = 0
this.rafs.length = 0
}
}