@tldraw/sync 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/src/useSync.ts CHANGED
@@ -49,6 +49,25 @@ const MULTIPLAYER_EVENT_NAME = 'multiplayer.client'
49
49
 
50
50
  const defaultCustomMessageHandler: TLCustomMessageHandler = () => {}
51
51
 
52
+ // Keyed by `reason`, which is an arbitrary string off the server's close event, so a Map rather
53
+ // than an object literal: a reason of 'constructor' or '__proto__' would find a prototype member.
54
+ const SYNC_ERROR_EVENT_NAMES = new Map<string, string>([
55
+ [TLSyncErrorCloseEventReason.NOT_FOUND, 'room-not-found'],
56
+ [TLSyncErrorCloseEventReason.FORBIDDEN, 'forbidden'],
57
+ [TLSyncErrorCloseEventReason.NOT_AUTHENTICATED, 'not-authenticated'],
58
+ [TLSyncErrorCloseEventReason.RATE_LIMITED, 'rate-limited'],
59
+ ])
60
+
61
+ type SyncSocket = TLPersistentClientSocket<
62
+ TLSocketClientSentEvent<TLRecord>,
63
+ TLSocketServerSentEvent<TLRecord>
64
+ >
65
+
66
+ // The socket reports 'error' but the collaboration status only knows 'offline'.
67
+ function toCollaborationStatus(status: SyncSocket['connectionStatus']) {
68
+ return status === 'error' ? 'offline' : status
69
+ }
70
+
52
71
  /**
53
72
  * A store wrapper specifically for remote collaboration that excludes local-only states.
54
73
  * This type represents a tldraw store that is synchronized with a remote multiplayer server.
@@ -214,33 +233,26 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
214
233
  useEffect(() => {
215
234
  const storeId = uniqueId()
216
235
 
217
- const users: Required<TLUserStore> = _users
218
- ? {
219
- currentUser: _users.currentUser,
220
- resolve:
221
- _users.resolve ??
222
- createCachedUserResolve((userId) => {
223
- const current = _users.currentUser.get()
224
- return current && current.id === createUserId(userId) ? current : null
225
- }),
226
- }
227
- : {
228
- currentUser: defaultUserStore.currentUser,
229
- resolve: createCachedUserResolve((userId) => {
230
- const current = defaultUserStore.currentUser.get()
231
- if (current && current.id === createUserId(userId)) return current
232
- const presences = store.query.records('instance_presence').get()
233
- const match = presences.find((p) => p.userId === createUserId(userId))
234
- if (match) {
235
- return UserRecordType.create({
236
- id: createUserId(userId),
237
- name: match.userName,
238
- color: match.color,
239
- })
240
- }
241
- return null
242
- }),
243
- }
236
+ const currentUserSignal = _users?.currentUser ?? defaultUserStore.currentUser
237
+ const users: Required<TLUserStore> = {
238
+ currentUser: currentUserSignal,
239
+ resolve:
240
+ _users?.resolve ??
241
+ createCachedUserResolve((userId) => {
242
+ const id = createUserId(userId)
243
+ const current = currentUserSignal.get()
244
+ if (current && current.id === id) return current
245
+ // Only the default user store falls back to presence records for other users.
246
+ if (_users) return null
247
+ const match = store.query
248
+ .records('instance_presence')
249
+ .get()
250
+ .find((p) => p.userId === id)
251
+ return match
252
+ ? UserRecordType.create({ id, name: match.userName, color: match.color })
253
+ : null
254
+ }),
255
+ }
244
256
 
245
257
  // This always returns a non-null user for presence display, falling back
246
258
  // to anonymous user preferences. The store receives the raw `users` object
@@ -257,41 +269,23 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
257
269
  })
258
270
  })
259
271
 
260
- let socket: TLPersistentClientSocket<
261
- TLSocketClientSentEvent<TLRecord>,
262
- TLSocketServerSentEvent<TLRecord>
263
- >
272
+ let socket: SyncSocket
264
273
  if (connect) {
265
274
  if (uri) {
266
275
  throw new Error('uri and connect cannot be used together')
267
276
  }
268
277
 
269
- socket = connect({
270
- sessionId: TAB_ID,
271
- storeId,
272
- }) as TLPersistentClientSocket<
273
- TLSocketClientSentEvent<TLRecord>,
274
- TLSocketServerSentEvent<TLRecord>
275
- >
278
+ socket = connect({ sessionId: TAB_ID, storeId }) as SyncSocket
276
279
  } else if (uri) {
277
- if (connect) {
278
- throw new Error('uri and connect cannot be used together')
279
- }
280
-
281
280
  socket = new ClientWebSocketAdapter(async () => {
282
- const uriString = typeof uri === 'string' ? uri : await uri()
283
-
284
281
  // set sessionId as a query param on the uri
285
- const withParams = new URL(uriString)
286
- if (withParams.searchParams.has('sessionId')) {
287
- throw new Error(
288
- 'useSync. "sessionId" is a reserved query param name. Please use a different name'
289
- )
290
- }
291
- if (withParams.searchParams.has('storeId')) {
292
- throw new Error(
293
- 'useSync. "storeId" is a reserved query param name. Please use a different name'
294
- )
282
+ const withParams = new URL(typeof uri === 'string' ? uri : await uri())
283
+ for (const param of ['sessionId', 'storeId']) {
284
+ if (withParams.searchParams.has(param)) {
285
+ throw new Error(
286
+ `useSync. "${param}" is a reserved query param name. Please use a different name`
287
+ )
288
+ }
295
289
  }
296
290
 
297
291
  withParams.searchParams.set('sessionId', TAB_ID)
@@ -304,12 +298,12 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
304
298
 
305
299
  let didCancel = false
306
300
 
307
- function getConnectionStatus() {
308
- return socket.connectionStatus === 'error' ? 'offline' : socket.connectionStatus
309
- }
310
- const collaborationStatusSignal = atom('collaboration status', getConnectionStatus())
301
+ const collaborationStatusSignal = atom(
302
+ 'collaboration status',
303
+ toCollaborationStatus(socket.connectionStatus)
304
+ )
311
305
  const unsubscribeFromConnectionStatus = socket.onStatusChange(() => {
312
- collaborationStatusSignal.set(getConnectionStatus())
306
+ collaborationStatusSignal.set(toCollaborationStatus(socket.connectionStatus))
313
307
  })
314
308
 
315
309
  const syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')
@@ -349,30 +343,18 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
349
343
  didCancel: () => didCancel,
350
344
  onLoad(client) {
351
345
  track?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })
352
- // Merge so we don't clobber objectAccess if onAfterConnect ran first.
353
- setState((prev) => ({ ...prev, readyClient: client }))
346
+ // Keep objectAccess (onAfterConnect always runs first for this client) but drop the
347
+ // rest: an `error` left behind by a previous client (e.g. NOT_FOUND on the old uri)
348
+ // would otherwise keep the hook reporting status 'error' for the new, healthy room.
349
+ setState((prev) => ({ readyClient: client, objectAccess: prev?.objectAccess }))
354
350
  },
355
351
  onSyncError(reason) {
356
352
  console.error('sync error', reason)
357
353
 
358
- switch (reason) {
359
- case TLSyncErrorCloseEventReason.NOT_FOUND:
360
- track?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })
361
- break
362
- case TLSyncErrorCloseEventReason.FORBIDDEN:
363
- track?.(MULTIPLAYER_EVENT_NAME, { name: 'forbidden', roomId })
364
- break
365
- case TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:
366
- track?.(MULTIPLAYER_EVENT_NAME, { name: 'not-authenticated', roomId })
367
- break
368
- case TLSyncErrorCloseEventReason.RATE_LIMITED:
369
- track?.(MULTIPLAYER_EVENT_NAME, { name: 'rate-limited', roomId })
370
- break
371
- default:
372
- track?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error:' + reason, roomId })
373
- break
374
- }
375
-
354
+ track?.(MULTIPLAYER_EVENT_NAME, {
355
+ name: SYNC_ERROR_EVENT_NAMES.get(reason) ?? 'sync-error:' + reason,
356
+ roomId,
357
+ })
376
358
  setState({ error: new TLRemoteSyncError(reason) })
377
359
  socket.close()
378
360
  },
@@ -424,10 +406,9 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
424
406
  if (!state) return { status: 'loading' }
425
407
  if (state.error) return { status: 'error', error: state.error }
426
408
  if (!state.readyClient) return { status: 'loading' }
427
- const connectionStatus = state.readyClient.socket.connectionStatus
428
409
  return {
429
410
  status: 'synced-remote',
430
- connectionStatus: connectionStatus === 'error' ? 'offline' : connectionStatus,
411
+ connectionStatus: toCollaborationStatus(state.readyClient.socket.connectionStatus),
431
412
  store: state.readyClient.store,
432
413
  objectAccess: state.objectAccess ?? 'write',
433
414
  }
@@ -121,9 +121,9 @@ export function useSyncDemo(
121
121
  assets,
122
122
  onMount: useCallback(
123
123
  (editor: Editor) => {
124
- editor.registerExternalAssetHandler('url', async ({ url }) => {
125
- return await createAssetFromUrlUsingDemoServer(host, url)
126
- })
124
+ editor.registerExternalAssetHandler('url', ({ url }) =>
125
+ createAssetFromUrlUsingDemoServer(host, url)
126
+ )
127
127
  },
128
128
  [host]
129
129
  ),
@@ -182,9 +182,7 @@ function createDemoAssetStore(host: string): TLAssetStore {
182
182
  alert('Uploading images is disabled in this demo.')
183
183
  throw new Error('Uploading images is disabled in this demo.')
184
184
  }
185
- const id = uniqueId()
186
-
187
- const objectName = `${id}-${file.name}`.replace(/\W/g, '-')
185
+ const objectName = `${uniqueId()}-${file.name}`.replace(/\W/g, '-')
188
186
  const url = `${host}/uploads/${objectName}`
189
187
 
190
188
  const response = await fetch(url, {
@@ -201,34 +199,34 @@ function createDemoAssetStore(host: string): TLAssetStore {
201
199
  },
202
200
 
203
201
  resolve(asset, context) {
204
- if (!asset.props.src) return null
202
+ const { src } = asset.props
203
+ if (!src) return null
205
204
 
206
205
  // We don't deal with videos at the moment.
207
- if (asset.type === 'video') return asset.props.src
206
+ if (asset.type === 'video') return src
208
207
 
209
208
  // Assert it's an image to make TS happy.
210
209
  if (asset.type !== 'image') return null
211
210
 
212
211
  // Don't try to transform data: URLs, yikes.
213
- if (!asset.props.src.startsWith('http:') && !asset.props.src.startsWith('https:'))
214
- return asset.props.src
212
+ if (!src.startsWith('http:') && !src.startsWith('https:')) return src
215
213
 
216
- if (context.shouldResolveToOriginal) return asset.props.src
214
+ if (context.shouldResolveToOriginal) return src
217
215
 
218
216
  // Don't try to transform animated images.
219
- if (MediaHelpers.isAnimatedImageType(asset?.props.mimeType) || asset.props.isAnimated)
220
- return asset.props.src
217
+ if (MediaHelpers.isAnimatedImageType(asset.props.mimeType) || asset.props.isAnimated)
218
+ return src
221
219
 
222
220
  // Don't try to transform vector images.
223
- if (MediaHelpers.isVectorImageType(asset?.props.mimeType)) return asset.props.src
221
+ if (MediaHelpers.isVectorImageType(asset.props.mimeType)) return src
224
222
 
225
- const url = new URL(asset.props.src)
223
+ const url = new URL(src)
226
224
 
227
225
  // we only transform images that are hosted on domains we control
228
226
  const isTldrawImage =
229
227
  url.origin === host || /\.tldraw\.(?:com|xyz|dev|workers\.dev)$/.test(url.host)
230
228
 
231
- if (!isTldrawImage) return asset.props.src
229
+ if (!isTldrawImage) return src
232
230
 
233
231
  // Assets that are under a certain file size aren't worth transforming (and incurring cost).
234
232
  // We still send them through the image worker to get them optimized though.
@@ -256,8 +254,7 @@ function createDemoAssetStore(host: string): TLAssetStore {
256
254
  url.searchParams.set('w', width.toString())
257
255
  }
258
256
 
259
- const newUrl = `${IMAGE_WORKER}/${url.host}/${url.toString().slice(url.origin.length + 1)}`
260
- return newUrl
257
+ return `${IMAGE_WORKER}/${url.host}/${url.toString().slice(url.origin.length + 1)}`
261
258
  },
262
259
  }
263
260
  }
@@ -285,47 +282,28 @@ function createDemoAssetStore(host: string): TLAssetStore {
285
282
  * @internal
286
283
  */
