Skip to content

Commit

Permalink
Merge pull request #1548 from quadratichq/fix-offline
Browse files Browse the repository at this point in the history
Fix offline and multiplayer
  • Loading branch information
davidkircos authored Jul 22, 2024
2 parents 0c13e26 + bccfa7e commit f94f0e8
Show file tree
Hide file tree
Showing 20 changed files with 272 additions and 108 deletions.
3 changes: 3 additions & 0 deletions quadratic-client/src/app/debugFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export const debugShowLoadingHashes = debug && false;

export const debugShowFileIO = debug && false;

// shows messages related to offline transaction
export const debugOffline = debug && false;

export const debugGridSettings = debug && false;

export const debugShowMultiplayer = debug && false;
Expand Down
1 change: 1 addition & 0 deletions quadratic-client/src/app/events/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ interface EventTypes {
resizeHeadingColumn: (sheetId: string, column: number) => void;

offlineTransactions: (transactions: number, operations: number) => void;
offlineTransactionsApplied: (timestamps: number[]) => void;

connector: (query: string) => void;
connectorResponse: (buffer: ArrayBuffer) => void;
Expand Down
76 changes: 72 additions & 4 deletions quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { events } from '@/app/events/events';
import { pluralize } from '@/app/helpers/pluralize';
import { multiplayer } from '@/app/web-workers/multiplayerWebWorker/multiplayer';
import { MultiplayerState } from '@/app/web-workers/multiplayerWebWorker/multiplayerClientMessages';
import { useGlobalSnackbar } from '@/shared/components/GlobalSnackbarProvider';
import {
DropdownMenu,
DropdownMenuContent,
Expand All @@ -10,34 +11,101 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/shared/shadcn/ui/dropdown-menu';
import { timeAgo } from '@/shared/utils/timeAgo';
import { Check, ErrorOutline } from '@mui/icons-material';
import { CircularProgress, Tooltip, useTheme } from '@mui/material';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import BottomBarItem from './BottomBarItem';
import { quadraticCore } from '@/app/web-workers/quadraticCore/quadraticCore';
import { DOCUMENTATION_OFFLINE } from '@/shared/constants/urls';

const TIMEOUT_TO_SHOW_DISCONNECT_MESSAGE = 1000;

export default function SyncState() {
const theme = useTheme();

const [syncState, setSyncState] = useState<MultiplayerState>(multiplayer.state);
const { addGlobalSnackbar } = useGlobalSnackbar();

const [disconnectMessage, setDisconnectMessage] = useState(false);
const timeout = useRef<number | null>(null);
useEffect(() => {
const updateState = (state: MultiplayerState) => setSyncState(state);
const updateState = (state: MultiplayerState) => {
if (state === 'waiting to reconnect' || state === 'no internet') {
if (!timeout.current && !disconnectMessage) {
timeout.current = window.setTimeout(() => {
const message = (
<div>
Connection to the Quadratic server was lost. Your changes are only saved locally.{' '}
<a className="underline" href={DOCUMENTATION_OFFLINE}>
Learn more
</a>
.
</div>
);
addGlobalSnackbar(message, {
severity: 'warning',
button: { title: 'Refresh', callback: () => window.location.reload() },
});
timeout.current = null;
setDisconnectMessage(true);
}, TIMEOUT_TO_SHOW_DISCONNECT_MESSAGE);
}
}
if (state === 'connected' && timeout.current) {
window.clearTimeout(timeout.current);
timeout.current = null;
}
if (state === 'connected' && disconnectMessage) {
setDisconnectMessage(false);
addGlobalSnackbar('Connection to the Quadratic server was reestablished.', { severity: 'success' });
}
setSyncState(state);
};
events.on('multiplayerState', updateState);
return () => {
events.off('multiplayerState', updateState);
};
}, []);
}, [addGlobalSnackbar, disconnectMessage]);

const [unsavedTransactions, setUnsavedTransactions] = useState(0);
useEffect(() => {
const updateUnsavedTransactions = (transactions: number, _operations: number) => {
setUnsavedTransactions(transactions);
};
events.on('offlineTransactions', updateUnsavedTransactions);

const offlineTransactionsApplied = (timestamps: number[]) => {
if (timestamps.length === 0) return;
const to = timeAgo(timestamps[timestamps.length - 1]);
const message = (
<div>
We applied {timestamps.length} unsynced changes from {to}. You can undo these changes.{' '}
<a className="underline" href={DOCUMENTATION_OFFLINE}>
Learn More
</a>
.
</div>
);
addGlobalSnackbar(message, {
severity: 'warning',
button: {
title: 'Undo',
callback: () => {
for (let i = 0; i < timestamps.length; i++) {
quadraticCore.undo();
}
},
},
});
};
events.on('offlineTransactionsApplied', offlineTransactionsApplied);

return () => {
events.off('offlineTransactions', updateUnsavedTransactions);
events.off('offlineTransactionsApplied', offlineTransactionsApplied);
};
}, []);
}, [addGlobalSnackbar]);

