@tldraw/editor 5.5.2 → 5.6.0-canary.035b741dc331

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 (35) hide show
  1. package/dist-cjs/index.js +1 -1
  2. package/dist-cjs/lib/editor/Editor.js +15 -4
  3. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  4. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +3 -5
  5. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  6. package/dist-cjs/lib/editor/shapes/shared/resizeBox.js +21 -16
  7. package/dist-cjs/lib/editor/shapes/shared/resizeBox.js.map +2 -2
  8. package/dist-cjs/lib/hooks/useCursor.js +12 -8
  9. package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
  10. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +12 -5
  11. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  12. package/dist-cjs/version.js +3 -3
  13. package/dist-cjs/version.js.map +1 -1
  14. package/dist-esm/index.mjs +1 -1
  15. package/dist-esm/lib/editor/Editor.mjs +17 -5
  16. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  17. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +3 -5
  18. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  19. package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs +21 -16
  20. package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs.map +2 -2
  21. package/dist-esm/lib/hooks/useCursor.mjs +12 -8
  22. package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
  23. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +13 -6
  24. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  25. package/dist-esm/version.mjs +3 -3
  26. package/dist-esm/version.mjs.map +1 -1
  27. package/package.json +17 -19
  28. package/src/lib/editor/Editor.ts +38 -9
  29. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +4 -6
  30. package/src/lib/editor/shapes/shared/resizeBox.ts +21 -16
  31. package/src/lib/hooks/useCursor.ts +14 -7
  32. package/src/lib/test/useCursor.test.tsx +36 -0
  33. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +73 -0
  34. package/src/lib/utils/sync/TLLocalSyncClient.ts +24 -8
  35. package/src/version.ts +3 -3
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/editor",
3
3
  "description": "tldraw infinite canvas SDK (editor).",
4
- "version": "5.5.2",
4
+ "version": "5.6.0-canary.035b741dc331",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -39,27 +39,16 @@
39
39
  "dist-cjs",
40
40
  "src"
41
41
  ],
42
- "scripts": {
43
- "test-ci": "yarn run -T vitest run --passWithNoTests",
44
- "test": "yarn run -T vitest --passWithNoTests",
45
- "test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
46
- "build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
47
- "build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
48
- "prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
49
- "postpack": "../../internal/scripts/postpack.sh",
50
- "pack-tarball": "yarn pack",
51
- "lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
52
- },
53
42
  "dependencies": {
54
43
  "@tiptap/core": "^3.12.1",
55
44
  "@tiptap/pm": "^3.12.1",
56
45
  "@tiptap/react": "^3.12.1",
57
- "@tldraw/state": "5.5.2",
58
- "@tldraw/state-react": "5.5.2",
59
- "@tldraw/store": "5.5.2",
60
- "@tldraw/tlschema": "5.5.2",
61
- "@tldraw/utils": "5.5.2",
62
- "@tldraw/validate": "5.5.2",
46
+ "@tldraw/state": "5.6.0-canary.035b741dc331",
47
+ "@tldraw/state-react": "5.6.0-canary.035b741dc331",
48
+ "@tldraw/store": "5.6.0-canary.035b741dc331",
49
+ "@tldraw/tlschema": "5.6.0-canary.035b741dc331",
50
+ "@tldraw/utils": "5.6.0-canary.035b741dc331",
51
+ "@tldraw/validate": "5.6.0-canary.035b741dc331",
63
52
  "classnames": "^2.5.1",
64
53
  "eventemitter3": "^4.0.7",
65
54
  "idb": "^7.1.1",
@@ -79,7 +68,6 @@
79
68
  "@types/react-dom": "^19.2.3",
80
69
  "@types/wicg-file-system-access": "^2020.9.8",
81
70
  "fake-indexeddb": "^4.0.2",
82
- "lazyrepo": "0.0.0-alpha.27",
83
71
  "react": "^19.2.1",
84
72
  "react-dom": "^19.2.1",
85
73
  "resize-observer-polyfill": "^1.5.1",
@@ -96,5 +84,15 @@
96
84
  "require": "./dist-cjs/index.js"
97
85
  },
