Prevent wobble during viewport following (#3695)

This revives the old 'derived camera' idea to prevent cursor wobbling
during viewport following.

Before this PR we updated the camera on a tick during viewport
following, but the shapes and cursors were not moving on the same tick
(we tried that during the perf work and it was all kinds of
problematic). Frankly I've forgotten how we ever managed to eliminate
wobble here in the first place?

Anyway after this PR we derive the camera based on whether or not we are
following a user. When you follow a user it makes it so that your
viewport contains their viewport. If your viewport is not already very
close to their viewport it will animate the initial position, after
which it will 'lock' in place and the derived value will be used from
then on.

This exposed a minor issue in our sync engine: the fact that we send
presence updates in separate websocket messages from document updates.
We get into situations like this

1. user A follows user B
2. user B deletes the current page they are on
3. user B's page deletion diff gets sent
4. user B's presence update gets sent with a new currentPageId
5. user A receives the page deletion
6. user A still thinks that user B is on the old page and doesn't know
how to update the follow state.

So to fix this I made it so that we can (and do) send presence updates
in the same websocket messages as document updates so the server can
handle them atomically.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [x] `bugfix` — Bug fix



### Test Plan

1. Add a step-by-step description of how to test your PR here.
8.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Fixes a bug that caused the cursor & shapes to wiggle around when
following someone else's viewport
This commit is contained in:
David Sheldrick 2024-05-19 02:22:01 +01:00 committed by GitHub
parent e559a7cdbb
commit 48512995b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 278 additions and 202 deletions

View file

@ -20,15 +20,8 @@ export const DEFAULT_CAMERA_OPTIONS: TLCameraOptions = {
zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8],
}
export const FOLLOW_CHASE_PROPORTION = 0.5
/** @internal */
export const FOLLOW_CHASE_PAN_SNAP = 0.1
/** @internal */
export const FOLLOW_CHASE_PAN_UNSNAP = 0.2
/** @internal */
export const FOLLOW_CHASE_ZOOM_SNAP = 0.005
/** @internal */
export const FOLLOW_CHASE_ZOOM_UNSNAP = 0.05
export const FOLLOW_CHASE_VIEWPORT_SNAP = 2
/** @internal */
export const DOUBLE_CLICK_DURATION = 450

View file

