Skip to content

Commit

Permalink
Improved Store API, more resilient handling of actions to eliminate p…
Browse files Browse the repository at this point in the history
…otential concurrency issues
  • Loading branch information
Mrazator committed Apr 18, 2024
1 parent 530617b commit 1f2cb61
Show file tree
Hide file tree
Showing 15 changed files with 333 additions and 187 deletions.
7 changes: 5 additions & 2 deletions excalidraw-app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import {
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
} from "../packages/excalidraw/index";
StoreAction,
} from "../packages/excalidraw";
import {
AppState,
ExcalidrawImperativeAPI,
Expand Down Expand Up @@ -438,7 +439,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
commitToStore: true,
storeAction: StoreAction.CAPTURE,
});
}
});
Expand Down Expand Up @@ -469,6 +470,7 @@ const ExcalidrawWrapper = () => {
setLangCode(langCode);
excalidrawAPI.updateScene({
...localDataState,
storeAction: StoreAction.UPDATE,
});
LibraryIndexedDBAdapter.load().then((data) => {
if (data) {
Expand Down Expand Up @@ -604,6 +606,7 @@ const ExcalidrawWrapper = () => {
if (didChange) {
excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});
}
}
Expand Down
6 changes: 5 additions & 1 deletion excalidraw-app/collab/Collab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import {
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import {
StoreAction,
getSceneVersion,
restoreElements,
zoomToFitBounds,
} from "../../packages/excalidraw/index";
} from "../../packages/excalidraw";
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
import {
assertNever,
Expand Down Expand Up @@ -356,6 +357,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {

this.excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});
}
};
Expand Down Expand Up @@ -506,6 +508,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// to database even if deleted before creating the room.
this.excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});

this.saveCollabRoomToFirebase(getSyncableElements(elements));
Expand Down Expand Up @@ -743,6 +746,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
) => {
this.excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});

this.loadImageFiles();
Expand Down
2 changes: 2 additions & 0 deletions excalidraw-app/collab/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import throttle from "lodash.throttle";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { encryptData } from "../../packages/excalidraw/data/encryption";
import type { Socket } from "socket.io-client";
import { StoreAction } from "../../packages/excalidraw";

class Portal {
collab: TCollabClass;
Expand Down Expand Up @@ -127,6 +128,7 @@ class Portal {
}
return element;
}),
storeAction: StoreAction.UPDATE,
});
}, FILE_UPLOAD_TIMEOUT);

Expand Down
2 changes: 2 additions & 0 deletions excalidraw-app/data/FileManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { StoreAction } from "../../packages/excalidraw";
import { compressData } from "../../packages/excalidraw/data/encode";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
Expand Down Expand Up @@ -238,5 +239,6 @@ export const updateStaleImageStatuses = (params: {
}
return element;
}),
storeAction: StoreAction.UPDATE,
});
};
1 change: 0 additions & 1 deletion excalidraw-app/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,6 @@ export const loadScene = async (
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
commitToStore: false,
};
};

Expand Down
10 changes: 6 additions & 4 deletions excalidraw-app/tests/collab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
createRedoAction,
createUndoAction,
} from "../../packages/excalidraw/actions/actionHistory";
import { newElementWith } from "../../packages/excalidraw";
import { StoreAction, newElementWith } from "../../packages/excalidraw";

const { h } = window;

