@tldraw/editor 5.5.2 → 5.6.0-canary.0091c91994d6

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 (107) hide show
  1. package/dist-cjs/index.d.ts +167 -2
  2. package/dist-cjs/index.js +7 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +18 -5
  5. package/dist-cjs/lib/TldrawEditor.js.map +3 -3
  6. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +57 -15
  7. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
  8. package/dist-cjs/lib/config/TLI18n.js +64 -0
  9. package/dist-cjs/lib/config/TLI18n.js.map +7 -0
  10. package/dist-cjs/lib/editor/Editor.js +44 -16
  11. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  12. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +10 -3
  13. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
  14. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +1 -1
  15. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  16. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +19 -2
  17. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  18. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +3 -5
  19. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  20. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +7 -1
  21. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  22. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +28 -0
  23. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  24. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +3 -0
  25. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +2 -2
  26. package/dist-cjs/lib/editor/shapes/shared/resizeBox.js +21 -16
  27. package/dist-cjs/lib/editor/shapes/shared/resizeBox.js.map +2 -2
  28. package/dist-cjs/lib/hooks/useCursor.js +12 -8
  29. package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
  30. package/dist-cjs/lib/hooks/useDocumentEvents.js +28 -3
  31. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  32. package/dist-cjs/lib/license/LicenseManager.js +64 -1
  33. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  34. package/dist-cjs/lib/options.js +1 -1
  35. package/dist-cjs/lib/options.js.map +1 -1
  36. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +12 -5
  37. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  38. package/dist-cjs/version.js +3 -3
  39. package/dist-cjs/version.js.map +1 -1
  40. package/dist-esm/index.d.mts +167 -2
  41. package/dist-esm/index.mjs +13 -1
  42. package/dist-esm/index.mjs.map +2 -2
  43. package/dist-esm/lib/TldrawEditor.mjs +18 -5
  44. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  45. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +61 -16
  46. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
  47. package/dist-esm/lib/config/TLI18n.mjs +44 -0
  48. package/dist-esm/lib/config/TLI18n.mjs.map +7 -0
  49. package/dist-esm/lib/editor/Editor.mjs +46 -17
  50. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  51. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +10 -3
  52. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
  53. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +1 -1
  54. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  55. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +20 -3
  56. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  57. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +3 -5
  58. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  59. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +7 -1
  60. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  61. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +28 -0
  62. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  63. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +3 -0
  64. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +2 -2
  65. package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs +21 -16
  66. package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs.map +2 -2
  67. package/dist-esm/lib/hooks/useCursor.mjs +12 -8
  68. package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
  69. package/dist-esm/lib/hooks/useDocumentEvents.mjs +28 -3
  70. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  71. package/dist-esm/lib/license/LicenseManager.mjs +64 -1
  72. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  73. package/dist-esm/lib/options.mjs +1 -1
  74. package/dist-esm/lib/options.mjs.map +1 -1
  75. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +13 -6
  76. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  77. package/dist-esm/version.mjs +3 -3
  78. package/dist-esm/version.mjs.map +1 -1
  79. package/editor.css +1 -1
  80. package/package.json +17 -19
  81. package/src/index.ts +14 -2
  82. package/src/lib/TldrawEditor.tsx +44 -4
  83. package/src/lib/components/default-components/DefaultErrorFallback.tsx +66 -18
  84. package/src/lib/config/TLI18n.test.ts +84 -0
  85. package/src/lib/config/TLI18n.ts +172 -0
  86. package/src/lib/editor/Editor.ts +90 -33
  87. package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +19 -3
  88. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +17 -0
  89. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +2 -2
  90. package/src/lib/editor/managers/FontManager/FontManager.test.ts +1 -0
  91. package/src/lib/editor/managers/FontManager/FontManager.ts +26 -3
  92. package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +32 -0
  93. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +4 -6
  94. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +16 -7
  95. package/src/lib/editor/shapes/ShapeUtil.ts +30 -0
  96. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +4 -0
  97. package/src/lib/editor/shapes/shared/resizeBox.ts +21 -16
  98. package/src/lib/hooks/useCursor.ts +14 -7
  99. package/src/lib/hooks/useDocumentEvents.ts +51 -3
  100. package/src/lib/license/LicenseManager.test.ts +220 -0
  101. package/src/lib/license/LicenseManager.ts +89 -1
  102. package/src/lib/options.ts +1 -1
  103. package/src/lib/test/useCursor.test.tsx +36 -0
  104. package/src/lib/test/useDocumentEvents.test.tsx +153 -22
  105. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +73 -0
  106. package/src/lib/utils/sync/TLLocalSyncClient.ts +24 -8
  107. package/src/version.ts +3 -3
