Skip to content

Commit dfdf6b7

Browse files
authored
Add solo sync mode (tldraw#6524)
This makes it so that presence updates only send if someone else is in the room. ### Change type - [x] `improvement` ### Release notes - `useSync` now only sends presence updates, like mouse and camera positions, when there are more than one unique user in a room. ### API changes Internal only
1 parent 146e95b commit dfdf6b7

5 files changed

Lines changed: 177 additions & 0 deletions

File tree

packages/sync-core/api-report.api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,9 @@ export interface TLPingRequest {
263263
type: 'ping';
264264
}
265265

266+
// @internal (undocumented)
267+
export type TLPresenceMode = 'full' | 'solo';
268+
266269
// @internal (undocumented)
267270
export interface TLPushRequest<R extends UnknownRecord> {
268271
// (undocumented)
@@ -439,6 +442,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
439442
onLoad(self: TLSyncClient<R, S>): void;
440443
onSyncError(reason: string): void;
441444
presence: Signal<null | R>;
445+
presenceMode?: Signal<TLPresenceMode>;
442446
socket: TLPersistentClientSocket<R>;
443447
store: S;
444448
});
@@ -458,6 +462,8 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
458462
isReadonly: boolean;
459463
}) => void;
460464
// (undocumented)
465+
readonly presenceMode: Signal<TLPresenceMode> | undefined;
466+
// (undocumented)
461467
readonly presenceState: Signal<null | R> | undefined;
462468
// (undocumented)
463469
readonly socket: TLPersistentClientSocket<R>;

packages/sync-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export {
3838
type SubscribingFn,
3939
type TLPersistentClientSocket,
4040
type TLPersistentClientSocketStatus,
41+
type TLPresenceMode,
4142
type TlSocketStatusChangeEvent,
4243
type TLSocketStatusListener,
4344
} from './lib/TLSyncClient'

packages/sync-core/src/lib/TLSyncClient.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ export type TLSocketStatusListener = (params: TlSocketStatusChangeEvent) => void
8282

