Skip to content

Commit 5dfc426

Browse files
fix(memory): clean up module-level state leaks after editor unmount (tldraw#8604)
In order to prevent `Editor`, `Store`, and sync client instances from being retained after unmount, this PR cleans up several module-level caches, globals, and debounced functions that hold references. Particularly impactful for apps that mount/unmount editors frequently (iOS WebViews hit memory limits). Closes tldraw#8440. **`debounce.cancel()`** — sets `state = undefined` so `latestArgs` (and any captured Editor) are released on cancel. Previously only cleared the timeout. **`window.tlsync`** — cleared in `close()` for both `TLLocalSyncClient` and `TLSyncClient`. Guarded with `=== this` to avoid clearing a newer client's reference. **Bookmark debounce** — replaced module-level `debounce(editor, shape)` with per-editor instances via `WeakMap<Editor, ...>` (same pattern as the `updateHoveredShapeId` fix in tldraw#8439). Debouncer is GC'd with the editor. **`LruCache`** — new `@tldraw/utils` class (~20 LOC) using `Map` insertion-order for O(1) LRU eviction. `fetchCache` (cap 200) and `alphaCache` (cap 500) now use `LruCache` instead of unbounded `Map`s, preventing unbounded growth while keeping hot entries warm. ### Change type - [x] `bugfix` ### Test plan 1. Mount/unmount editor multiple times 2. Take heap snapshots, confirm Editor instances are GC'd 3. Verify bookmark URL unfurling still works after editor remount 4. Verify image alpha hit-testing works with many unique images - [x] Unit tests - [ ] End to end tests ### Release notes - Fixed memory leaks from module-level state retaining editor references after unmount - Added `LruCache` utility class for bounded caches with least-recently-used eviction ### API changes - Added `LruCache<K, V>` class to `@tldraw/utils` with `get`, `set`, `has`, and `size` ### Code changes | Section | LOC change | | --------------- | ---------- | | Core code | +60 / -8 | | Tests | +89 / -0 | | Automated files | +13 / -0 |
1 parent 5c7bbfd commit 5dfc426

10 files changed

Lines changed: 162 additions & 8 deletions

File tree

packages/editor/src/lib/exports/fetchCache.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { FileHelpers, assert, fetch } from '@tldraw/utils'
1+
import { FileHelpers, LruCache, assert, fetch } from '@tldraw/utils'
22

3-
// TODO(alex): currently, this cache will grow unbounded. we should come up with a better strategy
4-
// for clearing items from the cache over time.
53
export function fetchCache<T>(cb: (response: Response) => Promise<T>, init?: RequestInit) {
6-
const cache = new Map<string, Promise<T | null>>()
4+
const cache = new LruCache<string, Promise<T | null>>(100)
75

86
return async function fetchCached(url: string): Promise<T | null> {
97
const existing = cache.get(url)

packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ export class TLLocalSyncClient {
262262
this.debug('closing')
263263
this.didDispose = true
264264
this.disposables.forEach((d) => d())
265+
if (typeof window !== 'undefined' && (window as any).tlsync === this) {
266+
delete (window as any).tlsync
267+
}
265268
}
266269

267270
private isPersisting = false

packages/sync-core/src/lib/TLSyncClient.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,9 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
826826
this.disposables.forEach((dispose) => dispose())
827827
this.sendUnsentChanges.cancel?.()
828828
this.scheduleRebase.cancel?.()
829+
if (typeof window !== 'undefined' && (window as any).tlsync === this) {
830+
delete (window as any).tlsync
831+
}
829832
}
830833

831834
private lastPushedPresenceState: R | null = null

packages/tldraw/src/lib/shapes/bookmark/bookmarks.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export function updateBookmarkAssetOnUrlChange(editor: Editor, shape: TLBookmark
8484
}
8585
}
8686

87-
const createBookmarkAssetOnUrlChange = debounce(async (editor: Editor, shape: TLBookmarkShape) => {
87+
async function _createBookmarkAssetOnUrlChange(editor: Editor, shape: TLBookmarkShape) {
8888
if (editor.isDisposed) return
8989

9090
const { url } = shape.props
@@ -111,7 +111,21 @@ const createBookmarkAssetOnUrlChange = debounce(async (editor: Editor, shape: TL
111111
},
112112
])
113113
})
114-
}, 500)
114+
}
115+
116+
const bookmarkDebouncers = new WeakMap<
117+
Editor,
118+
ReturnType<typeof debounce<[Editor, TLBookmarkShape], void>>
119+
>()
120+
121+
function createBookmarkAssetOnUrlChange(editor: Editor, shape: TLBookmarkShape) {
122+
let fn = bookmarkDebouncers.get(editor)
123+
if (!fn) {
124+
fn = debounce(_createBookmarkAssetOnUrlChange, 500)
125+
bookmarkDebouncers.set(editor, fn)
126+
}
127+
fn(editor, shape)
128+
}
115129