287
284
  async function createAssetFromUrlUsingDemoServer(host: string, url: string): Promise<TLAsset> {
288
- const urlHash = getHashForString(url)
285
+ let meta: { description?: string; image?: string; favicon?: string; title?: string } | null = null
289
286
  try {
290
287
  // First, try to get the meta data from our endpoint
291
288
  const fetchUrl = new URL(`${host}/bookmarks/unfurl`)
292
289
  fetchUrl.searchParams.set('url', url)
293
-
294
- const meta = (await (await fetch(fetchUrl, { method: 'POST' })).json()) as {
295
- description?: string
296
- image?: string
297
- favicon?: string
298
- title?: string
299
- } | null
300
-
301
- return {
302
- id: AssetRecordType.createId(urlHash),
303
- typeName: 'asset',
304
- type: 'bookmark',
305
- props: {
306
- src: url,
307
- description: meta?.description ?? '',
308
- image: meta?.image ?? '',
309
- favicon: meta?.favicon ?? '',
310
- title: meta?.title ?? '',
311
- },
312
- meta: {},
313
- }
290
+ meta = await (await fetch(fetchUrl, { method: 'POST' })).json()
314
291
  } catch (error) {
315
- // Otherwise, fallback to a blank bookmark
292
+ // Otherwise, fall back to a blank bookmark
316
293
  console.error(error)
317
- return {
318
- id: AssetRecordType.createId(urlHash),
319
- typeName: 'asset',
320
- type: 'bookmark',
321
- props: {
322
- src: url,
323
- description: '',
324
- image: '',
325
- favicon: '',
326
- title: '',
327
- },
328
- meta: {},
329
- }
294
+ }
295
+
296
+ return {
297
+ id: AssetRecordType.createId(getHashForString(url)),
298
+ typeName: 'asset',
299
+ type: 'bookmark',
300
+ props: {
301
+ src: url,
302
+ description: meta?.description ?? '',
303
+ image: meta?.image ?? '',
304
+ favicon: meta?.favicon ?? '',
305
+ title: meta?.title ?? '',
306
+ },
307
+ meta: {},
330
308
  }
331
309
  }