8383
/** @internal */
8484
export type TLPersistentClientSocketStatus = 'online' | 'offline' | 'error'
85+
86+
/** @internal */
87+
export type TLPresenceMode = 'solo' | 'full'
8588
/**
8689
* A socket that can be used to send and receive messages to the server. It should handle staying
8790
* open and reconnecting when the connection is lost. In actual client code this will be a wrapper
@@ -139,6 +142,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
139142
readonly socket: TLPersistentClientSocket<R>
140143

141144
readonly presenceState: Signal<R | null> | undefined
145+
readonly presenceMode: Signal<TLPresenceMode> | undefined
142146

143147
// isOnline is true when we have an open socket connection and we have
144148
// established a connection with the server room (i.e. we have received a 'connect' message)
@@ -178,6 +182,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
178182
store: S
179183
socket: TLPersistentClientSocket<R>
180184
presence: Signal<R | null>
185+
presenceMode?: Signal<TLPresenceMode>
181186
onLoad(self: TLSyncClient<R, S>): void
182187
onSyncError(reason: string): void
183188
onAfterConnect?(self: TLSyncClient<R, S>, details: { isReadonly: boolean }): void
@@ -197,6 +202,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
197202
let didLoad = false
198203

199204
this.presenceState = config.presence
205+
this.presenceMode = config.presenceMode
200206

201207
this.disposables.push(
202208
// when local 'user' changes are made, send them to the server
@@ -274,6 +280,8 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
274280
this.disposables.push(
275281
react('pushPresence', () => {
276282
if (this.didCancel?.()) return this.close()
283+
const mode = this.presenceMode?.get()
284+
if (mode !== 'full') return
277285
this.pushPresence(this.presenceState!.get())
278286
})
279287
)
@@ -399,6 +407,10 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
399407
// this.store.applyDiff(stashedChanges, false)
400408

401409
this.onAfterConnect?.(this, { isReadonly: event.isReadonly })
410+
const presence = this.presenceState?.get()
411+
if (presence) {
412+
this.pushPresence(presence)
413+
}
402414
})
403415

404416
this.lastServerClock = event.serverClock
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { atom, computed, Signal } from '@tldraw/state'
2+
import { BaseRecord, createRecordType, RecordId, Store, StoreSchema } from '@tldraw/store'
3+
import { TLSyncClient } from '../lib/TLSyncClient'
4+
import { TestServer } from './TestServer'
5+
import { TestSocketPair } from './TestSocketPair'
6+
7+
jest.mock('@tldraw/utils', () => {
8+
return {
9+
...jest.requireActual('@tldraw/utils'),
10+
fpsThrottle: jest.fn((fn) => fn),
11+
}
12+
})
13+
14+
const disposables: Array<() => void> = []
15+
16+
afterEach(() => {
17+
for (const dispose of disposables) {
18+
dispose()
19+
}
20+
disposables.length = 0
21+
})
22+
23+
interface User extends BaseRecord<'user', RecordId<User>> {
24+
name: string
25+
age: number
26+
}
27+
28+
interface Presence extends BaseRecord<'presence', RecordId<Presence>> {
29+
name: string
30+
age: number
31+
}
32+
33+
const Presence = createRecordType<Presence>('presence', {
34+
scope: 'presence',
35+
validator: { validate: (value) => value as Presence },
36+
})
37+
38+
const User = createRecordType<User>('user', {
39+
scope: 'document',
40+
validator: { validate: (value) => value as User },
41+
})
42+
43+
type R = User | Presence
44+
45+
const schema = StoreSchema.create<R>({ user: User, presence: Presence })
46+
47+
class TestInstance {
48+
server: TestServer<R>
49+
socketPair: TestSocketPair<R>
50+
client: TLSyncClient<R>
51+
52+
hasLoaded = false
53+
54+
constructor(presenceSignal: Signal<Presence | null>, presenceMode?: 'solo' | 'full') {
55+
this.server = new TestServer(schema)
56+
this.socketPair = new TestSocketPair('test_presence_mode', this.server)
57+
this.socketPair.connect()
58+
59+
this.client = new TLSyncClient<R>({
60+
store: new Store({ schema, props: {} }),
61+
socket: this.socketPair.clientSocket,
62+
onLoad: () => {
63+
this.hasLoaded = true
64+
},
65+
onSyncError: jest.fn((reason) => {
66+
throw new Error('onSyncError: ' + reason)
67+
}),
68+
presence: presenceSignal,
69+
presenceMode: presenceMode ? computed('', () => presenceMode) : undefined,
70+
})
71+
72+
disposables.push(() => {
73+
this.client.close()
74+
})
75+
}
76+
77+
flush() {
78+
this.server.flushDebouncingMessages()
79+
80+
while (this.socketPair.getNeedsFlushing()) {
81+
this.socketPair.flushClientSentEvents()
82+
this.socketPair.flushServerSentEvents()
83+
}
84+
}
85+
}
86+
87+
test('presence is pushed on change when mode is full', () => {
88+
const presence = Presence.create({ name: 'bob', age: 10 })
89+
const presenceSignal = atom('', presence)
90+
91+
const t = new TestInstance(presenceSignal, 'full')
92+
t.socketPair.connect()
93+
t.flush()
94+
95+
const session = t.server.room.sessions.values().next().value
96+
expect(session).toBeDefined()
97+
expect(session?.presenceId).toBeDefined()
98+
expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({
99+
name: 'bob',
100+
age: 10,
101+
})
102+
103+
presenceSignal.set(Presence.create({ name: 'bob', age: 11 }))
104+
t.flush()
105+
expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({
106+
name: 'bob',
107+
age: 11,
108+
})
109+
110+
presenceSignal.set(Presence.create({ name: 'bob', age: 12 }))
111+
t.flush()
112+
expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({
113+
name: 'bob',
114+
age: 12,
115+
})
116+
})
117+
118+
test('presence is only pushed once on connect when mode is solo', () => {
119+
const presence = Presence.create({ name: 'bob', age: 10 })
120+
const presenceSignal = atom('', presence)
121+
122+
const t = new TestInstance(presenceSignal, 'solo')
123+
t.socketPair.connect()
124+
t.flush()
125+
126+
const session = t.server.room.sessions.values().next().value
127+
expect(session).toBeDefined()
128+
expect(session?.presenceId).toBeDefined()
129+
expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({
130+
name: 'bob',
131+
age: 10,
132+
})
133+
134+
presenceSignal.set(Presence.create({ name: 'bob', age: 11 }))
135+
t.flush()
136+
expect(t.server.room.documents.get(session!.presenceId!)?.state).not.toMatchObject({
137+
name: 'bob',
138+
age: 11,
139+
})
140+
141+
presenceSignal.set(Presence.create({ name: 'bob', age: 12 }))
142+
t.flush()
143+
expect(t.server.room.documents.get(session!.presenceId!)?.state).not.toMatchObject({
144+
name: 'bob',
145+
age: 12,
146+
})
147+
})

packages/sync/src/useSync.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { atom, isSignal, transact } from '@tldraw/state'
22
import { useAtom } from '@tldraw/state-react'
33
import {
44
ClientWebSocketAdapter,
5+
TLPresenceMode,
56
TLRemoteSyncError,
67
TLSyncClient,
78
TLSyncErrorCloseEventReason,
@@ -165,6 +166,15 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
165166
})
166167
})
167168

169+
const otherUserPresences = store.query.ids('instance_presence', () => ({
170+
userId: { neq: userPreferences.get().id },
171+
}))
172+
173+
const presenceMode = computed<TLPresenceMode>('presenceMode', () => {
174+
if (otherUserPresences.get().size === 0) return 'solo'
175+
return 'full'
176+
})
177+
168178
const client = new TLSyncClient({
169179
store,
170180
socket,
@@ -210,6 +220,7 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
210220
})
211221
},
212222
presence,
223+
presenceMode,
213224
})
214225

215226
return () => {

0 commit comments

Comments
 (0)