116130
/**
117131
* Creates a bookmark shape from a URL with unfurled metadata.

packages/tldraw/src/lib/shapes/image/ImageAlphaCache.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Image, VecLike } from '@tldraw/editor'
1+
import { Image, LruCache, VecLike } from '@tldraw/editor'
22

33
/** Mime types of image formats that support transparency / alpha channel. */
44
export const TRANSPARENT_IMAGE_MIMETYPES: readonly string[] = [
@@ -65,7 +65,7 @@ export function isImagePointTransparent(
6565

6666
const MAX_SIZE = 256
6767

68-
const alphaCache = new Map<string, AlphaData>()
68+
const alphaCache = new LruCache<string, AlphaData>(100)
6969
const pending = new Set<string>()
7070
let offscreenCanvas: OffscreenCanvas | null = null
7171

packages/utils/api-report.api.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,19 @@ export function lerp(a: number, b: number, t: number): number;
264264
// @public
265265
export function lns(str: string): string;
266266

267+
// @public
268+
export class LruCache<K, V> {
269+
constructor(maxSize: number);
270+
// (undocumented)
271+
get(key: K): undefined | V;
272+
// (undocumented)
273+
has(key: K): boolean;
274+
// (undocumented)
275+
set(key: K, value: V): void;
276+
// (undocumented)
277+
get size(): number;
278+
}
279+
267280
// @public
268281
export type MakeUndefinedOptional<T extends object> = Expand<{
269282
[P in {

packages/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export { noop, omitFromStackTrace } from './lib/function'
3535
export { getHashForBuffer, getHashForObject, getHashForString, lns } from './lib/hash'
3636
export { mockUniqueId, restoreUniqueId, uniqueId } from './lib/id'
3737
export { getFirstFromIterable } from './lib/iterable'
38+
export { LruCache } from './lib/LruCache'
3839
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
3940
export {
4041
DEFAULT_SUPPORT_VIDEO_TYPES,
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { LruCache } from './LruCache'
2+
3+
describe('LruCache', () => {
4+
it('stores and retrieves values', () => {
5+
const cache = new LruCache<string, number>(3)
6+
cache.set('a', 1)
7+
cache.set('b', 2)
8+
expect(cache.get('a')).toBe(1)
9+
expect(cache.get('b')).toBe(2)
10+
expect(cache.get('c')).toBeUndefined()
11+
})
12+
13+
it('reports size', () => {
14+
const cache = new LruCache<string, number>(5)
15+
expect(cache.size).toBe(0)
16+
cache.set('a', 1)
17+
expect(cache.size).toBe(1)
18+
cache.set('b', 2)
19+
expect(cache.size).toBe(2)
20+
})
21+
22+
it('has() checks existence without promoting', () => {
23+
const cache = new LruCache<string, number>(2)
24+
cache.set('a', 1)
25+
cache.set('b', 2)
26+
expect(cache.has('a')).toBe(true)
27+
expect(cache.has('z')).toBe(false)
28+
29+
// 'a' was not promoted by has(), so adding 'c' should evict 'a'
30+
cache.set('c', 3)
31+
expect(cache.has('a')).toBe(false)
32+
})
33+
34+
it('evicts the oldest entry when exceeding capacity', () => {
35+
const cache = new LruCache<string, number>(2)
36+
cache.set('a', 1)
37+
cache.set('b', 2)
38+
cache.set('c', 3) // should evict 'a'
39+
40+
expect(cache.get('a')).toBeUndefined()
41+
expect(cache.get('b')).toBe(2)
42+
expect(cache.get('c')).toBe(3)
43+
expect(cache.size).toBe(2)
44+
})
45+
46+
it('get() promotes entry so it is not evicted next', () => {
47+
const cache = new LruCache<string, number>(2)
48+
cache.set('a', 1)
49+
cache.set('b', 2)
50+
51+
// Access 'a' to promote it; now 'b' is oldest
52+
cache.get('a')
53+
cache.set('c', 3) // should evict 'b', not 'a'
54+
55+
expect(cache.get('b')).toBeUndefined()
56+
expect(cache.get('a')).toBe(1)
57+
expect(cache.get('c')).toBe(3)
58+
})
59+
60+
it('set() on existing key updates value and promotes it', () => {
61+
const cache = new LruCache<string, number>(2)
62+
cache.set('a', 1)
63+
cache.set('b', 2)
64+
65+
// Update 'a' — promotes it, 'b' becomes oldest
66+
cache.set('a', 10)
67+
expect(cache.get('a')).toBe(10)
68+
69+
cache.set('c', 3) // should evict 'b'
70+
expect(cache.get('b')).toBeUndefined()
71+
expect(cache.get('a')).toBe(10)
72+
expect(cache.size).toBe(2)
73+
})
74+
75+
it('evicts entries in insertion order across many inserts', () => {
76+
const cache = new LruCache<number, number>(3)
77+
for (let i = 0; i < 10; i++) {
78+
cache.set(i, i * 10)
79+
}
80+
// Only the last 3 should remain
81+
expect(cache.size).toBe(3)
82+
expect(cache.get(7)).toBe(70)
83+
expect(cache.get(8)).toBe(80)
84+
expect(cache.get(9)).toBe(90)
85+
for (let i = 0; i < 7; i++) {
86+
expect(cache.has(i)).toBe(false)
87+
}
88+
})
89+
})

packages/utils/src/lib/LruCache.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/** Simple LRU cache backed by a Map's insertion-order iteration. @public */
2+
export class LruCache<K, V> {
3+
private map = new Map<K, V>()
4+
constructor(private maxSize: number) {}
5+
6+
get(key: K): V | undefined {
7+
if (!this.map.has(key)) return undefined
8+
const value = this.map.get(key)!
9+
// Move to most-recent position
10+
this.map.delete(key)
11+
this.map.set(key, value)
12+
return value
13+
}
14+
15+
set(key: K, value: V): void {
16+
if (this.map.has(key)) this.map.delete(key)
17+
this.map.set(key, value)
18+
if (this.map.size > this.maxSize) {
19+
// Evict oldest entry
20+
this.map.delete(this.map.keys().next().value!)
21+
}
22+
}
23+
24+
has(key: K): boolean {
25+
return this.map.has(key)
26+
}
27+
28+
// eslint-disable-next-line tldraw/no-setter-getter
29+
get size(): number {
30+
return this.map.size
31+
}
32+
}

packages/utils/src/lib/debounce.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export function debounce<T extends unknown[], U>(
7979
fn.cancel = () => {
8080
if (!state) return
8181
clearTimeout(state.timeout)
82+
state = undefined
8283
}
8384
return fn
8485
}

0 commit comments

Comments
 (0)