const [open, setOpen] = useState(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,17 @@ export class Multiplayer {
if (this.codeRunning) this.sendCodeRunning(codeRunning);
};

private handleMessage = (e: MessageEvent<MultiplayerClientMessage>) => {
private handleMessage = async (e: MessageEvent<MultiplayerClientMessage>) => {
if (debugWebWorkersMessages) console.log(`[Multiplayer] message: ${e.data.type}`);

switch (e.data.type) {
case 'multiplayerClientState':
this.state = e.data.state;
if (this.state === 'no internet' || this.state === 'waiting to reconnect') {
this.clearAllUsers();
this.brokenConnection = true;
} else if (this.state === 'connected') {
this.brokenConnection = false;
}
events.emit('multiplayerState', this.state);
break;
Expand All @@ -117,6 +120,11 @@ export class Multiplayer {
events.emit('needRefresh', 'force');
break;

case 'multiplayerClientRefreshJwt':
await this.addJwtCookie(true);
this.send({ type: 'clientMultiplayerRefreshJwt', id: e.data.id });
break;

default:
console.warn('Unhandled message type', e.data);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,23 @@ export interface MultiplayerClientReload {
type: 'multiplayerClientReload';
}

export interface MultiplayerClientRefreshJwt {
type: 'multiplayerClientRefreshJwt';
id: number;
}

export interface ClientMultiplayerRefreshJwt {
type: 'clientMultiplayerRefreshJwt';
id: number;
}

export type MultiplayerClientMessage =
| MultiplayerClientState
| MultiplayerClientUserUpdate
| MultiplayerClientUsersInRoom
| MultiplayerClientUserUpdate
| MultiplayerClientReload;
| MultiplayerClientReload
| MultiplayerClientRefreshJwt;

export type ClientMultiplayerMessage =
| ClientMultiplayerInit
Expand All @@ -99,4 +110,5 @@ export type ClientMultiplayerMessage =
| ClientMultiplayerCellEdit
| clientMultiplayerViewport
| clientMultiplayerCodeRunning
| ClientMultiplayerFollow;
| ClientMultiplayerFollow
| ClientMultiplayerRefreshJwt;
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import { cellEditDefault, multiplayerServer } from './multiplayerServer';
declare var self: WorkerGlobalScope & typeof globalThis;

class MultiplayerClient {
// messages pending a reconnect
private waitingForConnection: Record<number, Function> = {};
private id = 0;

constructor() {
self.onmessage = this.handleMessage;
}
Expand Down Expand Up @@ -59,6 +63,15 @@ class MultiplayerClient {
multiplayerServer.userUpdate.follow = e.data.follow;
break;

case 'clientMultiplayerRefreshJwt':
if (e.data.id in this.waitingForConnection) {
this.waitingForConnection[e.data.id]();
delete this.waitingForConnection[e.data.id];
} else {
throw new Error('Expected id to be in waitingForConnection for clientMultiplayerRefreshJwt');
}
break;

default:
console.warn('[multiplayerClient] Unhandled message type', e.data);
}
Expand Down Expand Up @@ -92,6 +105,17 @@ class MultiplayerClient {
type: 'multiplayerClientReload',
});
}

sendRefreshJwt(): Promise<void> {
return new Promise((resolve) => {
const id = this.id++;
this.waitingForConnection[id] = resolve;
this.send({
type: 'multiplayerClientRefreshJwt',
id,
});
});
}
}

export const multiplayerClient = new MultiplayerClient();
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,8 @@ export class MultiplayerServer {
private sessionId?: string;
private fileId?: string;
private user?: User;
private anonymous?: boolean;

private connectionTimeout: number | undefined;
private brokenConnection = false;

private userData?: UserData;

Expand All @@ -78,7 +76,7 @@ export class MultiplayerServer {
this.sessionId = message.sessionId;
this.fileId = message.fileId;
this.user = message.user;
this.anonymous = message.anonymous;

this.userData = {
sheetId: message.sheetId,
selection: message.selection,
Expand All @@ -89,7 +87,7 @@ export class MultiplayerServer {
x: message.x,
y: message.y,
};
this.connect();
this.connect(true);

self.addEventListener('online', () => {
if (this.state === 'no internet') {
Expand All @@ -110,30 +108,34 @@ export class MultiplayerServer {
multiplayerClient.sendState(state);
}

private connect() {
// Attempt to connect to the server.
// @param skipFetchJwt If true, don't fetch a new JWT from the client
// (only true on first connection, since client already provided the jwt)
private async connect(skipFetchJwt = false) {
if (this.state === 'connecting' || this.state === 'waiting to reconnect') {
return;
}

this.state = 'connecting';
if (!skipFetchJwt) {
await multiplayerClient.sendRefreshJwt();
}

this.websocket = new WebSocket(import.meta.env.VITE_QUADRATIC_MULTIPLAYER_URL);
this.websocket.addEventListener('message', this.handleMessage);

this.websocket.addEventListener('close', () => {
if (debugShowMultiplayer) console.log('[Multiplayer] websocket closed unexpectedly.');
this.brokenConnection = true;
this.state = 'waiting to reconnect';
this.reconnect();
});
this.websocket.addEventListener('error', (e) => {
if (debugShowMultiplayer) console.log('[Multiplayer] websocket error', e);
this.brokenConnection = true;
this.state = 'waiting to reconnect';
this.reconnect();
});
this.websocket.addEventListener('open', () => {
if (debugShow) console.log('[Multiplayer] websocket connected.');
this.brokenConnection = false;
this.state = 'connected';
this.enterFileRoom();
this.waitingForConnection.forEach((resolve) => resolve(0));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,11 @@ export interface CoreClientOfflineTransactions {
operations: number;
}

export interface CoreClientOfflineTransactionsApplied {
type: 'coreClientOfflineTransactionsApplied';
timestamps: number[];
}

export interface CoreClientUndoRedo {
type: 'coreClientUndoRedo';
undo: boolean;
Expand Down Expand Up @@ -954,4 +959,5 @@ export type CoreClientMessage =
| CoreClientGetFormatRow
| CoreClientGetFormatCell
| CoreClientSheetMetaFills
| CoreClientSetCursorSelection;
| CoreClientSetCursorSelection
| CoreClientOfflineTransactionsApplied;
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ class QuadraticCore {
} else if (e.data.type === 'coreClientOfflineTransactionStats') {
events.emit('offlineTransactions', e.data.transactions, e.data.operations);
return;
} else if (e.data.type === 'coreClientOfflineTransactionsApplied') {
events.emit('offlineTransactionsApplied', e.data.timestamps);
return;
} else if (e.data.type === 'coreClientUndoRedo') {
events.emit('undoRedo', e.data.undo, e.data.redo);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -630,8 +630,14 @@ class CoreClient {
sendOfflineTransactionStats() {
this.send({
type: 'coreClientOfflineTransactionStats',
transactions: offline.stats.transactions,
operations: offline.stats.operations,
...offline.stats,
});
}

sendOfflineTransactionsApplied(timestamps: number[]) {
this.send({
type: 'coreClientOfflineTransactionsApplied',
timestamps,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ describe('offline', () => {
offline.addUnsentTransaction('3', 'c', 1);
const load = await offline.load();
expect(load?.length).toBe(3);
expect(load).toStrictEqual([
{ transactionId: '1', transactions: 'a', operations: 1 },
{ transactionId: '2', transactions: 'b', operations: 1 },
{ transactionId: '3', transactions: 'c', operations: 1 },
]);
expect(load![0].transactionId).toBe('1');
expect(load![0].transactions).toBe('a');
expect(load![1].transactionId).toBe('2');
expect(load![1].transactions).toBe('b');
expect(load![2].transactionId).toBe('3');
expect(load![2].transactions).toBe('c');
});

it('marks offline transactions as complete', async () => {
Expand All @@ -47,10 +48,10 @@ describe('offline', () => {

const load = await offline.load();
expect(load?.length).toBe(2);
expect(await offline.load()).toStrictEqual([
{ transactionId: '1', transactions: 'a', operations: 1 },
{ transactionId: '3', transactions: 'c', operations: 1 },
]);
expect(load![0].transactionId).toBe('1');
expect(load![0].transactions).toBe('a');
expect(load![1].transactionId).toBe('3');
expect(load![1].transactions).toBe('c');
});

it('checks whether there are any unsent transactions in db', async () => {
Expand Down
Loading

0 comments on commit f94f0e8

Please sign in to comment.