@tldraw/editor 5.5.0-next.f9308d324e1c → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/dist-cjs/index.d.ts +49 -23
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +16 -12
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/MenuClickCapture.js +42 -3
  7. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +5 -1
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +2 -5
  11. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
  12. package/dist-cjs/lib/components/default-components/DefaultSpinner.js.map +2 -2
  13. package/dist-cjs/lib/editor/Editor.js +168 -181
  14. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  15. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  16. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -2
  18. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/EditorManager.js +11 -2
  20. package/dist-cjs/lib/editor/managers/EditorManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js +49 -63
  22. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +1 -1
  24. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +24 -24
  26. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
  27. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js +7 -1
  28. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js.map +2 -2
  29. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js +4 -3
  30. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js.map +2 -2
  31. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
  32. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  33. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  34. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  35. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  36. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  37. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  38. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  39. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  40. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  41. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  42. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  43. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  44. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  45. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  46. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  47. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  48. package/dist-cjs/lib/exports/parseCss.js +1 -1
  49. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  50. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  51. package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
  52. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  53. package/dist-cjs/lib/hooks/useIdentity.js +6 -2
  54. package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
  55. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  56. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  57. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  58. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  59. package/dist-cjs/lib/utils/debug-flags.js +0 -6
  60. package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
  61. package/dist-cjs/lib/utils/deepLinks.js +2 -1
  62. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  63. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +32 -25
  64. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  65. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
  66. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  67. package/dist-cjs/version.js +3 -3
  68. package/dist-cjs/version.js.map +1 -1
  69. package/dist-esm/index.d.mts +49 -23
  70. package/dist-esm/index.mjs +4 -2
  71. package/dist-esm/index.mjs.map +2 -2
  72. package/dist-esm/lib/TldrawEditor.mjs +17 -13
  73. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  74. package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
  75. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  76. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
  77. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  78. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
  79. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
  80. package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
  81. package/dist-esm/lib/editor/Editor.mjs +168 -181
  82. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  83. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  84. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  85. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -2
  86. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
  87. package/dist-esm/lib/editor/managers/EditorManager.mjs +11 -2
  88. package/dist-esm/lib/editor/managers/EditorManager.mjs.map +2 -2
  89. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs +49 -63
  90. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs.map +2 -2
  91. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +1 -1
  92. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  93. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +24 -24
  94. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
  95. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs +7 -1
  96. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs.map +2 -2
  97. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs +4 -3
  98. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs.map +2 -2
  99. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
  100. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  101. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  102. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  103. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  104. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  105. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  106. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  107. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  108. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  109. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  110. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  111. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  112. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  113. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  114. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  115. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  116. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  117. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  118. package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
  119. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  120. package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
  121. package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
  122. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  123. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  124. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  125. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  126. package/dist-esm/lib/utils/debug-flags.mjs +0 -6
  127. package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
  128. package/dist-esm/lib/utils/deepLinks.mjs +2 -1
  129. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  130. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +32 -25
  131. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  132. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
  133. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  134. package/dist-esm/version.mjs +3 -3
  135. package/dist-esm/version.mjs.map +1 -1
  136. package/editor.css +9 -1
  137. package/package.json +8 -11
  138. package/src/index.ts +5 -1
  139. package/src/lib/TldrawEditor.tsx +35 -21
  140. package/src/lib/components/MenuClickCapture.tsx +55 -4
  141. package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
  142. package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
  143. package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
  144. package/src/lib/editor/Editor.test.ts +65 -0
  145. package/src/lib/editor/Editor.ts +261 -294
  146. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  147. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +1 -0
  148. package/src/lib/editor/managers/ClickManager/ClickManager.ts +3 -2
  149. package/src/lib/editor/managers/EditorManager.test.ts +32 -1
  150. package/src/lib/editor/managers/EditorManager.ts +15 -3
  151. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.test.ts +80 -0
  152. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.ts +54 -81
  153. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +1 -1
  154. package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +32 -36
  155. package/src/lib/editor/managers/SnapManager/HandleSnaps.ts +13 -2
  156. package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +13 -0
  157. package/src/lib/editor/managers/SnapManager/SnapManager.ts +6 -4
  158. package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
  159. package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
  160. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  161. package/src/lib/editor/types/external-content.ts +5 -0
  162. package/src/lib/exports/ExportDelay.tsx +16 -4
  163. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  164. package/src/lib/exports/FontEmbedder.ts +32 -11
  165. package/src/lib/exports/StyleEmbedder.ts +13 -4
  166. package/src/lib/exports/fetchCache.test.ts +22 -0
  167. package/src/lib/exports/fetchCache.ts +4 -0
  168. package/src/lib/exports/getSvgAsImage.ts +13 -5
  169. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  170. package/src/lib/exports/getSvgJsx.tsx +28 -16
  171. package/src/lib/exports/parseCss.test.ts +4 -0
  172. package/src/lib/exports/parseCss.ts +2 -2
  173. package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
  174. package/src/lib/hooks/useCanvasEvents.ts +42 -0
  175. package/src/lib/hooks/useIdentity.tsx +12 -1
  176. package/src/lib/license/LicenseManager.ts +13 -1
  177. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  178. package/src/lib/license/LicenseProvider.tsx +19 -5
  179. package/src/lib/test/preserveFocus.test.tsx +81 -0
  180. package/src/lib/utils/debug-flags.ts +0 -6
  181. package/src/lib/utils/deepLinks.test.ts +15 -0
  182. package/src/lib/utils/deepLinks.ts +8 -3
  183. package/src/lib/utils/sync/LocalIndexedDb.test.ts +121 -1
  184. package/src/lib/utils/sync/LocalIndexedDb.ts +41 -28
  185. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
  186. package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
  187. package/src/version.ts +3 -3
  188. package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
  189. package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
  190. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
  191. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
  192. package/src/lib/primitives/geometry/geometry.bench.ts +0 -179
