Skip to content

Commit 3df7177

Browse files
authored
fix(editor): dispose font and overlay managers (tldraw#8896)
In order to clean up editor manager state across editor lifecycles, this PR adds disposal hooks for the FontManager and overlay manager lifecycle. It clears FontManager font state and pending font requests, releases its store-backed cache references, and disposes registered overlay utils from editor disposal. Closes tldraw#8885. Closes tldraw#8891. ### Change type - [x] `api` ### Test plan 1. `cd packages/editor && yarn test run src/lib/editor/managers/FontManager/FontManager.test.ts` 2. `cd packages/tldraw && yarn test run src/test/overlays/OverlayManager.test.ts` 3. `yarn typecheck` 4. `yarn api-check` 5. `yarn lint-current` - [x] Unit tests ### Release notes - Add disposal lifecycle hooks for font and overlay managers. ### API changes - Added `FontManager.dispose()` for clearing font manager state and cache references. - Added `OverlayManager.dispose()` for disposing registered overlay utils. - Added `OverlayUtil.dispose()` as a default no-op for custom overlay cleanup. ### Code changes | Section | LOC change | | --------------- | ---------- | | Core code | +33 / -2 | | Tests | +71 / -2 | | Automated files | +5 / -0 |
1 parent 6b8de9e commit 3df7177

7 files changed

Lines changed: 109 additions & 4 deletions

File tree

packages/editor/api-report.api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,6 +1768,8 @@ export class FontManager {
17681768
[key: string]: string | undefined;
17691769
} | undefined);
17701770
// (undocumented)
1771+
dispose(): void;
1772+
// (undocumented)
17711773
ensureFontIsLoaded(font: TLFontFace): Promise<void>;
17721774
// (undocumented)
17731775
getShapeFontFaces(shape: TLShape | TLShapeId): TLFontFace[];
@@ -2612,6 +2614,8 @@ export type OptionalKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>
26122614
export class OverlayManager {
26132615
constructor(editor: Editor);
26142616
// (undocumented)
2617+
dispose(): void;
2618+
// (undocumented)
26152619
readonly editor: Editor;
26162620
getActiveOverlayEntries(): TLOverlayEntry[];
26172621
getCurrentOverlays(): TLOverlay[];
@@ -2647,6 +2651,7 @@ export abstract class OverlayUtil<T extends TLOverlay = TLOverlay> {
26472651
static configure<T extends TLOverlayUtilConstructor<any>>(this: T, options: T extends new (...args: any[]) => {
26482652
options: infer Options;
26492653
} ? Partial<Options> : never): T;
2654+
dispose(): void;
26502655
// (undocumented)
26512656
editor: Editor;
26522657
getCursor(_overlay: T): TLCursorType | undefined;

packages/editor/src/lib/editor/Editor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ export class Editor extends EventEmitter<TLEventMap> {
416416
})
417417

418418
this.fonts = new FontManager(this, fontAssetUrls)
419+
this.disposables.add(() => this.fonts.dispose())
419420

420421
this.inputs = new InputsManager(this)
421422
this.performance = new PerformanceManager(this)
@@ -502,6 +503,7 @@ export class Editor extends EventEmitter<TLEventMap> {
502503

503504
// Overlay utils
504505
this.overlays = new OverlayManager(this)
506+
this.disposables.add(() => this.overlays.dispose())
505507
if (overlayUtilConstructors) {
506508
for (const Util of overlayUtilConstructors) {
507509
const util = new Util(this)

packages/editor/src/lib/editor/managers/FontManager/FontManager.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ describe('FontManager', () => {
3636
let editor: Mocked<Editor>
3737
let fontManager: FontManager
3838
let mockAssetUrls: { [key: string]: string }
39+
let mockShapeFontFacesCacheGet: Mock
40+
let mockShapeFontLoadStateCacheGet: Mock
3941

4042
const createMockFont = (overrides: Partial<TLFontFace> = {}): TLFontFace => ({
4143
family: 'Test Font',
@@ -78,12 +80,15 @@ describe('FontManager', () => {
7880
getFontFaces: vi.fn(() => []),
7981
}
8082

83+
mockShapeFontFacesCacheGet = vi.fn(() => [])
84+
mockShapeFontLoadStateCacheGet = vi.fn(() => ({ get: vi.fn(() => []) }))
85+
8186
const mockStore = {
8287
createComputedCache: vi.fn(() => ({
83-
get: vi.fn(() => []),
88+
get: mockShapeFontFacesCacheGet,
8489
})),
8590
createCache: vi.fn(() => ({
86-
get: vi.fn(() => ({ get: vi.fn(() => []) })),
91+
get: mockShapeFontLoadStateCacheGet,
8792
})),
8893
}
8994

@@ -110,6 +115,32 @@ describe('FontManager', () => {
110115
})
111116
})
112117

118+
describe('dispose', () => {
119+
it('clears font state and caches', async () => {
120+
const font = createMockFont()
121+
const shapeId = createShapeId('test')
122+
mockShapeFontFacesCacheGet.mockReturnValue([font])
123+
const firstPromise = fontManager.ensureFontIsLoaded(font)
124+
125+
expect(fontManager.getShapeFontFaces(shapeId)).toEqual([font])
126+
fontManager.trackFontsForShape(shapeId)
127+
fontManager.requestFonts([font])
128+
await firstPromise
129+
fontManager.dispose()
130+
fontManager.requestFonts([font])
131+
const secondPromise = fontManager.ensureFontIsLoaded(font)
132+
133+
expect(fontManager.getShapeFontFaces(shapeId)).toEqual([])
134+
fontManager.trackFontsForShape(shapeId)
135+
expect(mockShapeFontFacesCacheGet).toHaveBeenCalledTimes(1)
136+
expect(mockShapeFontLoadStateCacheGet).toHaveBeenCalledTimes(1)
137+
expect(queueMicrotask).toHaveBeenCalledTimes(2)
138+
expect(secondPromise).not.toBe(firstPromise)
139+
140+
await secondPromise
141+
})
142+
})
143+
113144
describe('getShapeFontFaces', () => {
114145
it('should return empty array when no fonts found', () => {
115146
const shape = createMockShape()

packages/editor/src/lib/editor/managers/FontManager/FontManager.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ interface FontState {
1616
readonly loadingPromise: Promise<void>
1717
}
1818

19+
interface ShapeFontFacesCache {
20+
get(id: TLShapeId): TLFontFace[] | undefined
21+
}
22+
23+
interface ShapeFontLoadStateCache {
24+
get(id: TLShapeId): (FontState | null)[] | undefined
25+
}
26+
27+
const EMPTY_SHAPE_FONT_FACES_CACHE: ShapeFontFacesCache = { get: () => undefined }
28+
const EMPTY_SHAPE_FONT_LOAD_STATE_CACHE: ShapeFontLoadStateCache = { get: () => undefined }
29+
1930
/** @public */
2031
export class FontManager {
2132
constructor(
@@ -49,8 +60,15 @@ export class FontManager {
4960
)
5061
}
5162

52-
private readonly shapeFontFacesCache
53-
private readonly shapeFontLoadStateCache
63+
dispose() {
64+
this.fontStates.clear()
65+
this.fontsToLoad.clear()
66+
this.shapeFontFacesCache = EMPTY_SHAPE_FONT_FACES_CACHE
67+
this.shapeFontLoadStateCache = EMPTY_SHAPE_FONT_LOAD_STATE_CACHE
68+
}
69+
70+
private shapeFontFacesCache: ShapeFontFacesCache
71+
private shapeFontLoadStateCache: ShapeFontLoadStateCache
5472

5573
getShapeFontFaces(shape: TLShape | TLShapeId): TLFontFace[] {
5674
const shapeId = typeof shape === 'string' ? shape : shape.id

packages/editor/src/lib/editor/overlays/OverlayManager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export class OverlayManager {
3838
this._overlayUtils.set(type, util)
3939
}
4040

41+
dispose() {
42+
for (const util of this._overlayUtils.values()) {
43+
util.dispose()
44+
}
45+
}
46+
4147
/**
4248
* Get an overlay util by type string, overlay instance, or by passing
4349
* a util class as a generic parameter for type-safe lookup.

packages/editor/src/lib/editor/overlays/OverlayUtil.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,9 @@ export abstract class OverlayUtil<T extends TLOverlay = TLOverlay> {
140140
* at minimap scale (e.g. brushes, collaborator cursors) should opt in.
141141
*/
142142
renderMinimap(_ctx: CanvasRenderingContext2D, _overlays: T[], _zoom: number): void {}
143+
144+
/**
145+
* Clean up any resources held by this overlay util.
146+
*/
147+
dispose(): void {}
143148
}

packages/tldraw/src/test/overlays/OverlayManager.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,44 @@ beforeEach(() => {
1414
})
1515

1616
describe('OverlayManager', () => {
17+
describe('dispose', () => {
18+
it('calls dispose on registered overlay utils when the editor disposes', () => {
19+
const dispose = vi.fn()
20+
21+
class TestOverlay extends OverlayUtil<TLOverlay<Record<string, never>>> {
22+
static override type = 'dispose_tester'
23+
override dispose = dispose
24+
override isActive() {
25+
return false
26+
}
27+
override getOverlays() {
28+
return []
29+
}
30+
}
31+
32+
const editor = new TestEditor({ overlayUtils: [TestOverlay] })
33+
editor.dispose()
34+
35+
expect(dispose).toHaveBeenCalledTimes(1)
36+
})
37+
38+
it('supports the default OverlayUtil dispose no-op', () => {
39+
class TestOverlay extends OverlayUtil<TLOverlay<Record<string, never>>> {
40+
static override type = 'dispose_noop_tester'
41+
override isActive() {
42+
return false
43+
}
44+
override getOverlays() {
45+
return []
46+
}
47+
}
48+
49+
const editor = new TestEditor({ overlayUtils: [TestOverlay] })
50+
51+
expect(() => editor.overlays.dispose()).not.toThrow()
52+
})
53+
})
54+
1755
describe('getCurrentOverlays', () => {
1856
it('returns empty array when no overlays are active', () => {
1957
expect(editor.overlays.getCurrentOverlays()).toEqual([])

0 commit comments

Comments
 (0)