@@ -29,6 +29,7 @@ export const FLAGS = {
29
29
  FEAT_COLLABORATION: 1 << 6,
30
30
  // Commenting is the first sub-feature of collaboration.
31
31
  FEAT_COMMENTING: 1 << 7,
32
+ TRACKED: 1 << 8,
32
33
  }
33
34
  const HIGHEST_FLAG = Math.max(...Object.values(FLAGS))
34
35
 
@@ -42,6 +43,31 @@ const NUMBER_OF_KNOWN_PROPERTIES = Object.keys(PROPERTIES).length
42
43
 
43
44
  const LICENSE_EMAIL = 'sales@tldraw.com'
44
45
 
46
+ /**
47
+ * tldraw's own public domains. A page served from one of these hosts — or any subdomain of one — is
48
+ * licensed without a key, because we own them: minting and rotating keys for our own properties was
49
+ * a recurring source of expired and wrong-domain deployments.
50
+ *
51
+ * This list ships in the bundle, so it holds only domains we already serve from publicly. tldraw's
52
+ * private domains are deliberately absent and keep using a real license key.
53
+ */
54
+ const CANONICAL_TLDRAW_HOSTS = [
55
+ 'tldraw.com',
56
+ 'tldraw.dev',
57
+ 'tldraw.club',
58
+ 'tldraw.workers.dev',
59
+ 'tldraw.xyz',
60
+ ]
61
+
62
+ /** Our VS Code extension, whose webviews are served from `vscode-webview:` with a random hostname. */
63
+ const CANONICAL_VSCODE_EXTENSION_ID = 'tldraw-org.tldraw-vscode'
64
+
65
+ /** Marks a license as the keyless canonical-host grant, which is never tracked. */
66
+ const CANONICAL_HOST_LICENSE_ID = 'tldraw-canonical-host'
67
+
68
+ /** The canonical-host grant has no key behind it, and so no expiry date to fall due. */
69
+ const CANONICAL_HOST_NEVER_EXPIRES = new Date(8640000000000000)
70
+
45
71
  const WATERMARK_TRACK_SRC = `${getDefaultCdnBaseUrl()}/watermarks/watermark-track.svg`
46
72
 
47
73
  /** @internal */
@@ -114,7 +140,7 @@ export interface ValidLicenseKeyResult {
114
140
  }
115
141
 
116
142
  /** @internal */
117
- export type TrackType = 'unlicensed' | 'with_watermark' | 'evaluation' | null
143
+ export type TrackType = 'unlicensed' | 'with_watermark' | 'evaluation' | 'tracked' | null
118
144
 
119
145
  /** @internal */
