@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.
- package/dist-cjs/index.d.ts +49 -23
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +16 -12
- package/dist-cjs/lib/TldrawEditor.js.map +2 -2
- package/dist-cjs/lib/components/MenuClickCapture.js +42 -3
- package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
- package/dist-cjs/lib/components/default-components/DefaultCanvas.js +5 -1
- package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +2 -5
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
- package/dist-cjs/lib/components/default-components/DefaultSpinner.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +168 -181
- package/dist-cjs/lib/editor/Editor.js.map +3 -3
- package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
- package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -2
- package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/EditorManager.js +11 -2
- package/dist-cjs/lib/editor/managers/EditorManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js +49 -63
- package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +1 -1
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +24 -24
- package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js +7 -1
- package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js +4 -3
- package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
- package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
- package/dist-cjs/lib/exports/ExportDelay.js +10 -2
- package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
- package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
- package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
- package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
- package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
- package/dist-cjs/lib/exports/fetchCache.js +3 -0
- package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
- package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
- package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
- package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
- package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
- package/dist-cjs/lib/exports/parseCss.js +1 -1
- package/dist-cjs/lib/exports/parseCss.js.map +1 -1
- package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
- package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
- package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
- package/dist-cjs/lib/hooks/useIdentity.js +6 -2
- package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
- package/dist-cjs/lib/license/LicenseManager.js +9 -1
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/license/LicenseProvider.js +12 -4
- package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
- package/dist-cjs/lib/utils/debug-flags.js +0 -6
- package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
- package/dist-cjs/lib/utils/deepLinks.js +2 -1
- package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +32 -25
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +49 -23
- package/dist-esm/index.mjs +4 -2
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +17 -13
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
- package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
- package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
- package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +168 -181
- package/dist-esm/lib/editor/Editor.mjs.map +3 -3
- package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
- package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -2
- package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/EditorManager.mjs +11 -2
- package/dist-esm/lib/editor/managers/EditorManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs +49 -63
- package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +1 -1
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +24 -24
- package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs +7 -1
- package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs +4 -3
- package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
- package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
- package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
- package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
- package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
- package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
- package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
- package/dist-esm/lib/exports/fetchCache.mjs +3 -0
- package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
- package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
- package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
- package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
- package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
- package/dist-esm/lib/exports/parseCss.mjs +1 -1
- package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
- package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
- package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
- package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
- package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
- package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
- package/dist-esm/lib/license/LicenseManager.mjs +9 -1
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
- package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
- package/dist-esm/lib/utils/debug-flags.mjs +0 -6
- package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
- package/dist-esm/lib/utils/deepLinks.mjs +2 -1
- package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +32 -25
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +9 -1
- package/package.json +8 -11
- package/src/index.ts +5 -1
- package/src/lib/TldrawEditor.tsx +35 -21
- package/src/lib/components/MenuClickCapture.tsx +55 -4
- package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
- package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
- package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
- package/src/lib/editor/Editor.test.ts +65 -0
- package/src/lib/editor/Editor.ts +261 -294
- package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
- package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +1 -0
- package/src/lib/editor/managers/ClickManager/ClickManager.ts +3 -2
- package/src/lib/editor/managers/EditorManager.test.ts +32 -1
- package/src/lib/editor/managers/EditorManager.ts +15 -3
- package/src/lib/editor/managers/PerformanceManager/PerformanceManager.test.ts +80 -0
- package/src/lib/editor/managers/PerformanceManager/PerformanceManager.ts +54 -81
- package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +1 -1
- package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +32 -36
- package/src/lib/editor/managers/SnapManager/HandleSnaps.ts +13 -2
- package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +13 -0
- package/src/lib/editor/managers/SnapManager/SnapManager.ts +6 -4
- package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
- package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
- package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
- package/src/lib/editor/types/external-content.ts +5 -0
- package/src/lib/exports/ExportDelay.tsx +16 -4
- package/src/lib/exports/FontEmbedder.test.ts +99 -0
- package/src/lib/exports/FontEmbedder.ts +32 -11
- package/src/lib/exports/StyleEmbedder.ts +13 -4
- package/src/lib/exports/fetchCache.test.ts +22 -0
- package/src/lib/exports/fetchCache.ts +4 -0
- package/src/lib/exports/getSvgAsImage.ts +13 -5
- package/src/lib/exports/getSvgJsx.test.ts +43 -1
- package/src/lib/exports/getSvgJsx.tsx +28 -16
- package/src/lib/exports/parseCss.test.ts +4 -0
- package/src/lib/exports/parseCss.ts +2 -2
- package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
- package/src/lib/hooks/useCanvasEvents.ts +42 -0
- package/src/lib/hooks/useIdentity.tsx +12 -1
- package/src/lib/license/LicenseManager.ts +13 -1
- package/src/lib/license/LicenseProvider.test.tsx +76 -0
- package/src/lib/license/LicenseProvider.tsx +19 -5
- package/src/lib/test/preserveFocus.test.tsx +81 -0
- package/src/lib/utils/debug-flags.ts +0 -6
- package/src/lib/utils/deepLinks.test.ts +15 -0
- package/src/lib/utils/deepLinks.ts +8 -3
- package/src/lib/utils/sync/LocalIndexedDb.test.ts +121 -1
- package/src/lib/utils/sync/LocalIndexedDb.ts +41 -28
- package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
- package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
- package/src/version.ts +3 -3
- package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
- package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
- package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
- package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
- 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
|
-
|
|
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
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
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 (
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
requests.push(recordsStore.put(record, id))
|
|
213
222
|
}
|
|
214
223
|
|
|
215
224
|
for (const [_prev, updated] of Object.values(changes.updated)) {
|
|
216
|
-
|
|
225
|
+
requests.push(recordsStore.put(updated, updated.id))
|
|
217
226
|
}
|
|
218
227
|
|
|
219
228
|
for (const id of Object.keys(changes.removed)) {
|
|
220
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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())
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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 {
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
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-
|
|
8
|
-
patch: '2026-09-
|
|
7
|
+
minor: '2026-09-30T10:03:27.724Z',
|
|
8
|
+
patch: '2026-09-30T10:03:27.724Z',
|
|
9
9
|
}
|