feat: refactor local persistence & fix race condition on SW reload (#5032)

This commit is contained in:
David Luzar 2022-04-11 22:15:49 +02:00 committed by GitHub
parent 58fe639b8d
commit 5359e4fec9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 232 additions and 132 deletions

View File

@ -68,6 +68,7 @@ import {
} from "./reconciliation"; } from "./reconciliation";
import { decryptData } from "../../data/encryption"; import { decryptData } from "../../data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync"; import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
interface CollabState { interface CollabState {
modalIsShown: boolean; modalIsShown: boolean;
@ -109,10 +110,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal; portal: Portal;
fileManager: FileManager; fileManager: FileManager;
excalidrawAPI: Props["excalidrawAPI"]; excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false;
activeIntervalId: number | null; activeIntervalId: number | null;
idleTimeoutId: number | null; idleTimeoutId: number | null;
// marked as private to ensure we don't change it outside this class
private _isCollaborating: boolean = false;
private socketInitializationTimer?: number; private socketInitializationTimer?: number;
private lastBroadcastedOrReceivedSceneVersion: number = -1; private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>(); private collaborators = new Map<string, Collaborator>();
@ -193,6 +195,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
} }
} }
isCollaborating = () => this._isCollaborating;
private onUnload = () => { private onUnload = () => {
this.destroySocketClient({ isUnload: true }); this.destroySocketClient({ isUnload: true });
}; };
@ -203,7 +207,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
); );
if ( if (
this.isCollaborating && this._isCollaborating &&
(this.fileManager.shouldPreventUnload(syncableElements) || (this.fileManager.shouldPreventUnload(syncableElements) ||
!isSavedToFirebase(this.portal, syncableElements)) !isSavedToFirebase(this.portal, syncableElements))
) { ) {
@ -285,7 +289,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.setState({ this.setState({
activeRoomLink: "", activeRoomLink: "",
}); });
this.isCollaborating = false; this._isCollaborating = false;
LocalData.resumeSave("collaboration");
} }
this.lastBroadcastedOrReceivedSceneVersion = -1; this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close(); this.portal.close();
@ -353,7 +358,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
const scenePromise = resolvablePromise<ImportedDataState | null>(); const scenePromise = resolvablePromise<ImportedDataState | null>();
this.isCollaborating = true; this._isCollaborating = true;
LocalData.pauseSave("collaboration");
const { default: socketIOClient } = await import( const { default: socketIOClient } = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client" /* webpackChunkName: "socketIoClient" */ "socket.io-client"
@ -760,7 +766,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.contextValue = {} as CollabAPI; this.contextValue = {} as CollabAPI;
} }
this.contextValue.isCollaborating = () => this.isCollaborating; this.contextValue.isCollaborating = this.isCollaborating;
this.contextValue.username = this.state.username; this.contextValue.username = this.state.username;
this.contextValue.onPointerUpdate = this.onPointerUpdate; this.contextValue.onPointerUpdate = this.onPointerUpdate;
this.contextValue.initializeSocketClient = this.initializeSocketClient; this.contextValue.initializeSocketClient = this.initializeSocketClient;

View File

