Allow clients to gracefully handle rejection (#3673)

This PR fixes the issue where sync clients would get into a reconnect
loop after being rejected by the sync server.

- Close the socket when in the error state (see useRemoteSyncClient)
- Show a 'plx refresh the page' screen that doesn't have a sad face on
it.
  
<img width="665" alt="image"
src="https://github.com/tldraw/tldraw/assets/1242537/96025fa3-cc20-4f53-8f58-74e473e16702">

- If older clients who can't handle rejection well need to be rejected
(e.g. due to a store migration being added) then we send them to a
special purgatory where the canvas goes blank and it shows the offline
indicator but the websocket connection stays open and it won't try to
reconnect.

### Change Type

- [x] `dotcom` — Changes the tldraw.com web app
- [x] `bugfix` — Bug fix


### Test Plan

1. Gonna manually test this one by doing sneaky deploys to a test PR
This commit is contained in:
David Sheldrick 2024-05-02 14:54:21 +01:00 committed by GitHub
parent e999316691
commit f2827f6409
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 143 additions and 68 deletions

View file

@ -1,28 +1,39 @@
import { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { isInIframe } from '../../utils/iFrame'
export function ErrorPage({
icon,
messages,
}: {
icon?: boolean
messages: { header: string; para1: string; para2?: string }
}) {
const GoBackLink = () => {
const inIframe = isInIframe()
return (
<Link to={'/'} target={inIframe ? '_blank' : '_self'}>
{inIframe ? 'Open tldraw.' : 'Back to tldraw.'}
</Link>
)
}
const sadFaceIcon = (
<img width={36} height={36} src="/404-Sad-tldraw.svg" loading="lazy" role="presentation" />
)
export function ErrorPage({
messages,
icon = sadFaceIcon,
cta = <GoBackLink />,
}: {
icon?: ReactNode
messages: { header: string; para1: string; para2?: string }
cta?: ReactNode
}) {
return (
<div className="error-page">
<div className="error-page__container">
{icon && (
<img width={36} height={36} alt={'Not found'} src="/404-Sad-tldraw.svg" loading="lazy" />
)}
{icon}
<div className="error-page__content">
<h1>{messages.header}</h1>
<p>{messages.para1}</p>
{messages.para2 && <p>{messages.para2}</p>}
</div>
<Link to={'/'} target={inIframe ? '_blank' : '_self'}>
{inIframe ? 'Open tldraw.' : 'Back to tldraw.'}
</Link>
{cta}
</div>
</div>
)

View file

@ -1,17 +1,33 @@
import { TLIncompatibilityReason } from '@tldraw/tlsync'
import { exhaustiveSwitchError } from 'tldraw'
import 'tldraw/tldraw.css'
import { RemoteSyncError } from '../utils/remote-sync/remote-sync'
import { ErrorPage } from './ErrorPage/ErrorPage'
export function StoreErrorScreen({ error }: { error: Error }) {
let header = 'Could not connect to server.'
let message = ''
if (error instanceof RemoteSyncError) {
switch (error.reason) {
case TLIncompatibilityReason.ClientTooOld: {
message = 'This client is out of date. Please refresh the page.'
break
return (
<ErrorPage
icon={
<img
width={36}
height={36}
src="/tldraw-white-on-black.svg"
loading="lazy"
role="presentation"
/>
}
messages={{
header: 'Refresh the page',
para1: 'You need to update to the latest version of tldraw to continue.',
}}
cta={<button onClick={() => window.location.reload()}>Refresh</button>}
/>
)
}
case TLIncompatibilityReason.ServerTooOld: {
message =
@ -38,5 +54,5 @@ export function StoreErrorScreen({ error }: { error: Error }) {
}
}
return <ErrorPage icon messages={{ header, para1: message }} />
return <ErrorPage messages={{ header, para1: message }} />
}

View file

@ -40,7 +40,11 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
const store = useTLStore({ schema })
const error: NonNullable<typeof state>['error'] = state?.error ?? undefined
useEffect(() => {
if (error) return
const userPreferences = computed<{ id: string; color: string; name: string }>(
'userPreferences',
() => {
@ -107,7 +111,7 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
client.close()
socket.close()
}
}, [prefs, roomId, store, uri])
}, [prefs, roomId, store, uri, error])
return useValue<RemoteTLStoreWithStatus>(
'remote synced store',

View file

@ -28,7 +28,6 @@ export function Component() {
if (!result || !result.timestamp)
return (
<ErrorPage
icon
messages={{
header: 'Page not found',
para1: 'The page you are looking does not exist or has been moved.',

View file

@ -25,7 +25,6 @@ export function Component() {
if (!data)
return (
<ErrorPage
icon
messages={{
header: 'Page not found',
para1: 'The page you are looking does not exist or has been moved.',

View file

@ -29,7 +29,6 @@ export function Component() {
if (!data)
return (
<ErrorPage
icon
messages={{
header: 'Page not found',
para1: 'The page you are looking does not exist or has been moved.',

View file

@ -3,7 +3,6 @@ import { ErrorPage } from '../components/ErrorPage/ErrorPage'
export function Component() {
return (
<ErrorPage
icon
messages={{
header: 'Page not found',
para1: 'The page you are looking does not exist or has been moved.',

View file

@ -1,4 +1,4 @@
import { TLSocketClientSentEvent, TLSYNC_PROTOCOL_VERSION } from '@tldraw/tlsync'
import { TLSocketClientSentEvent, getTlsyncProtocolVersion } from '@tldraw/tlsync'
import { TLRecord } from 'tldraw'
import { ClientWebSocketAdapter, INACTIVE_MIN_DELAY } from './ClientWebSocketAdapter'
// NOTE: there is a hack in apps/dotcom/jestResolver.js to make this import work
@ -141,7 +141,7 @@ describe(ClientWebSocketAdapter, () => {
type: 'connect',
connectRequestId: 'test',
schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} },
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
lastServerClock: 0,
}

View file

@ -29,7 +29,7 @@ export {
} from './lib/diff'
export {
TLIncompatibilityReason,
TLSYNC_PROTOCOL_VERSION,
getTlsyncProtocolVersion,
type TLConnectRequest,
type TLPingRequest,
type TLPushRequest,

View file

@ -33,7 +33,6 @@ export type RoomSession<R extends UnknownRecord> =
state: typeof RoomSessionState.Connected
sessionKey: string
presenceId: string
isV4Client: boolean
socket: TLRoomSocket<R>
serializedSchema: SerializedSchema
lastInteractionTime: number

View file

@ -15,10 +15,10 @@ import { interval } from './interval'
import {
TLIncompatibilityReason,
TLPushRequest,
TLSYNC_PROTOCOL_VERSION,
TLSocketClientSentEvent,
TLSocketServerSentDataEvent,
TLSocketServerSentEvent,
getTlsyncProtocolVersion,
} from './protocol'
import './requestAnimationFrame.polyfill'
@ -272,7 +272,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
type: 'connect',
connectRequestId: this.latestConnectRequestId,
schema: this.store.schema.serialize(),
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
lastServerClock: this.lastServerClock,
})
}

View file

@ -41,10 +41,10 @@ import {
import { interval } from './interval'
import {
TLIncompatibilityReason,
TLSYNC_PROTOCOL_VERSION,
TLSocketClientSentEvent,
TLSocketServerSentDataEvent,
TLSocketServerSentEvent,
getTlsyncProtocolVersion,
} from './protocol'
/** @public */
@ -420,9 +420,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
} else {
if (session.debounceTimer === null) {
// this is the first message since the last flush, don't delay it
session.socket.sendMessage(
session.isV4Client ? message : { type: 'data', data: [message] }
)
session.socket.sendMessage({ type: 'data', data: [message] })
session.debounceTimer = setTimeout(
() => this._flushDataMessages(sessionKey),
@ -449,14 +447,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
session.debounceTimer = null
if (session.outstandingDataMessages.length > 0) {
if (session.isV4Client) {
// v4 clients don't support the "data" message, so we need to send each message separately
for (const message of session.outstandingDataMessages) {
session.socket.sendMessage(message)
}
} else {
session.socket.sendMessage({ type: 'data', data: session.outstandingDataMessages })
}
session.outstandingDataMessages.length = 0
}
}
@ -678,14 +669,15 @@ export class TLSyncRoom<R extends UnknownRecord> {
// if the protocol versions don't match, disconnect the client
// we will eventually want to try to make our protocol backwards compatible to some degree
// and have a MIN_PROTOCOL_VERSION constant that the TLSyncRoom implements support for
const isV4Client = message.protocolVersion === 4 && TLSYNC_PROTOCOL_VERSION === 5
if (
message.protocolVersion == null ||
(message.protocolVersion < TLSYNC_PROTOCOL_VERSION && !isV4Client)
) {
let theirProtocolVersion = message.protocolVersion
// 5 is the same as 6
if (theirProtocolVersion === 5) {
theirProtocolVersion = 6
}
if (theirProtocolVersion == null || theirProtocolVersion < getTlsyncProtocolVersion()) {
this.rejectSession(session, TLIncompatibilityReason.ClientTooOld)
return
} else if (message.protocolVersion > TLSYNC_PROTOCOL_VERSION) {
} else if (theirProtocolVersion > getTlsyncProtocolVersion()) {
this.rejectSession(session, TLIncompatibilityReason.ServerTooOld)
return
}
@ -711,7 +703,6 @@ export class TLSyncRoom<R extends UnknownRecord> {
state: RoomSessionState.Connected,
sessionKey: session.sessionKey,
presenceId: session.presenceId,
isV4Client,
socket: session.socket,
serializedSchema: sessionSchema,
lastInteractionTime: Date.now(),
@ -751,7 +742,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
type: 'connect',
connectRequestId: message.connectRequestId,
hydrationType: 'wipe_all',
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: this.schema.serialize(),
serverClock: this.clock,
diff: migrated.value,
@ -797,7 +788,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
connectRequestId: message.connectRequestId,
hydrationType: 'wipe_presence',
schema: this.schema.serialize(),
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
serverClock: this.clock,
diff: migrated.value,
})

View file

@ -2,7 +2,11 @@ import { SerializedSchema, UnknownRecord } from '@tldraw/store'
import { NetworkDiff, ObjectDiff, RecordOpType } from './diff'
/** @public */
export const TLSYNC_PROTOCOL_VERSION = 5
const TLSYNC_PROTOCOL_VERSION = 6
export function getTlsyncProtocolVersion() {
return TLSYNC_PROTOCOL_VERSION
}
/** @public */
export const TLIncompatibilityReason = {

View file

@ -14,7 +14,7 @@ import { DBLoadResult, TLServer } from '../lib/TLServer'
import { RoomSnapshot } from '../lib/TLSyncRoom'
import { chunk } from '../lib/chunk'
import { RecordOpType } from '../lib/diff'
import { TLSYNC_PROTOCOL_VERSION, TLSocketClientSentEvent } from '../lib/protocol'
import { TLSocketClientSentEvent, getTlsyncProtocolVersion } from '../lib/protocol'
import { RoomState } from '../lib/server-types'
// Because we are using jsdom in this package, jest tries to load the 'browser' version of the ws library
@ -145,7 +145,7 @@ describe('TLServer', () => {
type: 'connect',
lastServerClock: 0,
connectRequestId: 'test-connect-request-id',
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema,
}

View file

@ -15,12 +15,24 @@ import { RoomSnapshot, TLRoomSocket } from '../lib/TLSyncRoom'
import { RecordOpType, ValueOpType } from '../lib/diff'
import {
TLIncompatibilityReason,
TLSYNC_PROTOCOL_VERSION,
TLSocketServerSentEvent,
getTlsyncProtocolVersion,
} from '../lib/protocol'
import { TestServer } from './TestServer'
import { TestSocketPair } from './TestSocketPair'
const actualProtocol = jest.requireActual('../lib/protocol')
jest.mock('../lib/protocol', () => {
const actual = jest.requireActual('../lib/protocol')
return {
...actual,
getTlsyncProtocolVersion: jest.fn(actual.getTlsyncProtocolVersion),
}
})
const mockGetTlsyncProtocolVersion = getTlsyncProtocolVersion as jest.Mock
function mockSocket<R extends UnknownRecord>(): TLRoomSocket<R> {
return {
isOpen: true,
@ -328,7 +340,7 @@ test('clients will receive updates from a snapshot migration upon connection', (
type: 'connect',
connectRequestId: 'test',
lastServerClock: snapshot.clock,
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: schemaV3.serialize(),
})
@ -352,7 +364,7 @@ test('out-of-date clients will receive incompatibility errors', () => {
type: 'connect',
connectRequestId: 'test',
lastServerClock: 0,
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: schemaV2.serialize(),
})
@ -363,6 +375,9 @@ test('out-of-date clients will receive incompatibility errors', () => {
})
test('clients using an out-of-date protocol will receive compatibility errors', () => {
const actualVersion = getTlsyncProtocolVersion()
mockGetTlsyncProtocolVersion.mockReturnValue(actualVersion + 1)
try {
const v2server = new TestServer(schemaV2)
const id = 'test_upgrade_v3'
@ -373,7 +388,7 @@ test('clients using an out-of-date protocol will receive compatibility errors',
type: 'connect',
connectRequestId: 'test',
lastServerClock: 0,
protocolVersion: TLSYNC_PROTOCOL_VERSION - 2,
protocolVersion: actualVersion,
schema: schemaV2.serialize(),
})
@ -381,6 +396,45 @@ test('clients using an out-of-date protocol will receive compatibility errors',
type: 'incompatibility_error',
reason: TLIncompatibilityReason.ClientTooOld,
})
} finally {
mockGetTlsyncProtocolVersion.mockReset()
mockGetTlsyncProtocolVersion.mockImplementation(actualProtocol.getTlsyncProtocolVersion)
}
})
// this can be deleted when the protocol gets to v7
test('v5 special case should allow connections', () => {
const actualVersion = getTlsyncProtocolVersion()
if (actualVersion > 6) return
const v2server = new TestServer(schemaV2)
const id = 'test_upgrade_v3'
const socket = mockSocket()
v2server.room.handleNewSession(id, socket)
v2server.room.handleMessage(id, {
type: 'connect',
connectRequestId: 'test',
lastServerClock: 0,
protocolVersion: 5,
schema: schemaV2.serialize(),
})
expect(socket.sendMessage).toHaveBeenCalledWith({
connectRequestId: 'test',
diff: {},
hydrationType: 'wipe_all',
protocolVersion: 6,
schema: {
schemaVersion: 2,
sequences: {
'com.tldraw.user': 1,
},
},
serverClock: 1,
type: 'connect',
})
})
test('clients using a too-new protocol will receive compatibility errors', () => {
@ -394,7 +448,7 @@ test('clients using a too-new protocol will receive compatibility errors', () =>
type: 'connect',
connectRequestId: 'test',
lastServerClock: 0,
protocolVersion: TLSYNC_PROTOCOL_VERSION + 1,
protocolVersion: getTlsyncProtocolVersion() + 1,
schema: schemaV2.serialize(),
})
@ -436,7 +490,7 @@ test('when the client is too new it cannot connect', () => {
type: 'connect',
connectRequestId: 'test',
lastServerClock: 10,
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: schemaV2.serialize(),
})
@ -496,7 +550,7 @@ describe('when the client is too old', () => {
type: 'connect',
connectRequestId: 'test',
lastServerClock: 10,
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: schemaV1.serialize(),
})
@ -505,7 +559,7 @@ describe('when the client is too old', () => {
type: 'connect',
connectRequestId: 'test',
lastServerClock: 10,
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: schemaV2.serialize(),
})
@ -514,7 +568,7 @@ describe('when the client is too old', () => {
connectRequestId: 'test',
hydrationType: 'wipe_presence',
diff: {},
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: schemaV2.serialize(),
serverClock: 10,
} satisfies TLSocketServerSentEvent<RV2>)
@ -524,7 +578,7 @@ describe('when the client is too old', () => {
connectRequestId: 'test',
hydrationType: 'wipe_presence',
diff: {},
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: schemaV2.serialize(),
serverClock: 10,
} satisfies TLSocketServerSentEvent<RV2>)
@ -643,7 +697,7 @@ describe('when the client is the same version', () => {
type: 'connect',
connectRequestId: 'test',
lastServerClock: 10,
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: JSON.parse(JSON.stringify(schemaV2.serialize())),
})
@ -652,7 +706,7 @@ describe('when the client is the same version', () => {
type: 'connect',
connectRequestId: 'test',
lastServerClock: 10,
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: JSON.parse(JSON.stringify(schemaV2.serialize())),
})
@ -661,7 +715,7 @@ describe('when the client is the same version', () => {
connectRequestId: 'test',
hydrationType: 'wipe_presence',
diff: {},
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: schemaV2.serialize(),
serverClock: 10,
} satisfies TLSocketServerSentEvent<RV2>)
@ -671,7 +725,7 @@ describe('when the client is the same version', () => {
connectRequestId: 'test',
hydrationType: 'wipe_presence',
diff: {},
protocolVersion: TLSYNC_PROTOCOL_VERSION,
protocolVersion: getTlsyncProtocolVersion(),
schema: schemaV2.serialize(),
serverClock: 10,
} satisfies TLSocketServerSentEvent<RV2>)