120
146
  export class LicenseManager {
@@ -214,7 +240,59 @@ export class LicenseManager {
214
240
  return host === 'localhost' || host === '::1' || /^127(?:\.\d{1,3}){3}$/.test(host)
215
241
  }
216
242
 
243
+ /** Whether the page is served from one of tldraw's own domains, a subdomain of one, or our VS Code extension. */
244
+ private isCanonicalTldrawHost() {
245
+ if (window.location.protocol === 'vscode-webview:') {
246
+ const extensionId = new URL(window.location.href).searchParams.get('extensionId')
247
+ return extensionId === CANONICAL_VSCODE_EXTENSION_ID
248
+ }
249
+ const hostname = window.location.hostname.toLowerCase()
250
+ return CANONICAL_TLDRAW_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`))
251
+ }
252
+
253
+ /**
254
+ * The license tldraw's own domains get without a key. It keeps the watermark — our properties
255
+ * advertise the SDK the same way any other watermarked deployment does — and grants the
256
+ * collaboration features our apps are built on.
257
+ *
258
+ * Every expiry-derived field is a literal rather than something computed from a date, because
259
+ * there is no key here to expire: the grant lasts as long as the host is ours.
260
+ */
261
+ private getCanonicalHostLicense(): ValidLicenseKeyResult {
262
+ return {
263
+ isLicenseParseable: true,
264
+ license: {
265
+ id: CANONICAL_HOST_LICENSE_ID,
266
+ hosts: [window.location.hostname.toLowerCase()],
267
+ flags: FLAGS.WITH_WATERMARK | FLAGS.FEAT_COLLABORATION | FLAGS.FEAT_COMMENTING,
268
+ expiryDate: CANONICAL_HOST_NEVER_EXPIRES.toISOString(),
269
+ },
270
+ isDevelopment: this.isDevelopment,
271
+ isDomainValid: true,
272
+ expiryDate: CANONICAL_HOST_NEVER_EXPIRES,
273
+ isAnnualLicense: false,
274
+ isAnnualLicenseExpired: false,
275
+ isPerpetualLicense: false,
276
+ isPerpetualLicenseExpired: false,
277
+ isInternalLicense: false,
278
+ isNativeLicense: false,
279
+ isLicensedWithWatermark: true,
280
+ isEvaluationLicense: false,
281
+ isEvaluationLicenseExpired: false,
282
+ isCollaborationEnabled: true,
283
+ isCommentingEnabled: true,
284
+ daysSinceExpiry: 0,
285
+ }
286
+ }
287
+
217
288
  private getTrackType(result: LicenseFromKeyResult, licenseState: LicenseState): TrackType {
289
+ // Our own domains are licensed without a key, and the watermark ping exists to tell us about
290
+ // deployments we don't already know about. Reporting our own apps back to ourselves would only
291
+ // add noise to the numbers we read, so the canonical-host grant is never tracked.
292
+ if ('license' in result && result.license.id === CANONICAL_HOST_LICENSE_ID) {
293
+ return null
294
+ }
295
+
218
296
  // Track watermark for unlicensed production deployments
219
297
  if (licenseState === 'unlicensed-production') {
220
298
  return 'unlicensed'
@@ -233,6 +311,10 @@ export class LicenseManager {
233
311
  return 'evaluation'
234
312
  }
235
313
 
314
+ if (this.isFlagEnabled(result.license.flags, FLAGS.TRACKED)) {
315
+ return 'tracked'
316
+ }
317
+
236
318
  // Track licenses that show watermarks
237
319
  if (licenseState === 'licensed-with-watermark') {
238
320
  return 'with_watermark'
@@ -323,6 +405,12 @@ export class LicenseManager {
323
405
  }
324
406
 
325
407
  async getLicenseFromKey(licenseKey?: string): Promise<LicenseFromKeyResult> {
408
+ // A key that is provided still wins, so an app on one of our hosts can opt into a different
409
+ // license — a native or feature-specific key — just by passing it.
410
+ if (!licenseKey && this.isCanonicalTldrawHost()) {
411
+ return this.getCanonicalHostLicense()
412
+ }
413
+
326
414
  if (!licenseKey) {
327
415
  if (!this.isDevelopment) {
328
416
  this.outputNoLicenseKeyProvided()
@@ -313,7 +313,7 @@ export const defaultTldrawOptions = {
313
313
  collaboratorIdleTimeoutMs: 3000,
314
314
  collaboratorCheckIntervalMs: 1200,
315
315
  cameraMovingTimeoutMs: 64,
316
- hitTestMargin: 3,
316
+ hitTestMargin: 4.5,
317
317
  coarseHitTestMargin: 4,
318
318
  edgeScrollDelay: 200,
319
319
  edgeScrollEaseDuration: 200,
@@ -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
+ })
@@ -5,6 +5,34 @@ import { Editor } from '../editor/Editor'
5
5
  import { TLKeyboardEventInfo } from '../editor/types/event-types'
6
6
  import { TL_CONTAINER_CLASS, TldrawEditor } from '../TldrawEditor'
7
7
 
8
+ async function renderEditor() {
9
+ let editor!: Editor
10
+ const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
11
+ await act(async () => {
12
+ render(<TldrawEditor store={store} autoFocus onMount={(e) => void (editor = e)} />)
13
+ })
14
+ const container = document.querySelector<HTMLElement>(`.${TL_CONTAINER_CLASS}`)!
15
+ return { editor, container }
16
+ }
17
+
18
+ function keyDown(container: HTMLElement, init: KeyboardEventInit) {
19
+ act(() => {
20
+ container.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init }))
21
+ })
22
+ }
23
+
24
+ function keyUp(container: HTMLElement, init: KeyboardEventInit) {
25
+ act(() => {
26
+ container.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, ...init }))
27
+ })
28
+ }
29
+
30
+ function blurWindow() {
31
+ act(() => {
32
+ window.dispatchEvent(new Event('blur'))
33
+ })
34
+ }
35
+
8
36
  describe('useDocumentEvents drop handling', () => {
9
37
  // The container's native drop listener used to stop propagation before the event reached
10
38
  // React's root, so React onDrop handlers inside the canvas never fired.
@@ -33,28 +61,6 @@ describe('useDocumentEvents drop handling', () => {
33
61
  })
34
62
 
35
63
  describe('useDocumentEvents window blur', () => {
36
- async function renderEditor() {
37
- let editor!: Editor
38
- const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
39
- await act(async () => {
40
- render(<TldrawEditor store={store} autoFocus onMount={(e) => void (editor = e)} />)
41
- })
42
- const container = document.querySelector<HTMLElement>(`.${TL_CONTAINER_CLASS}`)!
43
- return { editor, container }
44
- }
45
-
46
- function keyDown(container: HTMLElement, init: KeyboardEventInit) {
47
- act(() => {
48
- container.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init }))
49
- })
50
- }
51
-
52
- function blurWindow() {
53
- act(() => {
54
- window.dispatchEvent(new Event('blur'))
55
- })
56
- }
57
-
58
64
  afterEach(() => {
59
65
  cleanup()
60
66
  })
@@ -105,3 +111,128 @@ describe('useDocumentEvents window blur', () => {
105
111
  expect(onEvent).not.toHaveBeenCalled()
106
112
  })
107
113
  })
114
+
115
+ // Browsers report `metaKey` as still true on the keyup of Meta itself.
116
+ const metaKeyUp: KeyboardEventInit = { key: 'Meta', code: 'MetaLeft', metaKey: true }
117
+
118
+ describe('useDocumentEvents meta release', () => {
119
+ afterEach(() => {
120
+ cleanup()
121
+ })
122
+
123
+ // macOS never delivers the keyup of a non-modifier key pressed while Meta is held, so the
124
+ // arrow from a Cmd+Arrow shortcut stayed held and the next plain arrow nudged diagonally.
125
+ it('releases keys whose keyup macOS swallowed while Meta was held', async () => {
126
+ const { editor, container } = await renderEditor()
127
+
128
+ keyDown(container, { key: 'Meta', code: 'MetaLeft', metaKey: true })
129
+ keyDown(container, { key: 'ArrowUp', code: 'ArrowUp', metaKey: true })
130
+ expect([...editor.inputs.keys]).toEqual(['MetaLeft', 'ArrowUp'])
131
+
132
+ // no keyup for ArrowUp ever arrives
133
+ keyUp(container, metaKeyUp)
134
+ expect([...editor.inputs.keys]).toEqual([])
135
+ })
136
+
137
+ // A key held from before Meta went down still gets its own keyup, so releasing it here
138
+ // would drop a key the user is still holding.
139
+ it('leaves keys held from before Meta went down', async () => {
140
+ const { editor, container } = await renderEditor()
141
+
142
+ keyDown(container, { key: ' ', code: 'Space' })
143
+ keyDown(container, { key: 'Meta', code: 'MetaLeft', metaKey: true })
144
+ keyUp(container, metaKeyUp)
145
+
146
+ expect([...editor.inputs.keys]).toEqual(['Space'])
147
+ })
148
+
149
+ // That key keeps auto-repeating, and once Cmd is down those repeats carry `metaKey`.
150
+ // Counting them would release a key that is still physically down - and a repeat never
151
+ // puts it back, since the editor treats key_repeat as a noop.
152
+ it('leaves a key that was already repeating when Meta went down', async () => {
153
+ const { editor, container } = await renderEditor()
154
+
155
+ keyDown(container, { key: 'ArrowUp', code: 'ArrowUp' })
156
+ keyDown(container, { key: 'Meta', code: 'MetaLeft', metaKey: true })
157
+ keyDown(container, { key: 'ArrowUp', code: 'ArrowUp', metaKey: true, repeat: true })
158
+ keyUp(container, metaKeyUp)
159
+
160
+ expect([...editor.inputs.keys]).toEqual(['ArrowUp'])
161
+ })
162
+
163
+ // Idle.onKeyUp re-enters shape editing on Enter, so replaying a key_up here stole the
164
+ // focus that cmd+Enter's a11y action had just put on the style toolbar.
165
+ it('does not fire tool key_up handlers for the keys it releases', async () => {
166
+ const { editor, container } = await renderEditor()
167
+ const keyUps: TLKeyboardEventInfo[] = []
168
+ editor.on('event', (info) => {
169
+ if (info.type === 'keyboard' && info.name === 'key_up') keyUps.push(info)
170
+ })
171
+
172
+ keyDown(container, { key: 'Meta', code: 'MetaLeft', metaKey: true })
173
+ keyDown(container, { key: 'Enter', code: 'Enter', metaKey: true })
174
+ keyUp(container, metaKeyUp)
175
+
176
+ expect([...editor.inputs.keys]).toEqual([])
177
+ expect(keyUps.map((info) => info.code)).toEqual(['MetaLeft'])
178
+ })
179
+
180
+ // A still-held modifier keeps its own keyup, and reporting it as up would start the
181
+ // editor's 150ms release: `ShiftLeft` would leave `inputs.keys` and shrink the nudge step.
182
+ it('leaves a still-held modifier alone', async () => {
183
+ const { editor, container } = await renderEditor()
184
+
185
+ keyDown(container, { key: 'Shift', code: 'ShiftLeft', shiftKey: true })
186
+ keyDown(container, { key: 'Meta', code: 'MetaLeft', shiftKey: true, metaKey: true })
187
+ keyDown(container, { key: 'ArrowUp', code: 'ArrowUp', shiftKey: true, metaKey: true })
188
+
189
+ keyUp(container, { ...metaKeyUp, shiftKey: true })
190
+ expect([...editor.inputs.keys]).toEqual(['ShiftLeft'])
191
+
192
+ await act(async () => {
193
+ await new Promise((resolve) => setTimeout(resolve, 200))
194
+ })
195
+ expect([...editor.inputs.keys]).toEqual(['ShiftLeft'])
196
+ expect(editor.inputs.getShiftKey()).toBe(true)
197
+ })
198
+
199
+ // Letting go of Cmd while still holding the arrow: the arrow is dropped as owed a keyup,
200
+ // but it is genuinely down, and its repeats say so. Without this, nudging stopped dead
201
+ // mid-gesture and stayed dead for as long as the key was held.
202
+ it('takes back a dropped key that is still repeating', async () => {
203
+ const { editor, container } = await renderEditor()
204
+
205
+ keyDown(container, { key: 'Meta', code: 'MetaLeft', metaKey: true })
206
+ keyDown(container, { key: 'ArrowUp', code: 'ArrowUp', metaKey: true })
207
+ keyUp(container, metaKeyUp)
208
+ expect([...editor.inputs.keys]).toEqual([])
209
+
210
+ keyDown(container, { key: 'ArrowUp', code: 'ArrowUp', repeat: true })
211
+ expect([...editor.inputs.keys]).toEqual(['ArrowUp'])
212
+ })
213
+
214
+ it('does not disturb keys held without Meta', async () => {
215
+ const { editor, container } = await renderEditor()
216
+
217
+ keyDown(container, { key: 'ArrowUp', code: 'ArrowUp' })
218
+ keyUp(container, { key: 'ArrowUp', code: 'ArrowUp' })
219
+ keyDown(container, { key: 'ArrowRight', code: 'ArrowRight' })
220
+
221
+ expect([...editor.inputs.keys]).toEqual(['ArrowRight'])
222
+ })
223
+
224
+ // Browsers report `metaKey: true` on Meta's own keyup, which left `getMetaKey()` true
225
+ // until some later event happened to report it false.
226
+ it('releases meta itself, though its keyup reports metaKey as still down', async () => {
227
+ const { editor, container } = await renderEditor()
228
+
229
+ keyDown(container, { key: 'Meta', code: 'MetaLeft', metaKey: true })
230
+ expect(editor.inputs.getMetaKey()).toBe(true)
231
+
232
+ keyUp(container, metaKeyUp)
233
+ await act(async () => {
234
+ await new Promise((resolve) => setTimeout(resolve, 200))
235
+ })
236
+ expect(editor.inputs.getMetaKey()).toBe(false)
237
+ })
238
+ })
@@ -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.0091c91994d6'
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-28T11:15:20.344Z',
8
+ patch: '2026-10-28T11:15:20.344Z',
9
9
  }