@tldraw/sync 4.6.0-next.5a871ec02ff3 → 4.6.0-next.6594d48ace27

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
@@ -1,5 +1,4 @@
1
- import { atom, isSignal, transact } from '@tldraw/state'
2
- import { useAtom } from '@tldraw/state-react'
1
+ import { atom, transact } from '@tldraw/state'
3
2
  import {
4
3
  ClientWebSocketAdapter,
5
4
  TLCustomMessageHandler,
@@ -14,26 +13,33 @@ import {
14
13
  import { useEffect } from 'react'
15
14
  import {
16
15
  Editor,
17
- InstancePresenceRecordType,
18
- Signal,
19
16
  TAB_ID,
20
17
  TLAssetStore,
21
18
  TLPresenceStateInfo,
22
- TLPresenceUserInfo,
23
19
  TLRecord,
24
20
  TLStore,
25
21
  TLStoreSchemaOptions,
26
22
  TLStoreWithStatus,
23
+ TLThemes,
24
+ TLUser,
25
+ TLUserStore,
26
+ UserRecordType,
27
27
  computed,
28
+ createCachedUserResolve,
29
+ createPresenceStateDerivation,
30
+ registerColorsFromThemes,
31
+ registerFontsFromThemes,
32
+ resolveThemes,
28
33
  createTLStore,
34
+ createUserId,
29
35
  defaultUserPreferences,
36
+ defaultUserStore,
30
37
  getDefaultUserPresence,
31
38
  getUserPreferences,
32
39
  uniqueId,
33
40
  useEvent,
34
41
  useReactiveEvent,
35
42
  useRefState,
36
- useShallowObjectIdentity,
37
43
  useTLSchemaFromUtils,
38
44
  useValue,
39
45
  } from 'tldraw'
@@ -96,7 +102,7 @@ export type RemoteTLStoreWithStatus = Exclude<
96
102
  * @param opts - Configuration options for multiplayer synchronization
97
103
  * - `uri` - WebSocket server URI (string or async function returning URI)
98
104
  * - `assets` - Asset store for blob storage (required for production use)
99
- * - `userInfo` - User information for presence system (can be reactive signal)
105
+ * - `users` - User store for identity, presence and attribution
100
106
  * - `getUserPresence` - Optional function to customize presence data
101
107
  * - `onCustomMessageReceived` - Handler for custom socket messages
102
108
  * - `roomId` - Room identifier for analytics (internal use)
@@ -112,10 +118,13 @@ export type RemoteTLStoreWithStatus = Exclude<
112
118
  * const store = useSync({
113
119
  * uri: 'wss://myserver.com/sync/room-123',
114
120
  * assets: myAssetStore,
115
- * userInfo: {
116
- * id: 'user-1',
117
- * name: 'Alice',
118
- * color: '#ff0000'
121
+ * users: {
122
+ * currentUser: computed('current-user', () => ({
123
+ * id: createUserId('user-1'),
124
+ * name: 'Alice',
125
+ * color: '#ff0000',
126
+ * meta: {},
127
+ * })),
119
128
  * }
120
129
  * })
121
130
  *
@@ -133,19 +142,15 @@ export type RemoteTLStoreWithStatus = Exclude<
133
142
  *
134
143
  * @example
135
144
  * ```tsx
136
- * // Dynamic authentication with reactive user info
137
- * import { atom } from '@tldraw/state'
138
- *
145
+ * // Dynamic authentication with user store
139
146
  * function AuthenticatedApp() {
140
- * const currentUser = atom('user', { id: 'user-1', name: 'Alice', color: '#ff0000' })
141
- *
142
147
  * const store = useSync({
143
148
  * uri: async () => {
144
149
  * const token = await getAuthToken()
145
150
  * return `wss://myserver.com/sync/room-123?token=${token}`
146
151
  * },
147
152
  * assets: authenticatedAssetStore,
148
- * userInfo: currentUser, // Reactive signal
153
+ * users: myUserStore,
149
154
  * getUserPresence: (store, user) => {
150
155
  * return {
151
156
  * userId: user.id,
@@ -170,52 +175,76 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
170
175
  uri,
171
176
  roomId = 'default',
172
177
  assets,
178
+ users: _users,
173
179
  onMount,
174
180
  connect,
175
181
  trackAnalyticsEvent: track,
176
- userInfo,
177
182
  getUserPresence: _getUserPresence,
178
183
  onCustomMessageReceived: _onCustomMessageReceived,
184
+ themes,
179
185
  ...schemaOpts
180
186
  } = opts
181
187
 
182
188
  // This line will throw a type error if we add any new options to the useSync hook but we don't destructure them
183
189
  // This is required because otherwise the useTLSchemaFromUtils might return a new schema on every render if the newly-added option
184
- // is allowed to be unstable (e.g. userInfo)
190
+ // is allowed to be unstable
185
191
  const __never__: never = 0 as any as keyof Omit<typeof schemaOpts, keyof TLStoreSchemaOptions>
186
192
 
193
+ const resolvedThemes = resolveThemes(themes)
194
+ registerColorsFromThemes(resolvedThemes)
195
+ registerFontsFromThemes(resolvedThemes)
187
196
  const schema = useTLSchemaFromUtils(schemaOpts)
188
197
 
189
- const prefs = useShallowObjectIdentity(userInfo)
190
198
  const getUserPresence = useReactiveEvent(
191
199
  (_getUserPresence ?? getDefaultUserPresence) as typeof getDefaultUserPresence
192
200
  )
193
201
  const onCustomMessageReceived = useEvent(_onCustomMessageReceived ?? defaultCustomMessageHandler)
194
202
 
195
- const userAtom = useAtom<TLPresenceUserInfo | Signal<TLPresenceUserInfo> | undefined>(
196
- 'userAtom',
197
- prefs
198
- )
199
-
200
- useEffect(() => {
201
- userAtom.set(prefs)
202
- }, [prefs, userAtom])
203
-
204
203
  useEffect(() => {
205
204
  const storeId = uniqueId()
206
205
 
207
- const userPreferences = computed<{ id: string; color: string; name: string }>(
208
- 'userPreferences',
209
- () => {
210
- const userStuff = userAtom.get()
211
- const user = (isSignal(userStuff) ? userStuff.get() : userStuff) ?? getUserPreferences()
212
- return {
213
- id: user.id,
214
- color: user.color ?? defaultUserPreferences.color,
215
- name: user.name ?? defaultUserPreferences.name,
206
+ const users: Required<TLUserStore> = _users
207
+ ? {
208
+ currentUser: _users.currentUser,
209
+ resolve:
210
+ _users.resolve ??
211
+ createCachedUserResolve((userId) => {
212
+ const current = _users.currentUser.get()
213
+ return current && current.id === createUserId(userId) ? current : null
214
+ }),
216
215
  }
217
- }
218
- )
216
+ : {
217
+ currentUser: defaultUserStore.currentUser,
218
+ resolve: createCachedUserResolve((userId) => {
219
+ const current = defaultUserStore.currentUser.get()
220
+ if (current && current.id === createUserId(userId)) return current
221
+ const presences = store.query.records('instance_presence').get()
222
+ const match = presences.find((p) => p.userId === createUserId(userId))
223
+ if (match) {
224
+ return UserRecordType.create({
225
+ id: createUserId(userId),
226
+ name: match.userName,
227
+ color: match.color,
228
+ })
229
+ }
230
+ return null
231
+ }),
232
+ }
233
+
234
+ // This always returns a non-null user for presence display, falling back
235
+ // to anonymous user preferences. The store receives the raw `users` object
236
+ // (where currentUser may return null), so attribution via
237
+ // getAttributionUserId() correctly returns null for anonymous sessions.
238
+ const currentUser = computed<TLUser>('currentUser', () => {
239
+ const user = users.currentUser.get()
240
+ if (user) return user
241
+ const prefs = getUserPreferences()
242
+ return UserRecordType.create({
243
+ id: createUserId(prefs.id),
244
+ name: prefs.name ?? '',
245
+ color: prefs.color ?? defaultUserPreferences.color,
246
+ })
247
+ })
219
248
 
220
249
  let socket: TLPersistentClientSocket<
221
250
  TLSocketClientSentEvent<TLRecord>,
@@ -278,6 +307,7 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
278
307
  id: storeId,
279
308
  schema,
280
309
  assets,
310
+ users,
281
311
  onMount,
282
312
  collaboration: {
283
313
  status: collaborationStatusSignal,
@@ -285,18 +315,12 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
285
315
  },
286
316
  })
287
317
 
288
- const presence = computed('instancePresence', () => {
289
- const presenceState = getUserPresence(store, userPreferences.get())
290
- if (!presenceState) return null
291
-
292
- return InstancePresenceRecordType.create({
293
- ...presenceState,
294
- id: InstancePresenceRecordType.createId(store.id),
295
- })
296
- })
318
+ const presence = createPresenceStateDerivation(currentUser, {
319
+ getUserPresence,
320
+ })(store)
297
321
 
298
322
  const otherUserPresences = store.query.ids('instance_presence', () => ({
299
- userId: { neq: userPreferences.get().id },
323
+ userId: { neq: currentUser.get().id },
300
324
  }))
301
325
 
302
326
  const presenceMode = computed<TLPresenceMode>('presenceMode', () => {
@@ -363,7 +387,7 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
363
387
  assets,
364
388
  onMount,
365
389
  connect,
366
- userAtom,
390
+ _users,
367
391
  roomId,
368
392
  schema,
369
393
  setState,
@@ -401,10 +425,8 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
401
425
  * const syncOptions: UseSyncOptions = {
402
426
  * uri: 'wss://myserver.com/sync/room-123',
403
427
  * assets: myAssetStore,
404
- * userInfo: {
405
- * id: 'user-1',
406
- * name: 'Alice',
407
- * color: '#ff0000'
428
+ * users: {
429
+ * currentUser: myCurrentUserSignal,
408
430
  * },
409
431
  * getUserPresence: (store, user) => ({
410
432
  * userId: user.id,
@@ -418,27 +440,11 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
418
440
  */
419
441
  export interface UseSyncOptionsBase {
420
442
  /**
421
- * User information for multiplayer presence and identification.
422
- *
423
- * Can be a static object or a reactive signal that updates when user
424
- * information changes. The presence system automatically updates when
425
- * reactive signals change, allowing real-time user profile updates.
426
- *
427
- * Should be synchronized with the `userPreferences` prop of the main
428
- * Tldraw component for consistent user experience. If not provided,
429
- * defaults to localStorage-based user preferences.
430
- *
431
- * @example
432
- * ```ts
433
- * // Static user info
434
- * userInfo: { id: 'user-123', name: 'Alice', color: '#ff0000' }
435
- *
436
- * // Reactive user info
437
- * const userSignal = atom('user', { id: 'user-123', name: 'Alice', color: '#ff0000' })
438
- * userInfo: userSignal
439
- * ```
443
+ * Named theme definitions. When provided, custom color names are automatically
444
+ * registered before the store is constructed so persisted data with those
445
+ * colors passes validation on load.
440
446
  */
441
- userInfo?: TLPresenceUserInfo | Signal<TLPresenceUserInfo>
447
+ themes?: Partial<TLThemes>
442
448
 
443
449
  /**
444
450
  * Asset store implementation for handling file uploads and storage.
@@ -466,6 +472,18 @@ export interface UseSyncOptionsBase {
466
472
  */
467
473
  assets: TLAssetStore
468
474
 
475
+ /**
476
+ * User store for identity, presence and attribution.
477
+ *
478
+ * Both methods return reactive {@link @tldraw/state#Signal | Signals}.
479
+ * `currentUser` provides the current user's identity (used for
480
+ * both presence broadcasting and shape attribution) and optionally
481
+ * `resolve(userId)` looks up other users by ID. If not provided,
482
+ * a default implementation backed by localStorage user preferences is
483
+ * used, with `resolve` falling back to presence records in the store.
484
+ */
485
+ users?: TLUserStore
486
+
469
487
  /**
470
488
  * Handler for receiving custom messages sent through the multiplayer connection.
471
489
  *
@@ -525,7 +543,7 @@ export interface UseSyncOptionsBase {
525
543
  * }
526
544
  * ```
527
545
  */
528
- getUserPresence?(store: TLStore, user: TLPresenceUserInfo): TLPresenceStateInfo | null
546
+ getUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null
529
547
  }
530
548
 
531
549
  /** @public */
@@ -3,13 +3,13 @@ import {
3
3
  AssetRecordType,
4
4
  Editor,
5
5
  MediaHelpers,
6
- Signal,
7
6
  TLAsset,
8
7
  TLAssetStore,
9
8
  TLPresenceStateInfo,
10
- TLPresenceUserInfo,
11
9
  TLStore,
12
10
  TLStoreSchemaOptions,
11
+ TLUser,
12
+ TLUserStore,
13
13
  clamp,
14
14
  defaultBindingUtils,
15
15
  defaultShapeUtils,
@@ -26,12 +26,12 @@ export interface UseSyncDemoOptions {
26
26
  * everyone using the demo server. Consider prefixing it with your company or project name.
27
27
  */
28
28
  roomId: string
29
+
29
30
  /**
30
- * A signal that contains the user information needed for multiplayer features.
31
- * This should be synchronized with the `userPreferences` configuration for the main `<Tldraw />` component.
31
+ * User store for identity, presence and attribution.
32
32
  * If not provided, a default implementation based on localStorage will be used.
33
33
  */
34
- userInfo?: TLPresenceUserInfo | Signal<TLPresenceUserInfo>
34
+ users?: TLUserStore
35
35
 
36
36
  /** @internal */
37
37
  host?: string
@@ -40,7 +40,7 @@ export interface UseSyncDemoOptions {
40
40
  * {@inheritdoc UseSyncOptions.getUserPresence}
41
41
  * @public
42
42
  */
43
- getUserPresence?(store: TLStore, user: TLPresenceUserInfo): TLPresenceStateInfo | null
43
+ getUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null
44
44
  }
45
45
 
46
46
  /**