98
86
  "./editor.css": "./editor.css"
87
+ },
88
+ "scripts": {
89
+ "test-ci": "pnpm exec vitest run --passWithNoTests",
90
+ "test": "pnpm exec vitest --passWithNoTests",
91
+ "test-coverage": "pnpm exec vitest run --coverage --passWithNoTests",
92
+ "build": "pnpm exec tsx ../../internal/scripts/build-package.ts",
93
+ "build-api": "pnpm exec tsx ../../internal/scripts/build-api.ts",
94
+ "pack-tarball": "pnpm pack",
95
+ "lint": "pnpm exec tsx ../../internal/scripts/lint.ts",
96
+ "build-types": "tsc --build"
99
97
  }
100
98
  }
@@ -137,6 +137,7 @@ import {
137
137
  areAnglesCompatible,
138
138
  clamp,
139
139
  pointInPolygon,
140
+ shortAngleDist,
140
141
  } from '../primitives/utils'
141
142
  import { Vec, VecLike } from '../primitives/Vec'
142
143
  import { areShapesContentEqual } from '../utils/areShapesContentEqual'
@@ -5122,7 +5123,11 @@ export class Editor extends EventEmitter<TLEventMap> {
5122
5123
  const freshPage = this.getPage(id) // get the most recent version of the page anyway
5123
5124
  if (!freshPage) return this
5124
5125
 
5125
- const prevCamera = { ...this.getCamera() }
5126
+ // Read the source page's own camera rather than getCamera(), which is the
5127
+ // current page's and is wrong when duplicating a page that isn't being viewed
5128
+ const prevCamera = {
5129
+ ...(this.store.get(CameraRecordType.createId(freshPage.id)) ?? this.getCamera()),
5130
+ }
5126
5131
  const content = this.getContentFromCurrentPage(this.getSortedChildIdsForParent(freshPage.id))
5127
5132
 
5128
5133
  this.run(() => {
@@ -5137,8 +5142,9 @@ export class Editor extends EventEmitter<TLEventMap> {
5137
5142
  this.setCamera(prevCamera)
5138
5143
 
5139
5144
  if (content) {
5140
- // If we had content on the previous page, put it on the new page
5141
- return this.putContentOntoCurrentPage(content)
5145
+ // Without preservePosition the paste rule recenters off-screen content
5146
+ // in the viewport, so the copy's layout would not match the original
5147
+ return this.putContentOntoCurrentPage(content, { preservePosition: true })
5142
5148
  }
5143
5149
  })
5144
5150
 
@@ -8313,6 +8319,9 @@ export class Editor extends EventEmitter<TLEventMap> {
8313
8319
  initialShape: TLShape
8314
8320
  isAspectRatioLocked: boolean
8315
8321
  initialPageTransform: MatLike
8322
+ dragHandle?: TLResizeHandle
8323
+ mode?: TLResizeMode
8324
+ skipStartAndEndCallbacks?: boolean
8316
8325
  }
8317
8326
  ) {
8318
8327
  const { type } = options.initialShape
@@ -8337,19 +8346,39 @@ export class Editor extends EventEmitter<TLEventMap> {
8337
8346
  initialBounds: options.initialBounds,
8338
8347
  isAspectRatioLocked: options.isAspectRatioLocked,
8339
8348
  initialPageTransform: options.initialPageTransform,
8349
+ dragHandle: options.dragHandle,
8350
+ mode: options.mode,
8351
+ // this is one frame of the same resize, not a new one, so don't fire start/end again
8352
+ skipStartAndEndCallbacks: options.skipStartAndEndCallbacks,
8340
8353
  })
8341
8354
 
8342
8355
  // then if the shape is flipped in one axis only, we need to apply an extra rotation
8343
8356
  // to make sure the shape is mirrored correctly
8344
8357
  if (Math.sign(scale.x) * Math.sign(scale.y) < 0) {
8345
- // We need to compute the new local rotation that will result in the negated page rotation.
8346
- // For a shape with local rotation `localRot` and parent page rotation `parentRot`:
8358
+ // Mirroring across an axis at angle `axisRot` maps a page rotation `pageRot` to
8359
+ // `2 * axisRot - pageRot`. For a shape with local rotation `localRot` whose parent had
8360
+ // page rotation `parentRot` when the resize began:
8347
8361
  // - pageRot = parentRot + localRot
8348
- // - newPageRot = -pageRot (we want to negate the page rotation)
8349
- // - newPageRot = parentRot + newLocalRot (parent hasn't changed)
8350
- // - Therefore: newLocalRot = -pageRot - parentRot = -(parentRot + localRot) - parentRot = -localRot - 2*parentRot
8362
+ // - newPageRot = 2 * axisRot - pageRot
8363
+ // - newPageRot = (parentRot + parentDelta) + newLocalRot
8364
+ // - Therefore: newLocalRot = 2 * axisRot - localRot - 2 * parentRot - parentDelta
8365
+ // `parentDelta` is how far the parent has rotated since the resize began. Nested resizes
8366
+ // commit ancestors before descendants, so an unaligned parent may itself have been
8367
+ // flipped by the time its child gets here; reading the parent's current rotation as if
8368
+ // it were the initial one would then double-count that flip.
8351
8369
  const parentRotation = this.getShapeParentTransform(id).rotation()
8352
- const rotation = -options.initialShape.rotation - 2 * parentRotation
8370
+ // take the shortest arc so the stored rotation doesn't drift by 2π when the parent
8371
+ // hasn't moved and the initial page rotation happens to be wrapped
8372
+ const parentDelta = shortAngleDist(
8373
+ Mat.Cast(options.initialPageTransform).rotation() - options.initialShape.rotation,
8374
+ parentRotation
8375
+ )
8376
+ const initialParentRotation = parentRotation - parentDelta
8377
+ const rotation =
8378
+ 2 * options.scaleAxisRotation -
8379
+ options.initialShape.rotation -
8380
+ 2 * initialParentRotation -
8381
+ parentDelta
8353
8382
  this.updateShapes([{ id, type, rotation }])
8354
8383
  }
8355
8384
 
@@ -444,13 +444,11 @@ export class ScribbleManager {
444
444
  }
445
445
  }