Expand Down Expand Up @@ -90,15 +90,15 @@ describe("collaboration", () => {

updateSceneData({
elements: syncInvalidIndices([rect1, rect2]),
commitToStore: true,
storeAction: StoreAction.CAPTURE,
});

updateSceneData({
elements: syncInvalidIndices([
rect1,
newElementWith(h.elements[1], { isDeleted: true }),
]),
commitToStore: true,
storeAction: StoreAction.CAPTURE,
});

await waitFor(() => {
Expand Down Expand Up @@ -145,6 +145,7 @@ describe("collaboration", () => {
// simulate force deleting the element remotely
updateSceneData({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});

await waitFor(() => {
Expand Down Expand Up @@ -182,7 +183,7 @@ describe("collaboration", () => {
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
commitToStore: true,
storeAction: StoreAction.CAPTURE,
});

await waitFor(() => {
Expand Down Expand Up @@ -217,6 +218,7 @@ describe("collaboration", () => {
// simulate force deleting the element remotely
updateSceneData({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});

// snapshot was correctly updated and marked the element as deleted
Expand Down
4 changes: 2 additions & 2 deletions packages/excalidraw/actions/actionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import { SceneElementsMap } from "../element/types";
import { IStore, StoreAction } from "../store";
import { Store, StoreAction } from "../store";
import { useEmitter } from "../hooks/useEmitter";

const writeData = (
Expand Down Expand Up @@ -40,7 +40,7 @@ const writeData = (
return { storeAction: StoreAction.NONE };
};

type ActionCreator = (history: History, store: IStore) => Action;
type ActionCreator = (history: History, store: Store) => Action;

export const createUndoAction: ActionCreator = (history, store) => ({
name: "undo",
Expand Down
4 changes: 2 additions & 2 deletions packages/excalidraw/actions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
UIAppState,
} from "../types";
import { MarkOptional } from "../utility-types";
import { StoreAction } from "../store";
import { StoreActionType } from "../store";

export type ActionSource =
| "ui"
Expand All @@ -26,7 +26,7 @@ export type ActionResult =
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
storeAction: keyof typeof StoreAction;
storeAction: StoreActionType;
replaceFiles?: boolean;
}
| false;
Expand Down
86 changes: 37 additions & 49 deletions packages/excalidraw/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,6 @@ import {
ExcalidrawIframeElement,
ExcalidrawEmbeddableElement,
Ordered,
OrderedExcalidrawElement,
} from "../element/types";
import { getCenter, getDistance } from "../gesture";
import {
Expand Down Expand Up @@ -412,7 +411,7 @@ import { ElementCanvasButton } from "./MagicButton";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import FollowMode from "./FollowMode/FollowMode";
import { IStore, Store, StoreAction } from "../store";
import { Store, StoreAction } from "../store";
import { AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animated-trail";
import { LaserTrails } from "../laser-trails";
Expand Down Expand Up @@ -543,7 +542,7 @@ class App extends React.Component<AppProps, AppState> {
public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined;
public id: string;
private store: IStore;
private store: Store;
private history: History;
private excalidrawContainerValue: {
container: HTMLDivElement | null;
Expand Down Expand Up @@ -2123,7 +2122,7 @@ class App extends React.Component<AppProps, AppState> {
}
return el;
}),
commitToStore: true,
storeAction: StoreAction.CAPTURE,
});
}
},
Expand Down Expand Up @@ -2810,7 +2809,7 @@ class App extends React.Component<AppProps, AppState> {
);
}

this.store.capture(elementsMap, this.state);
this.store.commit(elementsMap, this.state);

// Do not notify consumers if we're still loading the scene. Among other
// potential issues, this fixes a case where the tab isn't focused during
Expand Down Expand Up @@ -3683,51 +3682,38 @@ class App extends React.Component<AppProps, AppState> {
elements?: SceneData["elements"];
appState?: Pick<AppState, K> | null;
collaborators?: SceneData["collaborators"];
commitToStore?: SceneData["commitToStore"];
storeAction?: SceneData["storeAction"];
}) => {
const nextElements = syncInvalidIndices(sceneData.elements ?? []);

if (sceneData.commitToStore) {
this.store.shouldCaptureIncrement();
}

if (sceneData.elements || sceneData.appState) {
let nextCommittedAppState = this.state;
let nextCommittedElements: Map<string, OrderedExcalidrawElement>;

if (sceneData.appState) {
nextCommittedAppState = {
...this.state,
...sceneData.appState, // Here we expect just partial appState
};
}
if (sceneData.storeAction && sceneData.storeAction !== StoreAction.NONE) {
const prevCommittedAppState = this.store.snapshot.appState;
const prevCommittedElements = this.store.snapshot.elements;

const prevElements = this.scene.getElementsIncludingDeleted();

if (sceneData.elements) {
/**
* We need to schedule a snapshot update, as in case `commitToStore` is false (i.e. remote update),
* as it's essential for computing local changes after the async action is completed (i.e. not to include remote changes in the diff).
*
* This is also a breaking change for all local `updateScene` calls without set `commitToStore` to true,
* as it makes such updates impossible to undo (previously they were undone coincidentally with the switch to the whole snapshot captured by the history).
*
* WARN: be careful here as moving it elsewhere could break the history for remote client without noticing
* - we need to find a way to test two concurrent client updates simultaneously, while having access to both stores & histories.
*/
this.store.shouldUpdateSnapshot();
const nextCommittedAppState = sceneData.appState
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
: prevCommittedAppState;

// TODO#7348: deprecate once exchanging just store increments between clients
nextCommittedElements = this.store.ignoreUncomittedElements(
arrayToMap(prevElements),
arrayToMap(nextElements),
const nextCommittedElements = sceneData.elements
? this.store.filterUncomittedElements(
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
arrayToMap(nextElements), // We expect all (already reconciled) elements
)
: prevCommittedElements;

// WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
// NOT scheduling actions after re-render, as it might cause unexpected concurrency issues
if (sceneData.storeAction === StoreAction.CAPTURE) {
this.store.captureIncrement(
nextCommittedElements,
nextCommittedAppState,
);
} else if (sceneData.storeAction === StoreAction.UPDATE) {
this.store.updateSnapshot(
nextCommittedElements,
nextCommittedAppState,
);
} else {
nextCommittedElements = arrayToMap(prevElements);
}

// WARN: Performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
this.store.capture(nextCommittedElements, nextCommittedAppState);
}

if (sceneData.appState) {
Expand Down Expand Up @@ -5704,6 +5690,7 @@ class App extends React.Component<AppProps, AppState> {
this.state,
),
},
storeAction: StoreAction.UPDATE,
});
return;
}
Expand Down Expand Up @@ -7993,6 +7980,7 @@ class App extends React.Component<AppProps, AppState> {
appState: {
draggingElement: null,
},
storeAction: StoreAction.UPDATE,
});

return;
Expand Down Expand Up @@ -8165,6 +8153,7 @@ class App extends React.Component<AppProps, AppState> {
elements: this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== resizingElement.id),
storeAction: StoreAction.UPDATE,
});
}

Expand Down Expand Up @@ -9228,13 +9217,12 @@ class App extends React.Component<AppProps, AppState> {
}

if (ret.type === MIME_TYPES.excalidraw) {
// Restore the fractional indices by mutating elements and update the
// store snapshot, otherwise we would end up with duplicate indices
// restore the fractional indices by mutating elements
syncInvalidIndices(elements.concat(ret.data.elements));
this.store.snapshot = this.store.snapshot.clone(
arrayToMap(elements),
this.state,
);

// update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
this.store.updateSnapshot(arrayToMap(elements), this.state);

this.setState({ isLoading: true });
this.syncActionResult({
...ret.data,
Expand Down
4 changes: 2 additions & 2 deletions packages/excalidraw/element/linearElementEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { DRAGGING_THRESHOLD } from "../constants";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { IStore } from "../store";
import { Store } from "../store";

const editorMidPointsCache: {
version: number | null;
Expand Down Expand Up @@ -642,7 +642,7 @@ export class LinearElementEditor {
static handlePointerDown(
event: React.PointerEvent<HTMLElement>,
appState: AppState,
store: IStore,
store: Store,
scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor,
app: AppClassProperties,
Expand Down
2 changes: 2 additions & 0 deletions packages/excalidraw/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ export {
bumpVersion,
} from "./element/mutateElement";

export { StoreAction } from "./store";

export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";

export {
Expand Down

0 comments on commit 1f2cb61

Please sign in to comment.