@tldraw/editor 5.4.2 → 5.5.0-canary.0241f11f2e99
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 +7 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/constants.js +2 -0
- package/dist-cjs/lib/constants.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +131 -151
- 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/EdgeScrollManager/EdgeScrollManager.js +10 -6
- package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +7 -3
- package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.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/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/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/primitives/geometry/Arc2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/Group2d.js +8 -5
- package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/Point2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +13 -17
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +78 -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 +7 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/constants.mjs +2 -0
- package/dist-esm/lib/constants.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +132 -151
- 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/EdgeScrollManager/EdgeScrollManager.mjs +10 -6
- package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +7 -3
- package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.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/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/primitives/geometry/Arc2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/Group2d.mjs +12 -6
- package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/Point2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +13 -17
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +78 -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/package.json +7 -7
- package/src/lib/constants.ts +8 -0
- package/src/lib/editor/Editor.test.ts +20 -0
- package/src/lib/editor/Editor.ts +202 -255
- package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +35 -4
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +13 -7
- package/src/lib/editor/managers/ThemeManager/ThemeManager.test.ts +53 -0
- package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +9 -3
- package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
- 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/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/primitives/geometry/Arc2d.test.ts +9 -0
- package/src/lib/primitives/geometry/Arc2d.ts +1 -1
- package/src/lib/primitives/geometry/CubicBezier2d.test.ts +13 -0
- package/src/lib/primitives/geometry/CubicBezier2d.ts +1 -1
- package/src/lib/primitives/geometry/Edge2d.test.ts +21 -3
- package/src/lib/primitives/geometry/Edge2d.ts +1 -1
- package/src/lib/primitives/geometry/Group2d.test.ts +26 -5
- package/src/lib/primitives/geometry/Group2d.ts +18 -7
- package/src/lib/primitives/geometry/Point2d.test.ts +14 -3
- package/src/lib/primitives/geometry/Point2d.ts +1 -1
- package/src/lib/utils/sync/LocalIndexedDb.test.ts +28 -0
- package/src/lib/utils/sync/LocalIndexedDb.ts +18 -20
- package/src/lib/utils/sync/TLLocalSyncClient.test.ts +236 -0
- package/src/lib/utils/sync/TLLocalSyncClient.ts +106 -54
- package/src/version.ts +3 -3
|
@@ -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 {
|
|
@@ -214,6 +215,73 @@ test('pagehide does not write before the initial load has completed', async () =
|
|
|
214
215
|
expect(client.db.storeChanges).not.toHaveBeenCalled()
|
|
215
216
|
})
|
|
216
217
|
|
|
218
|
+
test('diffs received while the client is still loading are applied once it has loaded', async () => {
|
|
219
|
+
const { client, channel, tick } = testClient()
|
|
220
|
+
const remotePage = PageRecordType.create({ name: 'remote', index: 'a0' as IndexKey })
|
|
221
|
+
channel.onmessage?.({
|
|
222
|
+
data: {
|
|
223
|
+
type: 'diff',
|
|
224
|
+
storeId: 'other-tab',
|
|
225
|
+
schema: client.serializedSchema,
|
|
226
|
+
changes: { added: { [remotePage.id]: remotePage }, updated: {}, removed: {} },
|
|
227
|
+
},
|
|
228
|
+
} as any)
|
|
229
|
+
expect(client.store.get(remotePage.id)).toBeUndefined()
|
|
230
|
+
|
|
231
|
+
await tick()
|
|
232
|
+
expect(client.store.get(remotePage.id)).toEqual(remotePage)
|
|
233
|
+
|
|
234
|
+
// and the first (full) db write includes them rather than overwriting them
|
|
235
|
+
client.store.put([PageRecordType.create({ name: 'local', index: 'a1' as IndexKey })])
|
|
236
|
+
await tick()
|
|
237
|
+
expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
|
|
238
|
+
expect(client.db.storeSnapshot.mock.calls[0][0].snapshot[remotePage.id]).toEqual(remotePage)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('closing the client persists changes that are still queued', async () => {
|
|
242
|
+
const { client, tick } = testClient()
|
|
243
|
+
await tick()
|
|
244
|
+
client.store.put([PageRecordType.create({ name: 'test', index: 'a0' as IndexKey })])
|
|
245
|
+
expect(client.db.storeSnapshot).not.toHaveBeenCalled()
|
|
246
|
+
|
|
247
|
+
// close before the throttled persist fires
|
|
248
|
+
client.close()
|
|
249
|
+
expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('closing the client while a persist is in flight still persists edits queued during the write', async () => {
|
|
253
|
+
const idbOperationResult = promiseWithResolve<void>()
|
|
254
|
+
const { client, tick } = testClient()
|
|
255
|
+
client.db.storeSnapshot.mockImplementationOnce(() => idbOperationResult)
|
|
256
|
+
const closeDb = vi.spyOn(client.db, 'close')
|
|
257
|
+
|
|
258
|
+
await tick()
|
|
259
|
+
client.store.put([PageRecordType.create({ name: 'test', index: 'a0' as IndexKey })])
|
|
260
|
+
await tick()
|
|
261
|
+
expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
|
|
262
|
+
|
|
263
|
+
// an edit made while the first write is still in flight, then unmount
|
|
264
|
+
const page = PageRecordType.create({ name: 'test2', index: 'a1' as IndexKey })
|
|
265
|
+
client.store.put([page])
|
|
266
|
+
client.close()
|
|
267
|
+
expect(client.db.storeChanges).not.toHaveBeenCalled()
|
|
268
|
+
expect(closeDb).not.toHaveBeenCalled()
|
|
269
|
+
|
|
270
|
+
idbOperationResult.resolve()
|
|
271
|
+
await tick()
|
|
272
|
+
expect(client.db.storeChanges).toHaveBeenCalledTimes(1)
|
|
273
|
+
expect(client.db.storeChanges.mock.calls[0][0].changes.added[page.id]).toEqual(page)
|
|
274
|
+
expect(closeDb).toHaveBeenCalledTimes(1)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('closing the client with nothing queued does not write to the db', async () => {
|
|
278
|
+
const { client, tick } = testClient()
|
|
279
|
+
await tick()
|
|
280
|
+
client.close()
|
|
281
|
+
expect(client.db.storeSnapshot).not.toHaveBeenCalled()
|
|
282
|
+
expect(client.db.storeChanges).not.toHaveBeenCalled()
|
|
283
|
+
})
|
|
284
|
+
|
|
217
285
|
test('pagehide and visibilitychange listeners are removed when the client closes', async () => {
|
|
218
286
|
const removeWindowListener = vi.spyOn(window, 'removeEventListener')
|
|
219
287
|
const removeDocumentListener = vi.spyOn(document, 'removeEventListener')
|
|
@@ -228,3 +296,171 @@ test('pagehide and visibilitychange listeners are removed when the client closes
|
|
|
228
296
|
removeDocumentListener.mockRestore()
|
|
229
297
|
}
|
|
230
298
|
})
|
|
299
|
+
|
|
300
|
+
test('closing the client before it has loaded does not write to the db', async () => {
|
|
301
|
+
const { client, tick } = testClient()
|
|
302
|
+
client.close()
|
|
303
|
+
await tick()
|
|
304
|
+
expect(client.db.storeSnapshot).not.toHaveBeenCalled()
|
|
305
|
+
expect(client.db.storeChanges).not.toHaveBeenCalled()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('closing flushes changes that have not reached the next animation frame', async () => {
|
|
309
|
+
const { client, tick } = testClient()
|
|
310
|
+
await tick()
|
|
311
|
+
const page = PageRecordType.create({ name: 'last frame', index: 'a0' as IndexKey })
|
|
312
|
+
vi.stubGlobal('__FORCE_RAF_IN_TESTS__', true)
|
|
313
|
+
try {
|
|
314
|
+
client.store.put([page])
|
|
315
|
+
client.close()
|
|
316
|
+
expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
|
|
317
|
+
expect(client.db.storeSnapshot.mock.calls[0][0].snapshot[page.id]).toEqual(page)
|
|
318
|
+
} finally {
|
|
319
|
+
vi.unstubAllGlobals()
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('buffered schema announcements cannot persist before the remaining buffered diffs', async () => {
|
|
324
|
+
const { client, channel, tick } = testClient()
|
|
325
|
+
const page = PageRecordType.create({ name: 'remote', index: 'a0' as IndexKey })
|
|
326
|
+
const schema = client.store.schema.serialize()
|
|
327
|
+
channel.onmessage?.({
|
|
328
|
+
data: {
|
|
329
|
+
type: 'announce',
|
|
330
|
+
schema: { ...schema, sequences: { ...schema.sequences, 'com.tldraw.page': 0 } },
|
|
331
|
+
},
|
|
332
|
+
} as MessageEvent)
|
|
333
|
+
channel.onmessage?.({
|
|
334
|
+
data: {
|
|
335
|
+
type: 'diff',
|
|
336
|
+
storeId: 'other-tab',
|
|
337
|
+
schema: client.serializedSchema,
|
|
338
|
+
changes: { added: { [page.id]: page }, updated: {}, removed: {} },
|
|
339
|
+
},
|
|
340
|
+
} as MessageEvent)
|
|
341
|
+
await tick()
|
|
342
|
+
expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
|
|
343
|
+
expect(client.db.storeSnapshot.mock.calls[0][0].snapshot[page.id]).toEqual(page)
|
|
344
|
+
client.close()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('closing before load cancels a persist scheduled by an early edit', async () => {
|
|
348
|
+
const load = promiseWithResolve<Awaited<ReturnType<LocalIndexedDb['load']>>>()
|
|
349
|
+
const loadMock = vi.spyOn(LocalIndexedDb.prototype, 'load').mockImplementationOnce(() => load)
|
|
350
|
+
try {
|
|
351
|
+
const { client, tick } = testClient()
|
|
352
|
+
client.store.put([PageRecordType.create({ name: 'early', index: 'a0' as IndexKey })])
|
|
353
|
+
client.close()
|
|
354
|
+
load.resolve({ records: [], schema: undefined, sessionStateSnapshot: undefined })
|
|
355
|
+
await tick()
|
|
356
|
+
expect(client.db.storeSnapshot).not.toHaveBeenCalled()
|
|
357
|
+
expect(client.db.storeChanges).not.toHaveBeenCalled()
|
|
358
|
+
} finally {
|
|
359
|
+
loadMock.mockRestore()
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
test.each(['close flush', 'in-flight write'] as const)(
|
|
364
|
+
'a failed %s after close logs the error without disrupting the current page',
|
|
365
|
+
async (when) => {
|
|
366
|
+
const { client, tick } = testClient()
|
|
367
|
+
await tick()
|
|
368
|
+
const write = promiseWithResolve<void>()
|
|
369
|
+
client.db.storeSnapshot.mockImplementationOnce(() => write)
|
|
370
|
+
const error = new Error('write failed')
|
|
371
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
372
|
+
const alert = vi.spyOn(window, 'alert').mockImplementation(() => {})
|
|
373
|
+
try {
|
|
374
|
+
client.store.put([PageRecordType.create({ name: 'last edit', index: 'a0' as IndexKey })])
|
|
375
|
+
if (when === 'in-flight write') await tick()
|
|
376
|
+
client.close()
|
|
377
|
+
write.reject(error)
|
|
378
|
+
await tick()
|
|
379
|
+
expect(log).toHaveBeenCalledWith('failed to store changes in indexed db', error)
|
|
380
|
+
expect(alert).not.toHaveBeenCalled()
|
|
381
|
+
expect(reloadMock).not.toHaveBeenCalled()
|
|
382
|
+
} finally {
|
|
383
|
+
log.mockRestore()
|
|
384
|
+
alert.mockRestore()
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
test('closing with no queued changes cancels a failed-write retry', async () => {
|
|
390
|
+
const { client, tick } = testClient()
|
|
391
|
+
await tick()
|
|
392
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
393
|
+
const alert = vi.spyOn(window, 'alert').mockImplementation(() => {})
|
|
394
|
+
try {
|
|
395
|
+
client.db.storeSnapshot.mockRejectedValueOnce(new Error('first write failed'))
|
|
396
|
+
client.store.put([PageRecordType.create({ name: 'retry', index: 'a0' as IndexKey })])
|
|
397
|
+
await tick()
|
|
398
|
+
expect(reloadMock).toHaveBeenCalledTimes(1)
|
|
399
|
+
client.close()
|
|
400
|
+
await vi.advanceTimersByTimeAsync(10_000)
|
|
401
|
+
expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1)
|
|
402
|
+
} finally {
|
|
403
|
+
log.mockRestore()
|
|
404
|
+
alert.mockRestore()
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
test('a schema mismatch does not try to refresh without a window', async () => {
|
|
409
|
+
const { client, channel, tick } = testClient()
|
|
410
|
+
await tick()
|
|
411
|
+
vi.advanceTimersByTime(10_000)
|
|
412
|
+
vi.stubGlobal('window', undefined)
|
|
413
|
+
try {
|
|
414
|
+
expect(() =>
|
|
415
|
+
channel.onmessage?.({
|
|
416
|
+
data: {
|
|
417
|
+
type: 'announce',
|
|
418
|
+
schema: {
|
|
419
|
+
...client.serializedSchema,
|
|
420
|
+
schemaVersion: client.serializedSchema.schemaVersion + 1,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
} as MessageEvent)
|
|
424
|
+
).not.toThrow()
|
|
425
|
+
} finally {
|
|
426
|
+
vi.unstubAllGlobals()
|
|
427
|
+
client.close()
|
|
428
|
+
}
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
test('onLoad can close the client without losing buffered remote changes', async () => {
|
|
432
|
+
const { client, channel, onLoad, tick } = testClient()
|
|
433
|
+
const page = PageRecordType.create({ name: 'remote', index: 'a0' as IndexKey })
|
|
434
|
+
channel.onmessage?.({
|
|
435
|
+
data: {
|
|
436
|
+
type: 'diff',
|
|
437
|
+
storeId: 'other-tab',
|
|
438
|
+
schema: client.serializedSchema,
|
|
439
|
+
changes: { added: { [page.id]: page }, updated: {}, removed: {} },
|
|
440
|
+
},
|
|
441
|
+
} as MessageEvent)
|
|
442
|
+
onLoad.mockImplementation(() => {
|
|
443
|
+
client.store.put([PageRecordType.create({ name: 'local', index: 'a1' as IndexKey })])
|
|
444
|
+
client.close()
|
|
445
|
+
})
|
|
446
|
+
await tick()
|
|
447
|
+
expect(client.db.storeSnapshot.mock.calls[0][0].snapshot[page.id]).toEqual(page)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test('a buffered newer schema reports a load error without announcing a successful load', async () => {
|
|
451
|
+
const { client, channel, onLoad, onLoadError, tick } = testClient()
|
|
452
|
+
channel.onmessage?.({
|
|
453
|
+
data: {
|
|
454
|
+
type: 'announce',
|
|
455
|
+
schema: {
|
|
456
|
+
...client.serializedSchema,
|
|
457
|
+
schemaVersion: client.serializedSchema.schemaVersion + 1,
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
} as MessageEvent)
|
|
461
|
+
await tick()
|
|
462
|
+
expect(onLoadError).toHaveBeenCalledTimes(1)
|
|
463
|
+
expect(onLoad).not.toHaveBeenCalled()
|
|
464
|
+
client.close()
|
|
465
|
+
expect(client.db.storeSnapshot).not.toHaveBeenCalled()
|
|
466
|
+
})
|
|
@@ -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,55 +283,17 @@ 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)
|
|
277
298
|
} catch (e: any) {
|
|
278
299
|
this.debug('error loading data from store', e)
|
|
@@ -283,15 +304,43 @@ export class TLLocalSyncClient {
|
|
|
283
304
|
}
|
|
284
305
|
|
|
285
306
|
close() {
|
|
307
|
+
if (this.didDispose) return
|
|
286
308
|
this.debug('closing')
|
|
287
309
|
this.didDispose = true
|
|
310
|
+
// Store listeners normally run on the next frame; collect the last edits before removal.
|
|
311
|
+
this.store._flushHistory()
|
|
288
312
|
this.disposables.forEach((d) => d())
|
|
313
|
+
if (this.scheduledPersistTimeout) {
|
|
314
|
+
clearTimeout(this.scheduledPersistTimeout)
|
|
315
|
+
this.scheduledPersistTimeout = null
|
|
316
|
+
}
|
|
289
317
|
if (typeof window !== 'undefined' && (window as any).tlsync === this) {
|
|
290
318
|
delete (window as any).tlsync
|
|
291
319
|
}
|
|
320
|
+
this.flushAndCloseDb()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Flush queued changes so edits made in the last PERSIST_THROTTLE_MS before unmount aren't lost,
|
|
325
|
+
* then close the db. Never flushes before load (a full write then would wipe the db with an empty
|
|
326
|
+
* store), and only when something is queued: shouldDoFullDBWrite stays true until the first
|
|
327
|
+
* persist, so an unconditional flush would rewrite the whole document on every no-op close.
|
|
328
|
+
*/
|
|
329
|
+
private flushAndCloseDb() {
|
|
330
|
+
// A persist in flight can't be joined (persistIfNeeded bails while one is running) and,
|
|
331
|
+
// now that we're disposed, won't schedule a follow-up. Edits queued during that write
|
|
332
|
+
// would be lost if the db closed now, so wait for it and flush again.
|
|
333
|
+
if (this.persistPromise) {
|
|
334
|
+
this.persistPromise.then(() => this.flushAndCloseDb())
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
if (this.didLoad && this.diffQueue.length > 0) this.persistIfNeeded()
|
|
338
|
+
// the db waits for the transaction the flush just opened before actually closing
|
|
339
|
+
this.db.close()
|
|
292
340
|
}
|
|
293
341
|
|
|
294
342
|
private isPersisting = false
|
|
343
|
+
private persistPromise: Promise<void> | null = null
|
|
295
344
|
private didLastWriteError = false
|
|
296
345
|
// eslint-disable-next-line no-restricted-globals
|
|
297
346
|
private scheduledPersistTimeout: ReturnType<typeof setTimeout> | null = null
|
|
@@ -305,6 +354,8 @@ export class TLLocalSyncClient {
|
|
|
305
354
|
private schedulePersist() {
|
|
306
355
|
this.debug('schedulePersist', this.scheduledPersistTimeout)
|
|
307
356
|
if (this.scheduledPersistTimeout) return
|
|
357
|
+
// A deferred persist would try to write to a database that is closing or already closed.
|
|
358
|
+
if (this.didDispose) return
|
|
308
359
|
// eslint-disable-next-line no-restricted-globals
|
|
309
360
|
this.scheduledPersistTimeout = setTimeout(
|
|
310
361
|
() => {
|
|
@@ -339,6 +390,8 @@ export class TLLocalSyncClient {
|
|
|
339
390
|
this.scheduledPersistTimeout = null
|
|
340
391
|
}
|
|
341
392
|
|
|
393
|
+
if (!this.didLoad) return
|
|
394
|
+
|
|
342
395
|
// if a persist is already in progress, we don't need to do anything -
|
|
343
396
|
// if there are still outstanding changes once it's finished, it'll
|
|
344
397
|
// schedule another persist
|
|
@@ -353,7 +406,7 @@ export class TLLocalSyncClient {
|
|
|
353
406
|
|
|
354
407
|
// if we're scheduled for a full write or if we have changes outstanding, let's persist them!
|
|
355
408
|
if (this.shouldDoFullDBWrite || this.diffQueue.length > 0) {
|
|
356
|
-
this.doPersist()
|
|
409
|
+
this.persistPromise = this.doPersist()
|
|
357
410
|
}
|
|
358
411
|
}
|
|
359
412
|
|
|
@@ -363,7 +416,6 @@ export class TLLocalSyncClient {
|
|
|
363
416
|
*/
|
|
364
417
|
private async doPersist() {
|
|
365
418
|
assert(!this.isPersisting, 'persist already in progress')
|
|
366
|
-
if (this.didDispose) return
|
|
367
419
|
this.isPersisting = true
|
|
368
420
|
|
|
369
421
|
this.debug('doPersist start')
|
|
@@ -401,14 +453,14 @@ export class TLLocalSyncClient {
|
|
|
401
453
|
this.didLastWriteError = true
|
|
402
454
|
console.error('failed to store changes in indexed db', e)
|
|
403
455
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
window.location.reload()
|
|
456
|
+
if (!this.didDispose && typeof window !== 'undefined') {
|
|
457
|
+
showCantWriteToIndexDbAlert()
|
|
458
|
+
refreshPage()
|
|
408
459
|
}
|
|
409
460
|
}
|
|
410
461
|
|
|
411
462
|
this.isPersisting = false
|
|
463
|
+
this.persistPromise = null
|
|
412
464
|
this.debug('doPersist end')
|
|
413
465
|
|
|
414
466
|
// 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.
|
|
4
|
+
export const version = '5.5.0-canary.0241f11f2e99'
|
|
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-10T11:28:47.056Z',
|
|
8
|
+
patch: '2026-09-10T11:28:47.056Z',
|
|
9
9
|
}
|