@ -172,7 +172,7 @@ class Portal {
this.queueFileUpload(); this.queueFileUpload();
if (syncAll && this.collab.isCollaborating) { if (syncAll && this.collab.isCollaborating()) {
await Promise.all([ await Promise.all([
broadcastPromise, broadcastPromise,
this.collab.saveCollabRoomToFirebase(syncableElements), this.collab.saveCollabRoomToFirebase(syncableElements),

View File

@ -0,0 +1,154 @@
/**
* This file deals with saving data state (appState, elements, images, ...)
* locally to the browser.
*
* Notes:
*
* - DataState refers to full state of the app: appState, elements, images,
* though some state is saved separately (collab username, library) for one
* reason or another. We also save different data to different sotrage
* (localStorage, indexedDB).
*/
import { createStore, keys, del, getMany, set } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../appState";
import { clearElementsForLocalStorage } from "../../element";
import { ExcalidrawElement, FileId } from "../../element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
import { debounce } from "../../utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store");
class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
const allIds = await keys(filesStore);
for (const id of allIds) {
if (!opts.currentFileIds.includes(id as FileId)) {
del(id, filesStore);
}
}
};
}
const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
type SavingLockTypes = "collaboration";
export class LocalData {
private static _save = debounce(
async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
saveDataStateToLocalStorage(elements, appState);
await this.fileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
/** Saves DataState, including files. Bails if saving is paused */
static save = (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
// we need to make the `isSavePaused` check synchronously (undebounced)
if (!this.isSavePaused()) {
this._save(elements, appState, files, onFilesSaved);
}
};
static flushSave = () => {
this._save.flush();
};
private static locker = new Locker<SavingLockTypes>();
static pauseSave = (lockType: SavingLockTypes) => {
this.locker.lock(lockType);
};
static resumeSave = (lockType: SavingLockTypes) => {
this.locker.unlock(lockType);
};
static isSavePaused = () => {
return document.hidden || this.locker.isLocked();
};
// ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({
getFiles(ids) {
return getMany(ids, filesStore).then(
(filesData: (BinaryFileData | undefined)[]) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
filesData.forEach((data, index) => {
const id = ids[index];
if (data) {
loadedFiles.push(data);
} else {
erroredFiles.set(id, true);
}
});
return { loadedFiles, erroredFiles };
},
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
// before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed
// before an IDB write finishes will read the latest value.
updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
await Promise.all(
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, true);
} catch (error: any) {
console.error(error);
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
},
});
}

View File

@ -0,0 +1,18 @@
export class Locker<T extends string> {
private locks = new Map<T, true>();
lock = (lockType: T) => {
this.locks.set(lockType, true);
};
/** @returns whether no locks remaining */
unlock = (lockType: T) => {
this.locks.delete(lockType);
return !this.isLocked();
};
/** @returns whether some (or specific) locks are present */
isLocked(lockType?: T) {
return lockType ? this.locks.has(lockType) : !!this.locks.size;
}
}

View File

@ -5,7 +5,6 @@ import {
getDefaultAppState, getDefaultAppState,
} from "../../appState"; } from "../../appState";
import { clearElementsForLocalStorage } from "../../element"; import { clearElementsForLocalStorage } from "../../element";
import { updateBrowserStateVersion } from "./tabSync";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
export const saveUsernameToLocalStorage = (username: string) => { export const saveUsernameToLocalStorage = (username: string) => {
@ -34,26 +33,6 @@ export const importUsernameFromLocalStorage = (): string | null => {
return null; return null;
}; };
export const saveToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
export const importFromLocalStorage = () => { export const importFromLocalStorage = () => {
let savedElements = null; let savedElements = null;
let savedState = null; let savedState = null;

View File

@ -28,7 +28,6 @@ import {
AppState, AppState,
LibraryItems, LibraryItems,
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
BinaryFileData,
BinaryFiles, BinaryFiles,
} from "../types"; } from "../types";
import { import {
@ -42,7 +41,6 @@ import {
} from "../utils"; } from "../utils";
import { import {
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
STORAGE_KEYS, STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT, SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants"; } from "./app_constants";
@ -57,7 +55,6 @@ import {
getLibraryItemsFromStorage, getLibraryItemsFromStorage,
importFromLocalStorage, importFromLocalStorage,
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
saveToLocalStorage,
} from "./data/localStorage"; } from "./data/localStorage";
import CustomStats from "./CustomStats"; import CustomStats from "./CustomStats";
import { restoreAppState, RestoredDataState } from "../data/restore"; import { restoreAppState, RestoredDataState } from "../data/restore";
@ -67,72 +64,12 @@ import { shield } from "../components/icons";
import "./index.scss"; import "./index.scss";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus"; import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
import { getMany, set, del, keys, createStore } from "idb-keyval"; import { updateStaleImageStatuses } from "./data/FileManager";
import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks"; import { isInitializedImageElement } from "../element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase"; import { loadFilesFromFirebase } from "./data/firebase";
import { import { LocalData } from "./data/LocalData";
isBrowserStorageStateNewer, import { isBrowserStorageStateNewer } from "./data/tabSync";
updateBrowserStateVersion,
} from "./data/tabSync";
const filesStore = createStore("files-db", "files-store");
const clearObsoleteFilesFromIndexedDB = async (opts: {
currentFileIds: FileId[];
}) => {
const allIds = await keys(filesStore);
for (const id of allIds) {
if (!opts.currentFileIds.includes(id as FileId)) {
del(id, filesStore);
}
}
};
const localFileStorage = new FileManager({
getFiles(ids) {
return getMany(ids, filesStore).then(
(filesData: (BinaryFileData | undefined)[]) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
filesData.forEach((data, index) => {
const id = ids[index];
if (data) {
loadedFiles.push(data);
} else {
erroredFiles.set(id, true);
}
});
return { loadedFiles, erroredFiles };
},
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
// before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed
// before an IDB write finishes will read the latest value.
updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
await Promise.all(
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, true);
} catch (error: any) {
console.error(error);
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
},
});
const languageDetector = new LanguageDetector(); const languageDetector = new LanguageDetector();
languageDetector.init({ languageDetector.init({
@ -143,28 +80,6 @@ languageDetector.init({
checkWhitelist: false, checkWhitelist: false,
}); });
const saveDebounced = debounce(
async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
saveToLocalStorage(elements, appState);
await localFileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
const onBlur = () => {
saveDebounced.flush();
};
const initializeScene = async (opts: { const initializeScene = async (opts: {
collabAPI: CollabAPI; collabAPI: CollabAPI;
}): Promise< }): Promise<
@ -366,7 +281,7 @@ const ExcalidrawWrapper = () => {
}); });
} else if (isInitialLoad) { } else if (isInitialLoad) {
if (fileIds.length) { if (fileIds.length) {
localFileStorage LocalData.fileStorage
.getFiles(fileIds) .getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => { .then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) { if (loadedFiles.length) {
@ -381,7 +296,7 @@ const ExcalidrawWrapper = () => {
} }
// on fresh load, clear unused files from IDB (from previous // on fresh load, clear unused files from IDB (from previous
// session) // session)
clearObsoleteFilesFromIndexedDB({ currentFileIds: fileIds }); LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
} }
} }
@ -458,7 +373,7 @@ const ExcalidrawWrapper = () => {
return acc; return acc;
}, [] as FileId[]) || []; }, [] as FileId[]) || [];
if (fileIds.length) { if (fileIds.length) {
localFileStorage LocalData.fileStorage
.getFiles(fileIds) .getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => { .then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) { if (loadedFiles.length) {
@ -475,28 +390,50 @@ const ExcalidrawWrapper = () => {
} }
}, SYNC_BROWSER_TABS_TIMEOUT); }, SYNC_BROWSER_TABS_TIMEOUT);
const onUnload = () => {
LocalData.flushSave();
};
const visibilityChange = (event: FocusEvent | Event) => {
if (event.type === EVENT.BLUR || document.hidden) {
LocalData.flushSave();
}
if (
event.type === EVENT.VISIBILITY_CHANGE ||
event.type === EVENT.FOCUS
) {
syncData();
}
};
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false); window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.addEventListener(EVENT.UNLOAD, onBlur, false); window.addEventListener(EVENT.UNLOAD, onUnload, false);
window.addEventListener(EVENT.BLUR, onBlur, false); window.addEventListener(EVENT.BLUR, visibilityChange, false);
document.addEventListener(EVENT.VISIBILITY_CHANGE, syncData, false); document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
window.addEventListener(EVENT.FOCUS, syncData, false); window.addEventListener(EVENT.FOCUS, visibilityChange, false);
return () => { return () => {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false); window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.removeEventListener(EVENT.UNLOAD, onBlur, false); window.removeEventListener(EVENT.UNLOAD, onUnload, false);
window.removeEventListener(EVENT.BLUR, onBlur, false); window.removeEventListener(EVENT.BLUR, visibilityChange, false);
window.removeEventListener(EVENT.FOCUS, syncData, false); window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
document.removeEventListener(EVENT.VISIBILITY_CHANGE, syncData, false); document.removeEventListener(
EVENT.VISIBILITY_CHANGE,
visibilityChange,
false,
);
clearTimeout(titleTimeout); clearTimeout(titleTimeout);
}; };
}, [collabAPI, excalidrawAPI]); }, [collabAPI, excalidrawAPI]);
useEffect(() => { useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => { const unloadHandler = (event: BeforeUnloadEvent) => {
saveDebounced.flush(); LocalData.flushSave();
if ( if (
excalidrawAPI && excalidrawAPI &&
localFileStorage.shouldPreventUnload(excalidrawAPI.getSceneElements()) LocalData.fileStorage.shouldPreventUnload(
excalidrawAPI.getSceneElements(),
)
) { ) {
preventUnload(event); preventUnload(event);
} }
@ -518,8 +455,12 @@ const ExcalidrawWrapper = () => {
) => { ) => {
if (collabAPI?.isCollaborating()) { if (collabAPI?.isCollaborating()) {
collabAPI.broadcastElements(elements); collabAPI.broadcastElements(elements);
} else { }
saveDebounced(elements, appState, files, () => {
// this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) {
LocalData.save(elements, appState, files, () => {
if (excalidrawAPI) { if (excalidrawAPI) {
let didChange = false; let didChange = false;
@ -527,7 +468,9 @@ const ExcalidrawWrapper = () => {
const elements = excalidrawAPI const elements = excalidrawAPI
.getSceneElementsIncludingDeleted() .getSceneElementsIncludingDeleted()
.map((element) => { .map((element) => {
if (localFileStorage.shouldUpdateImageElementStatus(element)) { if (
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
) {
didChange = true; didChange = true;
const newEl = newElementWith(element, { status: "saved" }); const newEl = newElementWith(element, { status: "saved" });
if (pendingImageElement === element) { if (pendingImageElement === element) {
@ -687,7 +630,7 @@ const ExcalidrawWrapper = () => {
}; };
const onRoomClose = useCallback(() => { const onRoomClose = useCallback(() => {
localFileStorage.reset(); LocalData.fileStorage.reset();
}, []); }, []);
return ( return (