@ -1,4 +1,4 @@
import { EMPTY_ARRAY, atom, computed, transact } from '@tldraw/state'
import { EMPTY_ARRAY, atom, computed, react, transact, unsafe__withoutCapture } from '@tldraw/state'
import {
ComputedCache,
RecordType,
@ -20,6 +20,7 @@ import {
TLBinding,
TLBindingId,
TLBindingPartial,
TLCamera,
TLCursor,
TLCursorType,
TLDOCUMENT_ID,
@ -69,6 +70,7 @@ import {
getOwnProperty,
hasOwnProperty,
last,
lerp,
sortById,
sortByIndex,
structuredClone,
@ -88,11 +90,7 @@ import {
DEFAULT_ANIMATION_OPTIONS,
DEFAULT_CAMERA_OPTIONS,
DRAG_DISTANCE,
FOLLOW_CHASE_PAN_SNAP,
FOLLOW_CHASE_PAN_UNSNAP,
FOLLOW_CHASE_PROPORTION,
FOLLOW_CHASE_ZOOM_SNAP,
FOLLOW_CHASE_ZOOM_UNSNAP,
FOLLOW_CHASE_VIEWPORT_SNAP,
HIT_TEST_MARGIN,
INTERNAL_POINTER_IDS,
LEFT_MOUSE_BUTTON,
@ -2036,8 +2034,55 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @public
*/
@computed getCamera() {
return this.store.get(this.getCameraId())!
@computed getCamera(): TLCamera {
const baseCamera = this.store.get(this.getCameraId())!
if (this._isLockedOnFollowingUser.get()) {
const followingCamera = this.getCameraForFollowing()
if (followingCamera) {
return { ...baseCamera, ...followingCamera }
}
}
return baseCamera
}
@computed
private getViewportPageBoundsForFollowing(): null | Box {
const followingUserId = this.getInstanceState().followingUserId
if (!followingUserId) return null
const leaderPresence = this.getCollaborators().find((c) => c.userId === followingUserId)
if (!leaderPresence) return null
// Fit their viewport inside of our screen bounds
// 1. calculate their viewport in page space
const { w: lw, h: lh } = leaderPresence.screenBounds
const { x: lx, y: ly, z: lz } = leaderPresence.camera
const theirViewport = new Box(-lx, -ly, lw / lz, lh / lz)
// resize our screenBounds to contain their viewport
const ourViewport = this.getViewportScreenBounds().clone()
const ourAspectRatio = ourViewport.width / ourViewport.height
ourViewport.width = theirViewport.width
ourViewport.height = ourViewport.width / ourAspectRatio
if (ourViewport.height < theirViewport.height) {
ourViewport.height = theirViewport.height
ourViewport.width = ourViewport.height * ourAspectRatio
}
ourViewport.center = theirViewport.center
return ourViewport
}
@computed
private getCameraForFollowing(): null | { x: number; y: number; z: number } {
const viewport = this.getViewportPageBoundsForFollowing()
if (!viewport) return null
return {
x: -viewport.x,
y: -viewport.y,
z: this.getViewportScreenBounds().w / viewport.width,
}
}
/**
@ -3100,6 +3145,9 @@ export class Editor extends EventEmitter<TLEventMap> {
// Following
// When we are 'locked on' to a user, our camera is derived from their camera.
private _isLockedOnFollowingUser = atom('isLockedOnFollowingUser', false)
/**
* Start viewport-following a user.
*
@ -3109,18 +3157,28 @@ export class Editor extends EventEmitter<TLEventMap> {
* ```
*
* @param userId - The id of the user to follow.
* @param opts - Options for starting to follow a user.
*
* @public
*/
startFollowingUser(userId: string): this {
// if we were already following someone, stop following them
this.stopFollowingUser()
const leaderPresences = this._getCollaboratorsQuery()
.get()
.filter((p) => p.userId === userId)
if (!leaderPresences.length) {
console.warn('User not found')
return this
}
const thisUserId = this.user.getId()
if (!thisUserId) {
console.warn('You should set the userId for the current instance before following a user')
// allow to continue since it's probably fine most of the time.
}
// If the leader is following us, then we can't follow them
@ -3128,113 +3186,108 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}
const latestLeaderPresence = computed('latestLeaderPresence', () => {
return this.getCollaborators().find((p) => p.userId === userId)
})
transact(() => {
this.stopFollowingUser()
this.updateInstanceState({ followingUserId: userId })
// we listen for page changes separately from the 'moveTowardsUser' tick
const dispose = react('update current page', () => {
const leaderPresence = latestLeaderPresence.get()
if (!leaderPresence) {
this.stopFollowingUser()
return
}
if (
leaderPresence.currentPageId !== this.getCurrentPageId() &&
this.getPage(leaderPresence.currentPageId)
) {
// if the page changed, switch page
this.history.ignore(() => {
// sneaky store.put here, we can't go through setCurrentPage because it calls stopFollowingUser
this.store.put([
{ ...this.getInstanceState(), currentPageId: leaderPresence.currentPageId },
])
this._isLockedOnFollowingUser.set(true)
})
}
})
const cancel = () => {
dispose()
this._isLockedOnFollowingUser.set(false)
this.off('frame', moveTowardsUser)
this.off('stop-following', cancel)
}
let isCaughtUp = false
const moveTowardsUser = () => {
transact(() => {
// Stop following if we can't find the user
const leaderPresence = this._getCollaboratorsQuery()
.get()
.filter((p) => p.userId === userId)
.sort((a, b) => {
return b.lastActivityTimestamp - a.lastActivityTimestamp
})[0]
const leaderPresence = latestLeaderPresence.get()
if (!leaderPresence) {
this.stopFollowingUser()
return
}
// Change page if leader is on a different page
const isOnSamePage = leaderPresence.currentPageId === this.getCurrentPageId()
const chaseProportion = isOnSamePage ? FOLLOW_CHASE_PROPORTION : 1
if (!isOnSamePage) {
this.stopFollowingUser()
this.setCurrentPage(leaderPresence.currentPageId)
this.startFollowingUser(userId)
if (this._isLockedOnFollowingUser.get()) return
const animationSpeed = this.user.getAnimationSpeed()
if (animationSpeed === 0) {
this._isLockedOnFollowingUser.set(true)
return
}
// Get the bounds of the follower (me) and the leader (them)
const { center, width, height } = this.getViewportPageBounds()
const leaderScreen = Box.From(leaderPresence.screenBounds)
const leaderWidth = leaderScreen.width / leaderPresence.camera.z
const leaderHeight = leaderScreen.height / leaderPresence.camera.z
const leaderCenter = new Vec(
leaderWidth / 2 - leaderPresence.camera.x,
leaderHeight / 2 - leaderPresence.camera.y
const targetViewport = this.getViewportPageBoundsForFollowing()
if (!targetViewport) {
this.stopFollowingUser()
return
}
const currentViewport = this.getViewportPageBounds()
const diffX =
Math.abs(targetViewport.minX - currentViewport.minX) +
Math.abs(targetViewport.maxX - currentViewport.maxX)
const diffY =
Math.abs(targetViewport.minY - currentViewport.minY) +
Math.abs(targetViewport.maxY - currentViewport.maxY)
// Stop chasing if we're close enough!
if (diffX < FOLLOW_CHASE_VIEWPORT_SNAP && diffY < FOLLOW_CHASE_VIEWPORT_SNAP) {
this._isLockedOnFollowingUser.set(true)
return
}
// Chase the user's viewport!
// Interpolate between the current viewport and the target viewport based on animation speed.
// This will produce an 'ease-out' effect.
const t = clamp(animationSpeed * 0.5, 0.1, 0.8)
const nextViewport = new Box(
lerp(currentViewport.minX, targetViewport.minX, t),
lerp(currentViewport.minY, targetViewport.minY, t),
lerp(currentViewport.width, targetViewport.width, t),
lerp(currentViewport.height, targetViewport.height, t)
)
// At this point, let's check if we're following someone who's following us.
// If so, we can't try to contain their entire viewport
// because that would become a feedback loop where we zoom, they zoom, etc.
const isFollowingFollower = leaderPresence.followingUserId === thisUserId
// Figure out how much to zoom
const desiredWidth = width + (leaderWidth - width) * chaseProportion
const desiredHeight = height + (leaderHeight - height) * chaseProportion
const ratio = !isFollowingFollower
? Math.min(width / desiredWidth, height / desiredHeight)
: height / desiredHeight
const baseZoom = this.getBaseZoom()
const { zoomSteps } = this.getCameraOptions()
const zoomMin = zoomSteps[0]
const zoomMax = last(zoomSteps)!
const targetZoom = clamp(this.getCamera().z * ratio, zoomMin * baseZoom, zoomMax * baseZoom)
const targetWidth = this.getViewportScreenBounds().w / targetZoom
const targetHeight = this.getViewportScreenBounds().h / targetZoom
// Figure out where to move the camera
const displacement = leaderCenter.sub(center)
const targetCenter = Vec.Add(center, Vec.Mul(displacement, chaseProportion))
// Now let's assess whether we've caught up to the leader or not
const distance = Vec.Sub(targetCenter, center).len()
const zoomChange = Math.abs(targetZoom - this.getCamera().z)
// If we're chasing the leader...
// Stop chasing if we're close enough
if (distance < FOLLOW_CHASE_PAN_SNAP && zoomChange < FOLLOW_CHASE_ZOOM_SNAP) {
isCaughtUp = true
return
}
// If we're already caught up with the leader...
// Only start moving again if we're far enough away
if (
isCaughtUp &&
distance < FOLLOW_CHASE_PAN_UNSNAP &&
zoomChange < FOLLOW_CHASE_ZOOM_UNSNAP
) {
return
}
const nextCamera = new Vec(
-nextViewport.x,
-nextViewport.y,
this.getViewportScreenBounds().width / nextViewport.width
)
// Update the camera!
isCaughtUp = false
this.stopCameraAnimation()
this._setCamera(
new Vec(
-(targetCenter.x - targetWidth / 2),
-(targetCenter.y - targetHeight / 2),
targetZoom
)
)
})
this._setCamera(nextCamera)
}
this.once('stop-following', cancel)
this.on('frame', moveTowardsUser)
this.addListener('frame', moveTowardsUser)
// call once to start synchronously
moveTowardsUser()
})
return this
}
@ -3249,8 +3302,14 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
stopFollowingUser(): this {
this.batch(() => {
// commit the current camera to the store
this.store.put([this.getCamera()])
// this must happen after the camera is committed
this._isLockedOnFollowingUser.set(false)
this.updateInstanceState({ followingUserId: null })
this.emit('stop-following')
})
return this
}
@ -8295,7 +8354,6 @@ export class Editor extends EventEmitter<TLEventMap> {
const instanceState = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
const pageState = this.store.get(this._getCurrentPageStateId())!
const cameraOptions = this._cameraOptions.__unsafe__getWithoutCapture()!
const camera = this.store.unsafeGetWithoutCapture(this.getCameraId())!
switch (type) {
case 'pinch': {
@ -8337,13 +8395,13 @@ export class Editor extends EventEmitter<TLEventMap> {
instanceState.screenBounds.y
)
const { x: cx, y: cy, z: cz } = camera
this.stopCameraAnimation()
if (instanceState.followingUserId) {
this.stopFollowingUser()
}
const { x: cx, y: cy, z: cz } = unsafe__withoutCapture(() => this.getCamera())
const { panSpeed, zoomSpeed } = cameraOptions
this._setCamera(
new Vec(
@ -8402,7 +8460,7 @@ export class Editor extends EventEmitter<TLEventMap> {
this.stopFollowingUser()
}
const { x: cx, y: cy, z: cz } = camera
const { x: cx, y: cy, z: cz } = unsafe__withoutCapture(() => this.getCamera())
const { x: dx, y: dy, z: dz = 0 } = info.delta
let behavior = wheelBehavior
@ -8517,10 +8575,12 @@ export class Editor extends EventEmitter<TLEventMap> {
// If the user is in pen mode, but the pointer is not a pen, stop here.
if (!isPen && isPenMode) return
const { x: cx, y: cy, z: cz } = unsafe__withoutCapture(() => this.getCamera())
// If we've started panning, then clear any long press timeout
if (this.inputs.isPanning && this.inputs.isPointing) {
// Handle spacebar / middle mouse button panning
const { currentScreenPoint, previousScreenPoint } = this.inputs
const { x: cx, y: cy, z: cz } = camera
const { panSpeed } = cameraOptions
const offset = Vec.Sub(currentScreenPoint, previousScreenPoint)
this.setCamera(
@ -8535,7 +8595,7 @@ export class Editor extends EventEmitter<TLEventMap> {
inputs.isPointing &&
!inputs.isDragging &&
Vec.Dist2(originPagePoint, currentPagePoint) >
(instanceState.isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / camera.z
(instanceState.isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / cz
) {
// Start dragging
inputs.isDragging = true

View file

@ -423,30 +423,45 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
lastPushedPresenceState: R | null = null
private pushPresence(nextPresence: R | null) {
// make sure we push any document changes first
// TODO: need to send presence changes in the same push request as document changes
// in order to not get into weird states
this.store._flushHistory()
if (!this.isConnectedToRoom) {
// if we're offline, don't do anything
return
}
let req: TLPushRequest<R> | null = null
let presence: TLPushRequest<any>['presence'] = undefined
if (!this.lastPushedPresenceState && nextPresence) {
// we don't have a last presence state, so we need to push the full state
req = {
type: 'push',
presence: [RecordOpType.Put, nextPresence],
clientClock: this.clientClock++,
}
presence = [RecordOpType.Put, nextPresence]
} else if (this.lastPushedPresenceState && nextPresence) {
// we have a last presence state, so we need to push a diff if there is one
const diff = diffRecord(this.lastPushedPresenceState, nextPresence)
if (diff) {
req = {
type: 'push',
presence: [RecordOpType.Patch, diff],
clientClock: this.clientClock++,
}
presence = [RecordOpType.Patch, diff]
}
}
if (!presence) return
this.lastPushedPresenceState = nextPresence
// if there is a pending push that has not been sent and does not already include a presence update,
// then add this presence update to it
const lastPush = this.pendingPushRequests.at(-1)
if (lastPush && !lastPush.sent && !lastPush.request.presence) {
lastPush.request.presence = presence
return
}
// otherwise, create a new push request
const req: TLPushRequest<R> = {
type: 'push',
clientClock: this.clientClock++,
presence,
}
if (req) {
this.pendingPushRequests.push({ request: req, sent: false })
this.flushPendingPushRequests()
@ -586,7 +601,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
this.pendingPushRequests.shift()
} else if (diff.action === 'commit') {
const { request } = this.pendingPushRequests.shift()!
if ('diff' in request) {
if ('diff' in request && request.diff) {
this.applyNetworkDiff(request.diff, true)
}
} else {
@ -598,7 +613,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
try {
this.speculativeChanges = this.store.extractingChanges(() => {
for (const { request } of this.pendingPushRequests) {
if (!('diff' in request)) continue
if (!('diff' in request) || !request.diff) continue
this.applyNetworkDiff(request.diff, true)
}
})

View file

@ -814,11 +814,13 @@ export class TLSyncRoom<R extends UnknownRecord> {
transaction((rollback) => {
// collect actual ops that resulted from the push
// these will be broadcast to other users
let mergedChanges: NetworkDiff<R> | null = null
type ActualChanges = { diff: NetworkDiff<R> | null }
const docChanges: ActualChanges = { diff: null }
const presenceChanges: ActualChanges = { diff: null }
const propagateOp = (id: string, op: RecordOp<R>) => {
if (!mergedChanges) mergedChanges = {}
mergedChanges[id] = op
const propagateOp = (changes: ActualChanges, id: string, op: RecordOp<R>) => {
if (!changes.diff) changes.diff = {}
changes.diff[id] = op
}
const fail = (reason: TLIncompatibilityReason): Result<void, void> => {
@ -830,7 +832,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
return Result.err(undefined)
}
const addDocument = (id: string, _state: R): Result<void, void> => {
const addDocument = (changes: ActualChanges, id: string, _state: R): Result<void, void> => {
const res = this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
if (res.type === 'error') {
return fail(
@ -852,7 +854,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
return fail(TLIncompatibilityReason.InvalidRecord)
}
if (diff.value) {
propagateOp(id, [RecordOpType.Patch, diff.value])
propagateOp(changes, id, [RecordOpType.Patch, diff.value])
}
} else {
// Otherwise, if we don't already have a document with this id
@ -861,13 +863,17 @@ export class TLSyncRoom<R extends UnknownRecord> {
if (!result.ok) {
return fail(TLIncompatibilityReason.InvalidRecord)
}
propagateOp(id, [RecordOpType.Put, state])
propagateOp(changes, id, [RecordOpType.Put, state])
}
return Result.ok(undefined)
}
const patchDocument = (id: string, patch: ObjectDiff): Result<void, void> => {
const patchDocument = (
changes: ActualChanges,
id: string,
patch: ObjectDiff
): Result<void, void> => {
// if it was already deleted, there's no need to apply the patch
const doc = this.getDocument(id)
if (!doc) return Result.ok(undefined)
@ -889,7 +895,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
return fail(TLIncompatibilityReason.InvalidRecord)
}
if (diff.value) {
propagateOp(id, [RecordOpType.Patch, diff.value])
propagateOp(changes, id, [RecordOpType.Patch, diff.value])
}
} else {
// need to apply the patch to the downgraded version and then upgrade it
@ -912,7 +918,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
return fail(TLIncompatibilityReason.InvalidRecord)
}
if (diff.value) {
propagateOp(id, [RecordOpType.Patch, diff.value])
propagateOp(changes, id, [RecordOpType.Patch, diff.value])
}
}
@ -921,7 +927,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
const { clientClock } = message
if ('presence' in message) {
if ('presence' in message && message.presence) {
// The push request was for the presence scope.
const id = session.presenceId
const [type, val] = message.presence
@ -929,30 +935,27 @@ export class TLSyncRoom<R extends UnknownRecord> {
switch (type) {
case RecordOpType.Put: {
// Try to put the document. If it fails, stop here.
const res = addDocument(id, { ...val, id, typeName })
const res = addDocument(presenceChanges, id, { ...val, id, typeName })
// if res.ok is false here then we already called `fail` and we should stop immediately
if (!res.ok) return
break
}
case RecordOpType.Patch: {
// Try to patch the document. If it fails, stop here.
const res = patchDocument(id, {
const res = patchDocument(presenceChanges, id, {
...val,
id: [ValueOpType.Put, id],
typeName: [ValueOpType.Put, typeName],
})
// if res.ok is false here then we already called `fail` and we should stop immediately
if (!res.ok) return
break
}
}
this.sendMessage(session.sessionKey, {
type: 'push_result',
clientClock,
action: 'commit',
serverClock: this.clock,
})
} else {
}
if (message.diff) {
// The push request was for the document scope.
for (const [id, op] of Object.entries(message.diff)) {
for (const [id, op] of Object.entries(message.diff!)) {
switch (op[0]) {
case RecordOpType.Put: {
// Try to add the document.
@ -960,13 +963,15 @@ export class TLSyncRoom<R extends UnknownRecord> {
if (!this.documentTypes.has(op[1].typeName)) {
return fail(TLIncompatibilityReason.InvalidRecord)
}
const res = addDocument(id, op[1])
const res = addDocument(docChanges, id, op[1])
// if res.ok is false here then we already called `fail` and we should stop immediately
if (!res.ok) return
break
}
case RecordOpType.Patch: {
// Try to patch the document. If it fails, stop here.
const res = patchDocument(id, op[1])
const res = patchDocument(docChanges, id, op[1])
// if res.ok is false here then we already called `fail` and we should stop immediately
if (!res.ok) return
break
}
@ -986,23 +991,20 @@ export class TLSyncRoom<R extends UnknownRecord> {
this.removeDocument(id, this.clock)
// Schedule a pruneTombstones call to happen on the next call stack
setTimeout(this.pruneTombstones, 0)
propagateOp(id, op)
propagateOp(docChanges, id, op)
break
}
}
}
}
// Let the client know what action to take based on the results of the push
if (!mergedChanges) {
// DISCARD
// Applying the client's changes had no effect, so the client should drop the diff
this.sendMessage(session.sessionKey, {
type: 'push_result',
serverClock: this.clock,
clientClock,
action: 'discard',
})
} else if (isEqual(mergedChanges, message.diff)) {
if (
// if there was only a presence push, the client doesn't need to do anything aside from
// shift the push request.
!message.diff ||
isEqual(docChanges.diff, message.diff)
) {
// COMMIT
// Applying the client's changes had the exact same effect on the server as
// they had on the client, so the client should keep the diff
@ -1012,12 +1014,21 @@ export class TLSyncRoom<R extends UnknownRecord> {
clientClock,
action: 'commit',
})
} else if (!docChanges.diff) {
// DISCARD
// Applying the client's changes had no effect, so the client should drop the diff
this.sendMessage(session.sessionKey, {
type: 'push_result',
serverClock: this.clock,
clientClock,
action: 'discard',
})
} else {
// REBASE
// Applying the client's changes had a different non-empty effect on the server,
// so the client should rebase with our gold-standard / authoritative diff.
// First we need to migrate the diff to the client's version
const migrateResult = this.migrateDiffForSession(session.serializedSchema, mergedChanges)
const migrateResult = this.migrateDiffForSession(session.serializedSchema, docChanges.diff)
if (!migrateResult.ok) {
return fail(
migrateResult.error === MigrationFailureReason.TargetVersionTooNew
@ -1033,13 +1044,15 @@ export class TLSyncRoom<R extends UnknownRecord> {
action: { rebaseWithDiff: migrateResult.value },
})
}
}
// If there are merged changes, broadcast them to all other clients
if (mergedChanges !== null) {
if (docChanges.diff || presenceChanges.diff) {
this.broadcastPatch({
sourceSessionKey: session.sessionKey,
diff: mergedChanges,
diff: {
...docChanges.diff,
...presenceChanges.diff,
},
})
}

View file

@ -61,17 +61,12 @@ export type TLSocketServerSentDataEvent<R extends UnknownRecord> =
}
/** @public */
export type TLPushRequest<R extends UnknownRecord> =
| {
export type TLPushRequest<R extends UnknownRecord> = {
type: 'push'
clientClock: number
presence: [typeof RecordOpType.Patch, ObjectDiff] | [typeof RecordOpType.Put, R]
}
| {
type: 'push'
clientClock: number
diff: NetworkDiff<R>
}
diff?: NetworkDiff<R>
presence?: [typeof RecordOpType.Patch, ObjectDiff] | [typeof RecordOpType.Put, R]
}
/** @public */
export type TLConnectRequest = {