446
446
 
447
- // Remove completed items in individual fade mode
447
+ // Keep starting scribbles with no point yet, or addPoint can't find them (#7681).
448
448
  if (session.options.fadeMode === 'individual') {
449
- for (let i = session.items.length - 1; i >= 0; i--) {
450
- if (session.items[i].scribble.points.length === 0) {
451
- session.items.splice(i, 1)
452
- }
453
- }
449
+ session.items = session.items.filter(
450
+ ({ scribble }) => scribble.points.length > 0 || scribble.state === 'starting'
451
+ )
454
452
  }
455
453
  }
456
454
 
@@ -1,5 +1,6 @@
1
1
  import { VecModel } from '@tldraw/tlschema'
2
2
  import { Box } from '../../../primitives/Box'
3
+ import { clamp } from '../../../primitives/utils'
3
4
  import { Vec } from '../../../primitives/Vec'
4
5
  import { TLResizeHandle } from '../../types/selection-types'
5
6
  import type { TLBaseBoxShape } from '../BaseBoxShapeUtil'
@@ -36,29 +37,31 @@ export function resizeBox<T extends TLBaseBoxShape>(
36
37
  const offset = new Vec(0, 0)
37
38
 
38
39
  if (w > 0) {
39
- if (w < minWidth) {
40
+ const clampedW = clamp(w, minWidth, maxWidth)
41
+ if (clampedW !== w) {
40
42
  switch (handle) {
41
43
  case 'top_left':
42
44
  case 'left':
43
45
  case 'bottom_left': {
44
- offset.x = w - minWidth
46
+ offset.x = w - clampedW
45
47
  break
46
48
  }
47
49
  case 'top':
48
50
  case 'bottom': {
49
- offset.x = (w - minWidth) / 2
51
+ offset.x = (w - clampedW) / 2
50
52
  break
51
53
  }
52
54
  default: {
53
55
  offset.x = 0
54
56
  }
55
57
  }
56
- w = minWidth
58
+ w = clampedW
57
59
  }
58
60
  } else {
59
61
  offset.x = w
60
62
  w = -w
61
- if (w < minWidth) {
63
+ const clampedW = clamp(w, minWidth, maxWidth)
64
+ if (clampedW !== w) {
62
65
  switch (handle) {
63
66
  case 'top_left':
64
67
  case 'left':
@@ -67,26 +70,27 @@ export function resizeBox<T extends TLBaseBoxShape>(
67
70
  break
68
71
  }
69
72
  default: {
70
- offset.x = -minWidth
73
+ offset.x = -clampedW
71
74
  }
72
75
  }
73
76
 
74
- w = minWidth
77
+ w = clampedW
75
78
  }
76
79
  }
77
80
 
78
81
  if (h > 0) {
79
- if (h < minHeight) {
82
+ const clampedH = clamp(h, minHeight, maxHeight)
83
+ if (clampedH !== h) {
80
84
  switch (handle) {
81
85
  case 'top_left':
82
86
  case 'top':
83
87
  case 'top_right': {
84
- offset.y = h - minHeight
88
+ offset.y = h - clampedH
85
89
  break
86
90
  }
87
91
  case 'right':
88
92
  case 'left': {
89
- offset.y = (h - minHeight) / 2
93
+ offset.y = (h - clampedH) / 2
90
94
  break
91
95
  }
92
96
  default: {
@@ -94,12 +98,13 @@ export function resizeBox<T extends TLBaseBoxShape>(
94
98
  }
95
99
  }
96
100
 
97
- h = minHeight
101
+ h = clampedH
98
102
  }
99
103
  } else {
100
104
  offset.y = h
101
105
  h = -h
102
- if (h < minHeight) {
106
+ const clampedH = clamp(h, minHeight, maxHeight)
107
+ if (clampedH !== h) {
103
108
  switch (handle) {
104
109
  case 'top_left':
105
110
  case 'top':
@@ -108,10 +113,10 @@ export function resizeBox<T extends TLBaseBoxShape>(
108
113
  break
109
114
  }
110
115
  default: {
111
- offset.y = -minHeight
116
+ offset.y = -clampedH
112
117
  }
113
118
  }
114
- h = minHeight
119
+ h = clampedH
115
120
  }
116
121
  }
117
122
 
@@ -122,8 +127,8 @@ export function resizeBox<T extends TLBaseBoxShape>(
122
127
  x,
123
128
  y,
124
129
  props: {
125
- w: Math.min(maxWidth, w),
126
- h: Math.min(maxHeight, h),
130
+ w,
131
+ h,
127
132
  },
128
133
  }
129
134
  }
@@ -23,8 +23,9 @@ function getCursorCss(
23
23
  const dx = 1 * c - 1 * s
24
24
  const dy = 1 * s + 1 * c
25
25
 
26
+ // A raw '#' in the colour (hex) would end the unencoded data url as a fragment.
26
27
  return (
27
- `url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: ${color};'><defs><filter id='shadow' y='-40%' x='-40%' width='180px' height='180%' color-interpolation-filters='sRGB'><feDropShadow dx='${dx}' dy='${dy}' stdDeviation='1.2' flood-opacity='.5'/></filter></defs><g fill='none' transform='rotate(${
28
+ `url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: ${encodeURIComponent(color)};'><defs><filter id='shadow' y='-40%' x='-40%' width='180px' height='180%' color-interpolation-filters='sRGB'><feDropShadow dx='${dx}' dy='${dy}' stdDeviation='1.2' flood-opacity='.5'/></filter></defs><g fill='none' transform='rotate(${
28
29
  r + tr
29
30
  } 16 16)${f ? ` scale(-1,-1) translate(0, -32)` : ''}' filter='url(%23shadow)'>` +
30
31
  svg.replaceAll(`"`, `'`) +
@@ -32,7 +33,8 @@ function getCursorCss(
32
33
  )
33
34
  }
34
35
 
35
- const STATIC_CURSORS = [
36
+ // Cursors that map straight to a prerendered `--tl-cursor-*` css variable rather than a runtime svg.
37
+ const STATIC_CURSORS = new Set<TLCursorType>([
36
38
  'default',
37
39
  'pointer',
38
40
  'cross',
@@ -43,11 +45,14 @@ const STATIC_CURSORS = [
43
45
  'text',
44
46
  'zoom-in',
45
47
  'zoom-out',
46
- ]
48
+ 'rotate',
49
+ 'resize-edge',
50
+ 'resize-corner',
51
+ ])
47
52
 
48
53
  type CursorFunction = (rotation: number, flip: boolean, color: string) => string
49
54
 
50
- const CURSORS: Record<TLCursorType, CursorFunction> = {
55
+ const DYNAMIC_CURSORS: Record<TLCursorType, CursorFunction> = {
51
56
  none: () => 'none',
52
57
  'ew-resize': (r, f, c) => getCursorCss(EDGE_SVG, r, 0, f, c),
53
58
  'ns-resize': (r, f, c) => getCursorCss(EDGE_SVG, r, 90, f, c),
@@ -61,7 +66,8 @@ const CURSORS: Record<TLCursorType, CursorFunction> = {
61
66
 
62
67
  /** @public */
63
68
  export function getCursor(cursor: TLCursorType, rotation = 0, color = 'black') {
64
- return CURSORS[cursor](radiansToDegrees(rotation), false, color)
69
+ if (STATIC_CURSORS.has(cursor)) return `var(--tl-cursor-${cursor})`
70
+ return DYNAMIC_CURSORS[cursor](radiansToDegrees(rotation), false, color)
65
71
  }
66
72
 
67
73
  export function useCursor() {
@@ -73,8 +79,9 @@ export function useCursor() {
73
79
  () => {
74
80
  const { type, rotation } = editor.getInstanceState().cursor
75
81
 
76
- if (STATIC_CURSORS.includes(type)) {
77
- container.style.setProperty('--tl-cursor', `var(--tl-cursor-${type})`)
82
+ // Static cursors don't need the theme colour, so skip reading it to avoid depending on it.
83
+ if (STATIC_CURSORS.has(type)) {
84
+ container.style.setProperty('--tl-cursor', getCursor(type))
78
85
  return
79
86
  }
80
87
 
@@ -0,0 +1,36 @@
1
+ import { act, render } from '@testing-library/react'
2
+ import { createTLStore } from '../config/createTLStore'
3
+ import { Editor } from '../editor/Editor'
4
+ import { getCursor } from '../hooks/useCursor'
5
+ import { TL_CONTAINER_CLASS, TldrawEditor } from '../TldrawEditor'
6
+
7
+ describe('useCursor', () => {
8
+ it('maps the static schema cursor types to their css variables', async () => {
9
+ let editor: Editor
10
+ const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
11
+ await act(async () => {
12
+ render(<TldrawEditor store={store} onMount={(e) => void (editor = e)} />)
13
+ })
14
+ const container = document.querySelector(`.${TL_CONTAINER_CLASS}`) as HTMLElement
15
+
16
+ for (const type of ['rotate', 'resize-edge', 'resize-corner', 'default'] as const) {
17
+ act(() => editor.setCursor({ type, rotation: 0 }))
18
+ expect(container.style.getPropertyValue('--tl-cursor')).toBe(`var(--tl-cursor-${type})`)
19
+ }
20
+ })
21
+ })
22
+
23
+ describe('getCursor', () => {
24
+ it('returns the css variable for the static cursor types', () => {
25
+ for (const type of ['rotate', 'resize-edge', 'resize-corner', 'default'] as const) {
26
+ expect(getCursor(type)).toBe(`var(--tl-cursor-${type})`)
27
+ expect(getCursor(type, Math.PI / 2, '#000000')).toBe(`var(--tl-cursor-${type})`)
28
+ }
29
+ })
30
+
31
+ it('encodes the colour so a hex value does not end the data url as a fragment', () => {
32
+ const css = getCursor('ew-resize', 0, '#000000')
33
+ expect(css).toContain("style='color: %23000000;'")
34
+ expect(css).not.toContain('#')
35
+ })
36
+ })
@@ -484,3 +484,76 @@ test('a buffered newer schema reports a load error without announcing a successf
484
484
  client.close()
485
485
  expect(client.db.storeSnapshot).not.toHaveBeenCalled()
486
486
  })
487
+
488
+ test('a client created on the same key while a closed client is still flushing waits for that flush', async () => {
489
+ const inFlightWrite = promiseWithResolve<void>()
490
+ const { client, tick } = testClient()
491
+ client.db.storeSnapshot.mockImplementationOnce(() => inFlightWrite)
492
+ await tick()
493
+ client.store.put([PageRecordType.create({ name: 'first', index: 'a0' as IndexKey })])
494
+ await tick()
495
+ expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1) // still in flight
496
+ client.store.put([PageRecordType.create({ name: 'during write', index: 'a1' as IndexKey })])
497
+ client.close()
498
+
499
+ // remount on the same key while the old client is still flushing
500
+ const loadSpy = vi.spyOn(LocalIndexedDb.prototype, 'load')
501
+ const next = testClient()
502
+ await tick()
503
+ // reading now would miss the queued edit, then erase it on the first write
504
+ expect(loadSpy).not.toHaveBeenCalled()
505
+ expect(next.onLoad).not.toHaveBeenCalled()
506
+
507
+ inFlightWrite.resolve()
508
+ await tick()
509
+ expect(client.db.storeChanges).toHaveBeenCalledTimes(1)
510
+ for (let i = 0; i < 20; i++) await Promise.resolve()
511
+ await next.tick()
512
+ expect(loadSpy).toHaveBeenCalledTimes(1)
513
+ expect(next.onLoad).toHaveBeenCalledTimes(1)
514
+ loadSpy.mockRestore()
515
+ })
516
+
517
+ test('two clients closing on the same key flush in order', async () => {
518
+ const writes: string[] = []
519
+ const inFlightWrite = promiseWithResolve<void>()
520
+
521
+ const a = testClient()
522
+ const b = testClient()
523
+ await a.tick()
524
+ await b.tick()
525
+ // hold a's first write in flight
526
+ a.client.db.storeSnapshot.mockImplementation(() => {
527
+ writes.push('a')
528
+ return inFlightWrite
529
+ })
530
+ a.client.db.storeChanges.mockImplementation(() => {
531
+ writes.push('a')
532
+ return Promise.resolve()
533
+ })
534
+ b.client.db.storeSnapshot.mockImplementation(() => {
535
+ writes.push('b')
536
+ return Promise.resolve()
537
+ })
538
+ b.client.db.storeChanges.mockImplementation(() => {
539
+ writes.push('b')
540
+ return Promise.resolve()
541
+ })
542
+
543
+ a.client.store.put([PageRecordType.create({ name: 'a1', index: 'a0' as IndexKey })])
544
+ await a.tick()
545
+ expect(writes).toEqual(['a']) // in flight
546
+ a.client.store.put([PageRecordType.create({ name: 'a2', index: 'a1' as IndexKey })])
547
+ a.client.close()
548
+
549
+ b.client.store.put([PageRecordType.create({ name: 'b1', index: 'a2' as IndexKey })])
550
+ b.client.close()
551
+ await b.tick()
552
+ // b's snapshot landing first would lose a's queued changes
553
+ expect(writes).toEqual(['a'])
554
+
555
+ inFlightWrite.resolve()
556
+ await a.tick()
557
+ await b.tick()
558
+ expect(writes).toEqual(['a', 'a', 'b'])
559
+ })
@@ -1,7 +1,7 @@
1
1
  import { Signal, transact } from '@tldraw/state'
2
2
  import { RecordsDiff, SerializedSchema, UnknownRecord, squashRecordDiffs } from '@tldraw/store'
3
3
  import { TLStore } from '@tldraw/tlschema'
4
- import { assert } from '@tldraw/utils'
4
+ import { assert, noop } from '@tldraw/utils'
5
5
  import {
6
6
  TAB_ID,
7
7
  TLSessionStateSnapshot,
@@ -64,6 +64,9 @@ export class BroadcastChannelMock {
64
64
 
65
65
  const BC = typeof BroadcastChannel === 'undefined' ? BroadcastChannelMock : BroadcastChannel
66
66
 
67
+ // Flushes still running for closed clients, by key. See connect() and close().
68
+ const pendingFlushes = new Map<string, Promise<void>>()
69
+
67
70
  /** @internal */
68
71
  export class TLLocalSyncClient {
69
72
  private disposables = new Set<() => void>()
@@ -238,6 +241,11 @@ export class TLLocalSyncClient {
238
241
  this.channel.close()
239
242
  })
240
243
 
244
+ // our first write is a full snapshot, so it would erase whatever a client that just closed
245
+ // on this key is still flushing
246
+ await pendingFlushes.get(this.persistenceKey)
247
+ if (this.didDispose) return
248
+
241
249
  try {
242
250
  data = await this.db.load({ sessionId: this.sessionId })
243
251
  } catch (error: any) {
@@ -313,8 +321,6 @@ export class TLLocalSyncClient {
313
321
  if (this.didDispose) return
314
322
  this.debug('closing')
315
323
  this.didDispose = true
316
- // Store listeners normally run on the next frame; collect the last edits before removal.
317
- this.store._flushHistory()
318
324
  this.disposables.forEach((d) => d())
319
325
  if (this.scheduledPersistTimeout) {
320
326
  clearTimeout(this.scheduledPersistTimeout)
@@ -323,7 +329,18 @@ export class TLLocalSyncClient {
323
329
  if (typeof window !== 'undefined' && (window as any).tlsync === this) {
324
330
  delete (window as any).tlsync
325
331
  }
326
- this.flushAndCloseDb()
332
+ // flush now when the key is idle: close() runs during teardown, where a deferred write may
333
+ // never run. wait when it isn't, or our snapshot lands first and gets overwritten.
334
+ // connect() awaits this, so it must never reject
335
+ const previous = pendingFlushes.get(this.persistenceKey)
336
+ const flush = (previous ? previous.then(() => this.flushAndCloseDb()) : this.flushAndCloseDb())
337
+ .catch(noop)
338
+ .then(() => {
339
+ if (pendingFlushes.get(this.persistenceKey) === flush) {
340
+ pendingFlushes.delete(this.persistenceKey)
341
+ }
342
+ })
343
+ pendingFlushes.set(this.persistenceKey, flush)
327
344
  }
328
345
 
329
346
  /**
@@ -332,17 +349,16 @@ export class TLLocalSyncClient {
332
349
  * store), and only when something is queued: shouldDoFullDBWrite stays true until the first
333
350
  * persist, so an unconditional flush would rewrite the whole document on every no-op close.
334
351
  */
335
- private flushAndCloseDb() {
352
+ private flushAndCloseDb(): Promise<void> {
336
353
  // A persist in flight can't be joined (persistIfNeeded bails while one is running) and,
337
354
  // now that we're disposed, won't schedule a follow-up. Edits queued during that write
338
355
  // would be lost if the db closed now, so wait for it and flush again.
339
356
  if (this.persistPromise) {
340
- this.persistPromise.then(() => this.flushAndCloseDb())
341
- return
357
+ return this.persistPromise.then(() => this.flushAndCloseDb())
342
358
  }
343
359
  if (this.didLoad && this.diffQueue.length > 0) this.persistIfNeeded()
344
360
  // the db waits for the transaction the flush just opened before actually closing
345
- this.db.close()
361
+ return this.db.close()
346
362
  }
347
363
 
348
364
  private isPersisting = false
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.2'
4
+ export const version = '5.6.0-canary.035b741dc331'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-09-30T10:03:27.724Z',
8
- patch: '2026-10-02T13:35:40.683Z',
7
+ minor: '2026-10-06T06:00:33.486Z',
8
+ patch: '2026-10-06T06:00:33.486Z',
9
9
  }