refactor: rewrite collabWrapper to remove TDZs and simplify (#2834)

This commit is contained in:
David Luzar 2021-01-25 10:47:35 +01:00 committed by GitHub
parent 03f6d9c783
commit 0a0be839b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 312 additions and 264 deletions

View file

@ -6,10 +6,11 @@ import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types";
import {
getElementMap,
getSceneVersion,
getSyncableElements,
} from "../../packages/excalidraw/index";
import { AppState, Collaborator, Gesture } from "../../types";
import { Collaborator, Gesture } from "../../types";
import { resolvablePromise, withBatchedUpdates } from "../../utils";
import {
INITIAL_SCENE_UPDATE_TIMEOUT,
@ -31,6 +32,7 @@ import {
} from "../data/localStorage";
import Portal from "./Portal";
import RoomDialog from "./RoomDialog";
import { createInverseContext } from "../../createInverseContext";
interface CollabState {
isCollaborating: boolean;
@ -56,17 +58,21 @@ type ReconciledElements = readonly ExcalidrawElement[] & {
};
interface Props {
children: (collab: CollabAPI) => React.ReactNode;
// NOTE not type-safe because the refObject may in fact not be initialized
// with ExcalidrawImperativeAPI yet
excalidrawRef: React.MutableRefObject<ExcalidrawImperativeAPI>;
excalidrawAPI: ExcalidrawImperativeAPI;
}
const {
Context: CollabContext,
Consumer: CollabContextConsumer,
Provider: CollabContextProvider,
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
export { CollabContext, CollabContextConsumer };
class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal;
excalidrawAPI: Props["excalidrawAPI"];
private socketInitializationTimer?: NodeJS.Timeout;
private excalidrawRef: Props["excalidrawRef"];
excalidrawAppState?: AppState;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>();
@ -80,7 +86,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
activeRoomLink: "",
};
this.portal = new Portal(this);
this.excalidrawRef = props.excalidrawRef;
this.excalidrawAPI = props.excalidrawAPI;
}
componentDidMount() {
@ -142,7 +148,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
saveCollabRoomToFirebase = async (
syncableElements: ExcalidrawElement[] = getSyncableElements(
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
) => {
try {
@ -154,13 +160,13 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
openPortal = async () => {
window.history.pushState({}, APP_NAME, await generateCollaborationLink());
const elements = this.excalidrawRef.current!.getSceneElements();
const elements = this.excalidrawAPI.getSceneElements();
// remove deleted elements from elements array & history to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
this.excalidrawRef.current!.history.clear();
this.excalidrawRef.current!.updateScene({
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
});
@ -175,7 +181,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
private destroySocketClient = () => {
this.collaborators = new Map();
this.excalidrawRef.current!.updateScene({
this.excalidrawAPI.updateScene({
collaborators: this.collaborators,
});
this.setState({
@ -265,7 +271,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
user.selectedElementIds = selectedElementIds;
user.username = username;
collaborators.set(socketId, user);
this.excalidrawRef.current!.updateScene({
this.excalidrawAPI.updateScene({
collaborators,
});
break;
@ -300,7 +306,55 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
private reconcileElements = (
elements: readonly ExcalidrawElement[],
): ReconciledElements => {
const newElements = this.portal.reconcileElements(elements);
const currentElements = this.getSceneElementsIncludingDeleted();
// create a map of ids so we don't have to iterate
// over the array more than once.
const localElementMap = getElementMap(currentElements);
const appState = this.excalidrawAPI.getAppState();
// Reconcile
const newElements: readonly ExcalidrawElement[] = elements
.reduce((elements, element) => {
// if the remote element references one that's currently
// edited on local, skip it (it'll be added in the next step)
if (
element.id === appState.editingElement?.id ||
element.id === appState.resizingElement?.id ||
element.id === appState.draggingElement?.id
) {
return elements;
}
if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version > element.version
) {
elements.push(localElementMap[element.id]);
delete localElementMap[element.id];
} else if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version === element.version &&
localElementMap[element.id].versionNonce !== element.versionNonce
) {
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
if (localElementMap[element.id].versionNonce < element.versionNonce) {
elements.push(localElementMap[element.id]);
} else {
// it should be highly unlikely that the two versionNonces are the same. if we are
// really worried about this, we can replace the versionNonce with the socket id.
elements.push(element);
}
delete localElementMap[element.id];
} else {
elements.push(element);
delete localElementMap[element.id];
}
return elements;
}, [] as Mutable<typeof elements>)
// add local elements that weren't deleted or on remote
.concat(...Object.values(localElementMap));
// Avoid broadcasting to the rest of the collaborators the scene
// we just received!
@ -319,10 +373,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}: { init?: boolean; initFromSnapshot?: boolean } = {},
) => {
if (init || initFromSnapshot) {
this.excalidrawRef.current!.setScrollToCenter(elements);
this.excalidrawAPI.setScrollToCenter(elements);
}
this.excalidrawRef.current!.updateScene({
this.excalidrawAPI.updateScene({
elements,
commitToHistory: !!init,
});
@ -331,7 +385,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
// when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
this.excalidrawRef.current!.history.clear();
this.excalidrawAPI.history.clear();
};
setCollaborators(sockets: string[]) {
@ -347,7 +401,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}
}
this.collaborators = collaborators;
this.excalidrawRef.current!.updateScene({ collaborators });
this.excalidrawAPI.updateScene({ collaborators });
});
}
@ -360,7 +414,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
};
public getSceneElementsIncludingDeleted = () => {
return this.excalidrawRef.current!.getSceneElementsIncludingDeleted();
return this.excalidrawAPI.getSceneElementsIncludingDeleted();
};
onPointerUpdate = (payload: {
@ -373,11 +427,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.portal.broadcastMouseLocation(payload);
};
broadcastElements = (
elements: readonly ExcalidrawElement[],
state: AppState,
) => {
this.excalidrawAppState = state;
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
if (
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
@ -396,7 +446,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.portal.broadcastScene(
SCENE.UPDATE,
getSyncableElements(
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
true,
);
@ -425,8 +475,23 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
});
};
/** PRIVATE. Use `this.getContextValue()` instead. */
private contextValue: CollabAPI | null = null;
/** Getter of context value. Returned object is stable. */
getContextValue = (): CollabAPI => {
this.contextValue = this.contextValue || ({} as CollabAPI);
this.contextValue.isCollaborating = this.state.isCollaborating;
this.contextValue.username = this.state.username;
this.contextValue.onPointerUpdate = this.onPointerUpdate;
this.contextValue.initializeSocketClient = this.initializeSocketClient;
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
this.contextValue.broadcastElements = this.broadcastElements;
return this.contextValue;
};
render() {
const { children } = this.props;
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
return (
@ -450,14 +515,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
onClose={() => this.setState({ errorMessage: "" })}
/>
)}
{children({
isCollaborating: this.state.isCollaborating,
username: this.state.username,
onPointerUpdate: this.onPointerUpdate,
initializeSocketClient: this.initializeSocketClient,
onCollabButtonClick: this.onCollabButtonClick,
broadcastElements: this.broadcastElements,
})}
<CollabContextProvider
value={{
api: this.getContextValue(),
}}
/>
</>
);
}

View file

@ -6,23 +6,20 @@ import {
import CollabWrapper from "./CollabWrapper";
import {
getElementMap,
getSyncableElements,
} from "../../packages/excalidraw/index";
import { getSyncableElements } from "../../packages/excalidraw/index";
import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants";
class Portal {
app: CollabWrapper;
collab: CollabWrapper;
socket: SocketIOClient.Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null;
roomKey: string | null = null;
broadcastedElementVersions: Map<string, number> = new Map();
constructor(app: CollabWrapper) {
this.app = app;
constructor(collab: CollabWrapper) {
this.collab = collab;
}
open(socket: SocketIOClient.Socket, id: string, key: string) {
@ -30,7 +27,7 @@ class Portal {
this.roomId = id;
this.roomKey = key;
// Initialize socket listeners (moving from App)
// Initialize socket listeners
this.socket.on("init-room", () => {
if (this.socket) {
this.socket.emit("join-room", this.roomId);
@ -39,12 +36,12 @@ class Portal {
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
SCENE.INIT,
getSyncableElements(this.app.getSceneElementsIncludingDeleted()),
getSyncableElements(this.collab.getSceneElementsIncludingDeleted()),
/* syncAll */ true,
);
});
this.socket.on("room-user-change", (clients: string[]) => {
this.app.setCollaborators(clients);
this.collab.setCollaborators(clients);
});
}
@ -125,10 +122,10 @@ class Portal {
data as SocketUpdateData,
);
if (syncAll && this.app.state.isCollaborating) {
if (syncAll && this.collab.state.isCollaborating) {
await Promise.all([
broadcastPromise,
this.app.saveCollabRoomToFirebase(syncableElements),
this.collab.saveCollabRoomToFirebase(syncableElements),
]);
} else {
await broadcastPromise;
@ -146,9 +143,9 @@ class Portal {
socketId: this.socket.id,
pointer: payload.pointer,
button: payload.button || "up",
selectedElementIds:
this.app.excalidrawAppState?.selectedElementIds || {},
username: this.app.state.username,
selectedElementIds: this.collab.excalidrawAPI.getAppState()
.selectedElementIds,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
@ -157,62 +154,6 @@ class Portal {
);
}
};
reconcileElements = (
sceneElements: readonly ExcalidrawElement[],
): readonly ExcalidrawElement[] => {
const currentElements = this.app.getSceneElementsIncludingDeleted();
// create a map of ids so we don't have to iterate
// over the array more than once.
const localElementMap = getElementMap(currentElements);
// Reconcile
return (
sceneElements
.reduce((elements, element) => {
// if the remote element references one that's currently
// edited on local, skip it (it'll be added in the next step)
if (
element.id === this.app.excalidrawAppState?.editingElement?.id ||
element.id === this.app.excalidrawAppState?.resizingElement?.id ||
element.id === this.app.excalidrawAppState?.draggingElement?.id
) {
return elements;
}
if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version > element.version
) {
elements.push(localElementMap[element.id]);
delete localElementMap[element.id];
} else if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version === element.version &&
localElementMap[element.id].versionNonce !== element.versionNonce
) {
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
if (
localElementMap[element.id].versionNonce < element.versionNonce
) {
elements.push(localElementMap[element.id]);
} else {
// it should be highly unlikely that the two versionNonces are the same. if we are
// really worried about this, we can replace the versionNonce with the socket id.
elements.push(element);
}
delete localElementMap[element.id];
} else {
elements.push(element);
delete localElementMap[element.id];
}
return elements;
}, [] as Mutable<typeof sceneElements>)
// add local elements that weren't deleted or on remote
.concat(...Object.values(localElementMap))
);
};
}
export default Portal;

View file

@ -1,6 +1,7 @@
import LanguageDetector from "i18next-browser-languagedetector";
import React, {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
@ -17,12 +18,13 @@ import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { Language, t } from "../i18n";
import Excalidraw, {
defaultLang,
languages,
} from "../packages/excalidraw/index";
import { AppState, ExcalidrawAPIRefValue } from "../types";
import { AppState } from "../types";
import {
debounce,
getVersion,
@ -30,7 +32,11 @@ import {
resolvablePromise,
} from "../utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
import CollabWrapper, {
CollabAPI,
CollabContext,
CollabContextConsumer,
} from "./collab/CollabWrapper";
import { LanguageList } from "./components/LanguageList";
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
import { loadFromFirebase } from "./data/firebase";
@ -49,15 +55,6 @@ languageDetector.init({
checkWhitelist: false,
});
const excalidrawRef: React.MutableRefObject<
MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
> = {
current: {
readyPromise: resolvablePromise(),
ready: false,
},
};
const saveDebounced = debounce(
(elements: readonly ExcalidrawElement[], state: AppState) => {
saveToLocalStorage(elements, state);
@ -191,7 +188,7 @@ const initializeScene = async (opts: {
return null;
};
function ExcalidrawWrapper(props: { collab: CollabAPI }) {
function ExcalidrawWrapper() {
// dimensions
// ---------------------------------------------------------------------------
@ -226,35 +223,40 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
}
const { collab } = props;
useEffect(() => {
// Delayed so that the app has a time to load the latest SW
setTimeout(() => {
trackEvent("load", "version", getVersion());
}, VERSION_TIMEOUT);
}, []);
excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
initializeScene({
resetScene: excalidrawApi.resetScene,
initializeSocketClient: collab.initializeSocketClient,
}).then((scene) => {
initialStatePromiseRef.current.promise.resolve(scene);
});
const [
excalidrawAPI,
excalidrawRefCallback,
] = useCallbackRefState<ExcalidrawImperativeAPI>();
const collabAPI = useContext(CollabContext)?.api;
useEffect(() => {
if (!collabAPI || !excalidrawAPI) {
return;
}
initializeScene({
resetScene: excalidrawAPI.resetScene,
initializeSocketClient: collabAPI.initializeSocketClient,
}).then((scene) => {
initialStatePromiseRef.current.promise.resolve(scene);
});
const onHashChange = (_: HashChangeEvent) => {
const api = excalidrawRef.current!;
if (!api.ready) {
return;
}
if (window.location.hash.length > 1) {
initializeScene({
resetScene: api.resetScene,
initializeSocketClient: collab.initializeSocketClient,
resetScene: excalidrawAPI.resetScene,
initializeSocketClient: collabAPI.initializeSocketClient,
}).then((scene) => {
if (scene) {
api.updateScene(scene);
excalidrawAPI.updateScene(scene);
}
});
}
@ -273,7 +275,7 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
window.removeEventListener(EVENT.BLUR, onBlur, false);
clearTimeout(titleTimeout);
};
}, [collab.initializeSocketClient]);
}, [collabAPI, excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
@ -284,8 +286,8 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
appState: AppState,
) => {
saveDebounced(elements, appState);
if (collab.isCollaborating) {
collab.broadcastElements(elements, appState);
if (collabAPI?.isCollaborating) {
collabAPI.broadcastElements(elements);
}
};
@ -343,19 +345,20 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
return (
<>
<Excalidraw
ref={excalidrawRef}
ref={excalidrawRefCallback}
onChange={onChange}
width={dimensions.width}
height={dimensions.height}
initialData={initialStatePromiseRef.current.promise}
user={{ name: collab.username }}
onCollabButtonClick={collab.onCollabButtonClick}
isCollaborating={collab.isCollaborating}
onPointerUpdate={collab.onPointerUpdate}
user={{ name: collabAPI?.username }}
onCollabButtonClick={collabAPI?.onCollabButtonClick}
isCollaborating={collabAPI?.isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
onExportToBackend={onExportToBackend}
renderFooter={renderFooter}
langCode={langCode}
/>
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{errorMessage && (
<ErrorDialog
message={errorMessage}
@ -369,13 +372,9 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
export default function ExcalidrawApp() {
return (
<TopErrorBoundary>
<CollabWrapper
excalidrawRef={
excalidrawRef as React.MutableRefObject<ExcalidrawImperativeAPI>
}
>
{(collab) => <ExcalidrawWrapper collab={collab} />}
</CollabWrapper>
<CollabContextConsumer>
<ExcalidrawWrapper />
</CollabContextConsumer>
</TopErrorBoundary>
);
}