@@ -9,6 +9,9 @@ const STORE_PREFIX = 'TLDRAW_DOCUMENT_v2'
9
9
  const LEGACY_ASSET_STORE_PREFIX = 'TLDRAW_ASSET_STORE_v1'
10
10
  const dbNameIndexKey = 'TLDRAW_DB_NAME_INDEX_v2'
11
11
 
12
+ /** How many session state rows (one per tab) to keep per document when pruning. */
13
+ const MAX_SESSION_STATE_ROWS = 10
14
+
12
15
  /** @internal */
13
16
  export const Table = {
14
17
  Records: 'records',
@@ -25,7 +28,7 @@ async function openLocalDb(persistenceKey: string) {
25
28
 
26
29
  addDbName(storeId)
27
30
 
28
- return await openDB<StoreName>(storeId, 4, {
31
+ const db = await openDB<StoreName>(storeId, 4, {
29
32
  upgrade(database) {
30
33
  if (!database.objectStoreNames.contains(Table.Records)) {
31
34
  database.createObjectStore(Table.Records)
@@ -40,7 +43,16 @@ async function openLocalDb(persistenceKey: string) {
40
43
  database.createObjectStore(Table.Assets)
41
44
  }
42
45
  },
46
+ // Another tab is deleting (hard reset) or upgrading this database. Its request waits
47
+ // until every open connection closes, so holding ours would stall it indefinitely.
48
+ // Closing here lets it proceed; this tab's next write then fails and goes through
49
+ // the write-failure alert-and-reload path.
50
+ blocking() {
51
+ console.warn(`Closing ${storeId} so another tab can delete or upgrade it`)
52
+ db.close()
53
+ },
43
54
  })
55
+ return db
44
56
  }
45
57
 
46
58
  async function migrateLegacyAssetDbIfNeeded(persistenceKey: string) {
@@ -141,26 +153,22 @@ export class LocalIndexedDb {
141
153
  assert(!this.isClosed, 'db is closed')
142
154
  const db = await this.getDb()
143
155
  const tx = db.transaction(names, mode)
144
- // need to add a catch here early to prevent unhandled promise rejection
145
- // during react-strict-mode where this tx.done promise can be rejected
146
- // before we have a chance to await on it
147
- const done = tx.done.catch((e: unknown) => {
148
- if (!this.isClosed) {
149
- throw e
150
- }
151
- })
156
+ // Commit failures may arrive before the callback finishes; observe them immediately
157
+ // while preserving the rejection for the await below.
158
+ const done = tx.done
159
+ done.catch(noop)
152
160
  try {
153
161
  return await cb(tx)
154
162
  } finally {
155
- if (!this.isClosed) {
156
- await done
157
- } else {
158
- tx.abort()
159
- }
163
+ // let the transaction commit even if close() was called meanwhile, otherwise a
164
+ // persist racing an unmount is silently rolled back
165
+ await done
160
166
  }
161
167
  })()
162
168
  this.pendingTransactionSet.add(txPromise)
163
- txPromise.finally(() => this.pendingTransactionSet.delete(txPromise))
169
+ // not `.finally()`: the promise it returns would re-reject with nobody observing it
170
+ const cleanup = () => this.pendingTransactionSet.delete(txPromise)
171
+ txPromise.then(cleanup, cleanup)
164
172
  return txPromise
165
173
  }
166
174
 
@@ -208,17 +216,19 @@ export class LocalIndexedDb {
208
216
  const schemaStore = tx.objectStore(Table.Schema)
209
217
  const sessionStateStore = tx.objectStore(Table.SessionState)
210
218
 
219
+ const requests: Promise<unknown>[] = []
211
220
  for (const [id, record] of Object.entries(changes.added)) {
212
- await recordsStore.put(record, id)
221
+ requests.push(recordsStore.put(record, id))
213
222
  }
214
223
 
215
224
  for (const [_prev, updated] of Object.values(changes.updated)) {
216
- await recordsStore.put(updated, updated.id)
225
+ requests.push(recordsStore.put(updated, updated.id))
217
226
  }
218
227
 
219
228
  for (const id of Object.keys(changes.removed)) {
220
- await recordsStore.delete(id)
229
+ requests.push(recordsStore.delete(id))
221
230
  }
231
+ await Promise.all(requests)
222
232
 
223
233
  schemaStore.put(schema.serialize(), Table.Schema)
224
234
  if (sessionStateSnapshot && sessionId) {
@@ -254,9 +264,9 @@ export class LocalIndexedDb {
254
264
 
255
265
  await recordsStore.clear()
256
266
 
257
- for (const [id, record] of Object.entries(snapshot)) {
258
- await recordsStore.put(record, id)
259
- }
267
+ await Promise.all(
268
+ Object.entries(snapshot).map(([id, record]) => recordsStore.put(record, id))
269
+ )
260
270
 
261
271
  schemaStore.put(schema.serialize(), Table.Schema)
262
272
 
@@ -275,15 +285,18 @@ export class LocalIndexedDb {
275
285
  })
276
286
  }
277
287
 
278
- async pruneSessions() {
288
+ /**
289
+ * Drop all but the most recently updated session state rows. The row for `keepSessionId` is never
290
+ * deleted: it may be an old row that this tab restored its state from and has not rewritten yet,
291
+ * and deleting it would make a later reload fall back to another tab's session state.
292
+ */
293
+ async pruneSessions({ keepSessionId }: { keepSessionId?: string } = {}) {
279
294
  await this.tx('readwrite', [Table.SessionState], async (tx) => {
280
295
  const sessionStateStore = tx.objectStore(Table.SessionState)
281
- const all = (await sessionStateStore.getAll()).sort((a, b) => a.updatedAt - b.updatedAt)
282
- if (all.length < 10) {
283
- await tx.done
284
- return
285
- }
286
- const toDelete = all.slice(0, all.length - 10)
296
+ const all = ((await sessionStateStore.getAll()) as SessionStateSnapshotRow[])
297
+ .filter((row) => row.id !== keepSessionId)
298
+ .sort((a, b) => a.updatedAt - b.updatedAt)
299
+ const toDelete = all.slice(0, Math.max(0, all.length - MAX_SESSION_STATE_ROWS))
287
300
  for (const { id } of toDelete) {
288
301
  await sessionStateStore.delete(id)
289
302
  }
@@ -3,6 +3,7 @@ import { IndexKey, promiseWithResolve } from '@tldraw/utils'
3
3
  import { Mock, vi } from 'vitest'
4
4
  import { createTLStore } from '../../config/createTLStore'
5
5
  import { hardReset } from './hardReset'
6
+ import { LocalIndexedDb } from './LocalIndexedDb'
6
7
  import { TLLocalSyncClient } from './TLLocalSyncClient'
7
8
 
8
9
  class BroadcastChannelMock {
@@ -38,9 +39,12 @@ function testClient(channel = new BroadcastChannelMock('test')) {
38
39
 
39
40
  client.db.storeSnapshot = vi.fn(() => Promise.resolve())
40
41
  client.db.storeChanges = vi.fn(() => Promise.resolve())
42
+ client.db.pruneSessions = vi.fn(() => Promise.resolve())
41
43
 
42
44
  return {
43
- client: client as { db: { storeSnapshot: Mock; storeChanges: Mock } } & typeof client,
45
+ client: client as {
46
+ db: { storeSnapshot: Mock; storeChanges: Mock; pruneSessions: Mock }
47
+ } & typeof client,
44
48
  store,
45
49
  onLoad,
46
50
  onLoadError,
@@ -83,6 +87,23 @@ test('the client connects on instantiation, announcing its schema', async () =>
83
87
  expect(msg).toMatchObject({ type: 'announce', schema: {} })
84
88
  })
85
89
 
90
+ test('the client prunes stale session state rows after loading, keeping its own', async () => {
91
+ const { client, onLoad, tick } = testClient()
92
+ expect(client.db.pruneSessions).not.toHaveBeenCalled()
93
+ await tick()
94
+ expect(onLoad).toHaveBeenCalledTimes(1)
95
+ expect(client.db.pruneSessions).toHaveBeenCalledTimes(1)
96
+ expect(client.db.pruneSessions).toHaveBeenCalledWith({ keepSessionId: client.sessionId })
97
+ })
98
+
99
+ test('a failed session prune does not fail the load', async () => {
100
+ const { client, onLoad, onLoadError, tick } = testClient()
101
+ client.db.pruneSessions.mockImplementation(() => Promise.reject(new Error('nope')))
102
+ await tick()
103
+ expect(onLoad).toHaveBeenCalledTimes(1)
104
+ expect(onLoadError).not.toHaveBeenCalled()
105
+ })
106
+
86
107
  test('when a client receives an announce with a newer schema version it reloads itself', async () => {
87
108
  const { client, channel, onLoadError, tick } = testClient()
88
109
  await tick()
@@ -214,6 +235,73 @@ test('pagehide does not write before the initial load has completed', async () =
214
235
  expect(client.db.storeChanges).not.toHaveBeenCalled()
215
236
  })
216
237
 
238
+ test('diffs received while the client is still loading are applied once it has loaded', async () => {
239
+ const { client, channel, tick } = testClient()
240
+ const remotePage = PageRecordType.create({ name: 'remote', index: 'a0' as IndexKey })
241
+ channel.onmessage?.({
242
+ data: {
243
+ type: 'diff',
244
+ storeId: 'other-tab',
245
+ schema: client.serializedSchema,
246
+ changes: { added: { [remotePage.id]: remotePage }, updated: {}, removed: {} },
247
+ },
248
+ } as any)
249
+ expect(client.store.get(remotePage.id)).toBeUndefined()
250
+
251
+ await tick()
252
+ expect(client.store.get(remotePage.id)).toEqual(remotePage)
253
+
254
+ // and the first (full) db write includes them rather than overwriting them
255
+ client.store.put([PageRecordType.create({ name: 'local', index: 'a1' as IndexKey })])
256
+ await tick()
257
+ expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
258
+ expect(client.db.storeSnapshot.mock.calls[0][0].snapshot[remotePage.id]).toEqual(remotePage)
259
+ })
260
+
261
+ test('closing the client persists changes that are still queued', async () => {
262
+ const { client, tick } = testClient()
263
+ await tick()
264
+ client.store.put([PageRecordType.create({ name: 'test', index: 'a0' as IndexKey })])
265
+ expect(client.db.storeSnapshot).not.toHaveBeenCalled()
266
+
267
+ // close before the throttled persist fires
268
+ client.close()
269
+ expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
270
+ })
271
+
272
+ test('closing the client while a persist is in flight still persists edits queued during the write', async () => {
273
+ const idbOperationResult = promiseWithResolve<void>()
274
+ const { client, tick } = testClient()
275
+ client.db.storeSnapshot.mockImplementationOnce(() => idbOperationResult)
276
+ const closeDb = vi.spyOn(client.db, 'close')
277
+
278
+ await tick()
279
+ client.store.put([PageRecordType.create({ name: 'test', index: 'a0' as IndexKey })])
280
+ await tick()
281
+ expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
282
+
283
+ // an edit made while the first write is still in flight, then unmount
284
+ const page = PageRecordType.create({ name: 'test2', index: 'a1' as IndexKey })
285
+ client.store.put([page])
286
+ client.close()
287
+ expect(client.db.storeChanges).not.toHaveBeenCalled()
288
+ expect(closeDb).not.toHaveBeenCalled()
289
+
290
+ idbOperationResult.resolve()
291
+ await tick()
292
+ expect(client.db.storeChanges).toHaveBeenCalledTimes(1)
293
+ expect(client.db.storeChanges.mock.calls[0][0].changes.added[page.id]).toEqual(page)
294
+ expect(closeDb).toHaveBeenCalledTimes(1)
295
+ })
296
+
297
+ test('closing the client with nothing queued does not write to the db', async () => {
298
+ const { client, tick } = testClient()
299
+ await tick()
300
+ client.close()
301
+ expect(client.db.storeSnapshot).not.toHaveBeenCalled()
302
+ expect(client.db.storeChanges).not.toHaveBeenCalled()
303
+ })
304
+
217
305
  test('pagehide and visibilitychange listeners are removed when the client closes', async () => {
218
306
  const removeWindowListener = vi.spyOn(window, 'removeEventListener')
219
307
  const removeDocumentListener = vi.spyOn(document, 'removeEventListener')
@@ -228,3 +316,171 @@ test('pagehide and visibilitychange listeners are removed when the client closes
228
316
  removeDocumentListener.mockRestore()
229
317
  }
230
318
  })
319
+
320
+ test('closing the client before it has loaded does not write to the db', async () => {
321
+ const { client, tick } = testClient()
322
+ client.close()
323
+ await tick()
324
+ expect(client.db.storeSnapshot).not.toHaveBeenCalled()
325
+ expect(client.db.storeChanges).not.toHaveBeenCalled()
326
+ })
327
+
328
+ test('closing flushes changes that have not reached the next animation frame', async () => {
329
+ const { client, tick } = testClient()
330
+ await tick()
331
+ const page = PageRecordType.create({ name: 'last frame', index: 'a0' as IndexKey })
332
+ vi.stubGlobal('__FORCE_RAF_IN_TESTS__', true)
333
+ try {
334
+ client.store.put([page])
335
+ client.close()
336
+ expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
337
+ expect(client.db.storeSnapshot.mock.calls[0][0].snapshot[page.id]).toEqual(page)
338
+ } finally {
339
+ vi.unstubAllGlobals()
340
+ }
341
+ })
342
+
343
+ test('buffered schema announcements cannot persist before the remaining buffered diffs', async () => {
344
+ const { client, channel, tick } = testClient()
345
+ const page = PageRecordType.create({ name: 'remote', index: 'a0' as IndexKey })
346
+ const schema = client.store.schema.serialize()
347
+ channel.onmessage?.({
348
+ data: {
349
+ type: 'announce',
350
+ schema: { ...schema, sequences: { ...schema.sequences, 'com.tldraw.page': 0 } },
351
+ },
352
+ } as MessageEvent)
353
+ channel.onmessage?.({
354
+ data: {
355
+ type: 'diff',
356
+ storeId: 'other-tab',
357
+ schema: client.serializedSchema,
358
+ changes: { added: { [page.id]: page }, updated: {}, removed: {} },
359
+ },
360
+ } as MessageEvent)
361
+ await tick()
362
+ expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
363
+ expect(client.db.storeSnapshot.mock.calls[0][0].snapshot[page.id]).toEqual(page)
364
+ client.close()
365
+ })
366
+
367
+ test('closing before load cancels a persist scheduled by an early edit', async () => {
368
+ const load = promiseWithResolve<Awaited<ReturnType<LocalIndexedDb['load']>>>()
369
+ const loadMock = vi.spyOn(LocalIndexedDb.prototype, 'load').mockImplementationOnce(() => load)
370
+ try {
371
+ const { client, tick } = testClient()
372
+ client.store.put([PageRecordType.create({ name: 'early', index: 'a0' as IndexKey })])
373
+ client.close()
374
+ load.resolve({ records: [], schema: undefined, sessionStateSnapshot: undefined })
375
+ await tick()
376
+ expect(client.db.storeSnapshot).not.toHaveBeenCalled()
377
+ expect(client.db.storeChanges).not.toHaveBeenCalled()
378
+ } finally {
379
+ loadMock.mockRestore()
380
+ }
381
+ })
382
+
383
+ test.each(['close flush', 'in-flight write'] as const)(
384
+ 'a failed %s after close logs the error without disrupting the current page',
385
+ async (when) => {
386
+ const { client, tick } = testClient()
387
+ await tick()
388
+ const write = promiseWithResolve<void>()
389
+ client.db.storeSnapshot.mockImplementationOnce(() => write)
390
+ const error = new Error('write failed')
391
+ const log = vi.spyOn(console, 'error').mockImplementation(() => {})
392
+ const alert = vi.spyOn(window, 'alert').mockImplementation(() => {})
393
+ try {
394
+ client.store.put([PageRecordType.create({ name: 'last edit', index: 'a0' as IndexKey })])
395
+ if (when === 'in-flight write') await tick()
396
+ client.close()
397
+ write.reject(error)
398
+ await tick()
399
+ expect(log).toHaveBeenCalledWith('failed to store changes in indexed db', error)
400
+ expect(alert).not.toHaveBeenCalled()
401
+ expect(reloadMock).not.toHaveBeenCalled()
402
+ } finally {
403
+ log.mockRestore()
404
+ alert.mockRestore()
405
+ }
406
+ }
407
+ )
408
+
409
+ test('closing with no queued changes cancels a failed-write retry', async () => {
410
+ const { client, tick } = testClient()
411
+ await tick()
412
+ const log = vi.spyOn(console, 'error').mockImplementation(() => {})
413
+ const alert = vi.spyOn(window, 'alert').mockImplementation(() => {})
414
+ try {
415
+ client.db.storeSnapshot.mockRejectedValueOnce(new Error('first write failed'))
416
+ client.store.put([PageRecordType.create({ name: 'retry', index: 'a0' as IndexKey })])
417
+ await tick()
418
+ expect(reloadMock).toHaveBeenCalledTimes(1)
419
+ client.close()
420
+ await vi.advanceTimersByTimeAsync(10_000)
421
+ expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
422
+ } finally {
423
+ log.mockRestore()
424
+ alert.mockRestore()
425
+ }
426
+ })
427
+
428
+ test('a schema mismatch does not try to refresh without a window', async () => {
429
+ const { client, channel, tick } = testClient()
430
+ await tick()
431
+ vi.advanceTimersByTime(10_000)
432
+ vi.stubGlobal('window', undefined)
433
+ try {
434
+ expect(() =>
435
+ channel.onmessage?.({
436
+ data: {
437
+ type: 'announce',
438
+ schema: {
439
+ ...client.serializedSchema,
440
+ schemaVersion: client.serializedSchema.schemaVersion + 1,
441
+ },
442
+ },
443
+ } as MessageEvent)
444
+ ).not.toThrow()
445
+ } finally {
446
+ vi.unstubAllGlobals()
447
+ client.close()
448
+ }
449
+ })
450
+
451
+ test('onLoad can close the client without losing buffered remote changes', async () => {
452
+ const { client, channel, onLoad, tick } = testClient()
453
+ const page = PageRecordType.create({ name: 'remote', index: 'a0' as IndexKey })
454
+ channel.onmessage?.({
455
+ data: {
456
+ type: 'diff',
457
+ storeId: 'other-tab',
458
+ schema: client.serializedSchema,
459
+ changes: { added: { [page.id]: page }, updated: {}, removed: {} },
460
+ },
461
+ } as MessageEvent)
462
+ onLoad.mockImplementation(() => {
463
+ client.store.put([PageRecordType.create({ name: 'local', index: 'a1' as IndexKey })])
464
+ client.close()
465
+ })
466
+ await tick()
467
+ expect(client.db.storeSnapshot.mock.calls[0][0].snapshot[page.id]).toEqual(page)
468
+ })
469
+
470
+ test('a buffered newer schema reports a load error without announcing a successful load', async () => {
471
+ const { client, channel, onLoad, onLoadError, tick } = testClient()
472
+ channel.onmessage?.({
473
+ data: {
474
+ type: 'announce',
475
+ schema: {
476
+ ...client.serializedSchema,
477
+ schemaVersion: client.serializedSchema.schemaVersion + 1,
478
+ },
479
+ },
480
+ } as MessageEvent)
481
+ await tick()
482
+ expect(onLoadError).toHaveBeenCalledTimes(1)
483
+ expect(onLoad).not.toHaveBeenCalled()
484
+ client.close()
485
+ expect(client.db.storeSnapshot).not.toHaveBeenCalled()
486
+ })
@@ -9,6 +9,7 @@ import {
9
9
  extractSessionStateFromLegacySnapshot,
10
10
  loadSessionStateSnapshotIntoStore,
11
11
  } from '../../config/TLSessionStateSnapshot'
12
+ import { refreshPage } from '../runtime'
12
13
  import { showCantReadFromIndexDbAlert, showCantWriteToIndexDbAlert } from './alerts'
13
14
  import { LocalIndexedDb } from './LocalIndexedDb'
14
15
 
@@ -108,7 +109,6 @@ export class TLLocalSyncClient {
108
109
  this.persistenceKey = persistenceKey
109
110
  this.sessionId = sessionId
110
111
  this.db = new LocalIndexedDb(persistenceKey)
111
- this.disposables.add(() => this.db.close())
112
112
 
113
113
  this.serializedSchema = this.store.schema.serialize()
114
114
  this.$sessionStateSnapshot = createSessionStateSnapshotSignal(this.store)
@@ -179,6 +179,65 @@ export class TLLocalSyncClient {
179
179
  this.debug('connecting')
180
180
  let data: UnpackPromise<ReturnType<LocalIndexedDb['load']>> | undefined
181
181
 
182
+ const handleMessage = (msg: Message) => {
183
+ // if their schema is earlier than ours, we need to tell them so they can refresh
184
+ // if their schema is later than ours, we need to refresh
185
+ const res = this.store.schema.getMigrationsSince(msg.schema)
186
+
187
+ if (!res.ok) {
188
+ this.isReloading = true
189
+ // we are older, refresh
190
+ // but add a safety check to make sure we don't get in an infinite loop
191
+ const timeSinceInit = Date.now() - this.initTime
192
+ if (timeSinceInit < 5000) {
193
+ // This tab was just reloaded, but is out of date compared to other tabs.
194
+ // Not expecting this to ever happen. It should only happen if we roll back a release that incremented
195
+ // the schema version (which we should never do)
196
+ // Or maybe during development if you have multiple local tabs open running the app on prod mode and you
197
+ // check out an older commit. Dev server should be fine.
198
+ onLoadError(new Error('Schema mismatch, please close other tabs and reload the page'))
199
+ return false
200
+ }
201
+ this.debug('reloading')
202
+ if (typeof window !== 'undefined') refreshPage()
203
+ return false
204
+ } else if (res.value.length > 0) {
205
+ // they are older, tell them to refresh and not write any more data
206
+ this.debug('telling them to reload')
207
+ this.channel.postMessage({ type: 'announce', schema: this.serializedSchema })
208
+ // schedule a full db write in case they wrote data anyway
209
+ this.shouldDoFullDBWrite = true
210
+ this.schedulePersist()
211
+ return true
212
+ }
213
+ // otherwise, all good, same version :)
214
+ if (msg.type === 'diff') {
215
+ this.debug('applying diff')
216
+ transact(() => {
217
+ this.store.mergeRemoteChanges(() => {
218
+ this.store.applyDiff(msg.changes as any)
219
+ })
220
+ })
221
+ }
222
+ return true
223
+ }
224
+
225
+ // Listen from the start and buffer until we've loaded: diffs other tabs broadcast while
226
+ // we're still reading from IndexedDB would otherwise be dropped, then erased by our first
227
+ // (full) db write.
228
+ let bufferedMessages: Message[] | null = []
229
+ this.channel.onmessage = ({ data }) => {
230
+ this.debug('got message', data)
231
+ if (bufferedMessages) {
232
+ bufferedMessages.push(data as Message)
233
+ } else {
234
+ handleMessage(data as Message)
235
+ }
236
+ }
237
+ this.disposables.add(() => {
238
+ this.channel.close()
239
+ })
240
+
182
241
  try {
183
242
  data = await this.db.load({ sessionId: this.sessionId })
184
243
  } catch (error: any) {
@@ -224,56 +283,24 @@ export class TLLocalSyncClient {
224
283
  })
225
284
  }
226
285
  }
227
-
228
- this.channel.onmessage = ({ data }) => {
229
- this.debug('got message', data)
230
- const msg = data as Message
231
- // if their schema is earlier than ours, we need to tell them so they can refresh
232
- // if their schema is later than ours, we need to refresh
233
- const res = this.store.schema.getMigrationsSince(msg.schema)
234
-
235
- if (!res.ok) {
236
- // we are older, refresh
237
- // but add a safety check to make sure we don't get in an infinite loop
238
- const timeSinceInit = Date.now() - this.initTime
239
- if (timeSinceInit < 5000) {
240
- // This tab was just reloaded, but is out of date compared to other tabs.
241
- // Not expecting this to ever happen. It should only happen if we roll back a release that incremented
242
- // the schema version (which we should never do)
243
- // Or maybe during development if you have multiple local tabs open running the app on prod mode and you
244
- // check out an older commit. Dev server should be fine.
245
- onLoadError(new Error('Schema mismatch, please close other tabs and reload the page'))
246
- return
247
- }
248
- this.debug('reloading')
249
- this.isReloading = true
250
- window?.location?.reload?.()
251
- return
252
- } else if (res.value.length > 0) {
253
- // they are older, tell them to refresh and not write any more data
254
- this.debug('telling them to reload')
255
- this.channel.postMessage({ type: 'announce', schema: this.serializedSchema })
256
- // schedule a full db write in case they wrote data anyway
257
- this.shouldDoFullDBWrite = true
258
- this.persistIfNeeded()
259
- return
260
- }
261
- // otherwise, all good, same version :)
262
- if (msg.type === 'diff') {
263
- this.debug('applying diff')
264
- transact(() => {
265
- this.store.mergeRemoteChanges(() => {
266
- this.store.applyDiff(msg.changes as any)
267
- })
268
- })
269
- }
286
+ // Older-schema announcements schedule writes, so drain every diff before those writes
287
+ // or an onLoad callback that immediately closes the client can capture a snapshot.
288
+ const messages = bufferedMessages
289
+ bufferedMessages = null
290
+ for (const message of messages) {
291
+ if (!handleMessage(message)) return
270
292
  }
271
- this.channel.postMessage({ type: 'announce', schema: this.serializedSchema })
272
- this.disposables.add(() => {
273
- this.channel.close()
274
- })
275
293
  this.didLoad = true
294
+ if (this.diffQueue.length > 0) this.schedulePersist()
295
+
296
+ this.channel.postMessage({ type: 'announce', schema: this.serializedSchema })
276
297
  onLoad(this)
298
+
299
+ // Session state rows accumulate one per tab, so trim them on load. Pruning is best-effort
300
+ // and must never turn a successful load into a failure.
301
+ this.db.pruneSessions({ keepSessionId: this.sessionId }).catch((e: unknown) => {
302
+ this.debug('failed to prune session state rows', e)
303
+ })
277
304
  } catch (e: any) {
278
305
  this.debug('error loading data from store', e)
279
306
  if (this.didDispose) return
@@ -283,15 +310,43 @@ export class TLLocalSyncClient {
283
310
  }
284
311
 
285
312
  close() {
313
+ if (this.didDispose) return
286
314
  this.debug('closing')
287
315
  this.didDispose = true
316
+ // Store listeners normally run on the next frame; collect the last edits before removal.
317
+ this.store._flushHistory()
288
318
  this.disposables.forEach((d) => d())
319
+ if (this.scheduledPersistTimeout) {
320
+ clearTimeout(this.scheduledPersistTimeout)
321
+ this.scheduledPersistTimeout = null
322
+ }
289
323
  if (typeof window !== 'undefined' && (window as any).tlsync === this) {
290
324
  delete (window as any).tlsync
291
325
  }
326
+ this.flushAndCloseDb()
327
+ }
328
+
329
+ /**
330
+ * Flush queued changes so edits made in the last PERSIST_THROTTLE_MS before unmount aren't lost,
331
+ * then close the db. Never flushes before load (a full write then would wipe the db with an empty
332
+ * store), and only when something is queued: shouldDoFullDBWrite stays true until the first
333
+ * persist, so an unconditional flush would rewrite the whole document on every no-op close.
334
+ */
335
+ private flushAndCloseDb() {
336
+ // A persist in flight can't be joined (persistIfNeeded bails while one is running) and,
337
+ // now that we're disposed, won't schedule a follow-up. Edits queued during that write
338
+ // would be lost if the db closed now, so wait for it and flush again.
339
+ if (this.persistPromise) {
340
+ this.persistPromise.then(() => this.flushAndCloseDb())
341
+ return
342
+ }
343
+ if (this.didLoad && this.diffQueue.length > 0) this.persistIfNeeded()
344
+ // the db waits for the transaction the flush just opened before actually closing
345
+ this.db.close()
292
346
  }
293
347
 
294
348
  private isPersisting = false
349
+ private persistPromise: Promise<void> | null = null
295
350
  private didLastWriteError = false
296
351
  // eslint-disable-next-line no-restricted-globals
297
352
  private scheduledPersistTimeout: ReturnType<typeof setTimeout> | null = null
@@ -305,6 +360,8 @@ export class TLLocalSyncClient {
305
360
  private schedulePersist() {
306
361
  this.debug('schedulePersist', this.scheduledPersistTimeout)
307
362
  if (this.scheduledPersistTimeout) return
363
+ // A deferred persist would try to write to a database that is closing or already closed.
364
+ if (this.didDispose) return
308
365
  // eslint-disable-next-line no-restricted-globals
309
366
  this.scheduledPersistTimeout = setTimeout(
310
367
  () => {
@@ -339,6 +396,8 @@ export class TLLocalSyncClient {
339
396
  this.scheduledPersistTimeout = null
340
397
  }
341
398
 
399
+ if (!this.didLoad) return
400
+
342
401
  // if a persist is already in progress, we don't need to do anything -
343
402
  // if there are still outstanding changes once it's finished, it'll
344
403
  // schedule another persist
@@ -353,7 +412,7 @@ export class TLLocalSyncClient {
353
412
 
354
413
  // if we're scheduled for a full write or if we have changes outstanding, let's persist them!
355
414
  if (this.shouldDoFullDBWrite || this.diffQueue.length > 0) {
356
- this.doPersist()
415
+ this.persistPromise = this.doPersist()
357
416
  }
358
417
  }
359
418
 
@@ -363,7 +422,6 @@ export class TLLocalSyncClient {
363
422
  */
364
423
  private async doPersist() {
365
424
  assert(!this.isPersisting, 'persist already in progress')
366
- if (this.didDispose) return
367
425
  this.isPersisting = true
368
426
 
369
427
  this.debug('doPersist start')
@@ -401,14 +459,14 @@ export class TLLocalSyncClient {
401
459
  this.didLastWriteError = true
402
460
  console.error('failed to store changes in indexed db', e)
403
461
 
404
- showCantWriteToIndexDbAlert()
405
- if (typeof window !== 'undefined') {
406
- // adios
407
- window.location.reload()
462
+ if (!this.didDispose && typeof window !== 'undefined') {
463
+ showCantWriteToIndexDbAlert()
464
+ refreshPage()
408
465
  }
409
466
  }
410
467
 
411
468
  this.isPersisting = false
469
+ this.persistPromise = null
412
470
  this.debug('doPersist end')
413
471
 
414
472
  // changes might have come in between when we started the persist and
package/src/version.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '5.5.0-next.f9308d324e1c'
4
+ export const version = '5.5.0'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-09-04T10:04:53.550Z',
8
- patch: '2026-09-04T10:04:53.550Z',
7
+ minor: '2026-09-30T10:03:27.724Z',
8
+ patch: '2026-09-30T10:03:27.724Z',
9
9
  }