feat: reconcile when saving to firebase (#4991)

* naming tweaks

* do not mark local element as duplicate when there's no remote counterpart

* merge instead of overwrite elements when saving to firebase & reconcile local state

* decouple syncing from persistence

* fix ts

* clarify doc

* fix reconciliation not removing duplicates
This commit is contained in:
David Luzar 2022-04-17 22:40:39 +02:00 committed by GitHub
parent 3840e2f4e6
commit 4d13dbf625
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 269 additions and 98 deletions

View File

@ -11,12 +11,12 @@ export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631) // 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000; export const FILE_CACHE_MAX_AGE_SEC = 31536000;
export const BROADCAST = { export const WS_EVENTS = {
SERVER_VOLATILE: "server-volatile-broadcast", SERVER_VOLATILE: "server-volatile-broadcast",
SERVER: "server-broadcast", SERVER: "server-broadcast",
}; };
export enum SCENE { export enum WS_SCENE_EVENT_TYPES {
INIT = "SCENE_INIT", INIT = "SCENE_INIT",
UPDATE = "SCENE_UPDATE", UPDATE = "SCENE_UPDATE",
} }

View File

@ -22,7 +22,7 @@ import {
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
INITIAL_SCENE_UPDATE_TIMEOUT, INITIAL_SCENE_UPDATE_TIMEOUT,
LOAD_IMAGES_TIMEOUT, LOAD_IMAGES_TIMEOUT,
SCENE, WS_SCENE_EVENT_TYPES,
STORAGE_KEYS, STORAGE_KEYS,
SYNC_FULL_SCENE_INTERVAL_MS, SYNC_FULL_SCENE_INTERVAL_MS,
} from "../app_constants"; } from "../app_constants";
@ -88,7 +88,7 @@ export interface CollabAPI {
onPointerUpdate: CollabInstance["onPointerUpdate"]; onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"]; initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"]; onCollabButtonClick: CollabInstance["onCollabButtonClick"];
broadcastElements: CollabInstance["broadcastElements"]; syncElements: CollabInstance["syncElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
setUsername: (username: string) => void; setUsername: (username: string) => void;
} }
@ -232,12 +232,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}); });
saveCollabRoomToFirebase = async ( saveCollabRoomToFirebase = async (
syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements( syncableElements: readonly ExcalidrawElement[],
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
) => { ) => {
try { try {
await saveToFirebase(this.portal, syncableElements); const savedData = await saveToFirebase(
this.portal,
syncableElements,
this.excalidrawAPI.getAppState(),
);
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
this.handleRemoteSceneUpdate(
this.reconcileElements(savedData.reconciledElements),
);
}
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
} }
@ -250,9 +258,14 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
closePortal = () => { closePortal = () => {
this.queueBroadcastAllElements.cancel(); this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel(); this.loadImageFiles.cancel();
this.saveCollabRoomToFirebase(); this.saveCollabRoomToFirebase(
this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
);
if (window.confirm(t("alerts.collabStopOverridePrompt"))) { if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
// hack to ensure that we prefer we disregard any new browser state // hack to ensure that we prefer we disregard any new browser state
// that could have been saved in other tabs while we were collaborating // that could have been saved in other tabs while we were collaborating
@ -400,10 +413,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
commitToHistory: true, commitToHistory: true,
}); });
this.broadcastElements(elements); this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
const syncableElements = this.getSyncableElements(elements);
this.saveCollabRoomToFirebase(syncableElements);
} }
// fallback in case you're not alone in the room but still don't receive // fallback in case you're not alone in the room but still don't receive
@ -433,7 +443,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
switch (decryptedData.type) { switch (decryptedData.type) {
case "INVALID_RESPONSE": case "INVALID_RESPONSE":
return; return;
case SCENE.INIT: { case WS_SCENE_EVENT_TYPES.INIT: {
if (!this.portal.socketInitialized) { if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false }); this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements; const remoteElements = decryptedData.payload.elements;
@ -449,7 +459,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
} }
break; break;
} }
case SCENE.UPDATE: case WS_SCENE_EVENT_TYPES.UPDATE:
this.handleRemoteSceneUpdate( this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements), this.reconcileElements(decryptedData.payload.elements),
); );
@ -711,15 +721,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
getSceneVersion(elements) > getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion() this.getLastBroadcastedOrReceivedSceneVersion()
) { ) {
this.portal.broadcastScene(SCENE.UPDATE, elements, false); this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
this.queueBroadcastAllElements(); this.queueBroadcastAllElements();
} }
}; };
syncElements = (elements: readonly ExcalidrawElement[]) => {
this.broadcastElements(elements);
this.queueSaveToFirebase();
};
queueBroadcastAllElements = throttle(() => { queueBroadcastAllElements = throttle(() => {
this.portal.broadcastScene( this.portal.broadcastScene(
SCENE.UPDATE, WS_SCENE_EVENT_TYPES.UPDATE,
this.excalidrawAPI.getSceneElementsIncludingDeleted(), this.excalidrawAPI.getSceneElementsIncludingDeleted(),
true, true,
); );
@ -731,6 +746,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.setLastBroadcastedOrReceivedSceneVersion(newVersion); this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
}, SYNC_FULL_SCENE_INTERVAL_MS); }, SYNC_FULL_SCENE_INTERVAL_MS);
queueSaveToFirebase = throttle(() => {
if (this.portal.socketInitialized) {
this.saveCollabRoomToFirebase(
this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
);
}
}, SYNC_FULL_SCENE_INTERVAL_MS);
handleClose = () => { handleClose = () => {
this.setState({ modalIsShown: false }); this.setState({ modalIsShown: false });
}; };
@ -771,7 +796,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.contextValue.onPointerUpdate = this.onPointerUpdate; this.contextValue.onPointerUpdate = this.onPointerUpdate;
this.contextValue.initializeSocketClient = this.initializeSocketClient; this.contextValue.initializeSocketClient = this.initializeSocketClient;
this.contextValue.onCollabButtonClick = this.onCollabButtonClick; this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
this.contextValue.broadcastElements = this.broadcastElements; this.contextValue.syncElements = this.syncElements;
this.contextValue.fetchImageFilesFromFirebase = this.contextValue.fetchImageFilesFromFirebase =
this.fetchImageFilesFromFirebase; this.fetchImageFilesFromFirebase;
this.contextValue.setUsername = this.setUsername; this.contextValue.setUsername = this.setUsername;

View File

@ -3,7 +3,11 @@ import { SocketUpdateData, SocketUpdateDataSource } from "../data";
import CollabWrapper from "./CollabWrapper"; import CollabWrapper from "./CollabWrapper";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants"; import {
WS_EVENTS,
FILE_UPLOAD_TIMEOUT,
WS_SCENE_EVENT_TYPES,
} from "../app_constants";
import { UserIdleState } from "../../types"; import { UserIdleState } from "../../types";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { throttle } from "lodash"; import { throttle } from "lodash";
@ -37,7 +41,7 @@ class Portal {
}); });
this.socket.on("new-user", async (_socketId: string) => { this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene( this.broadcastScene(
SCENE.INIT, WS_SCENE_EVENT_TYPES.INIT,
this.collab.getSceneElementsIncludingDeleted(), this.collab.getSceneElementsIncludingDeleted(),
/* syncAll */ true, /* syncAll */ true,
); );
@ -81,7 +85,7 @@ class Portal {
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded); const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
this.socket?.emit( this.socket?.emit(
volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER, volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
this.roomId, this.roomId,
encryptedBuffer, encryptedBuffer,
iv, iv,
@ -121,11 +125,11 @@ class Portal {
}, FILE_UPLOAD_TIMEOUT); }, FILE_UPLOAD_TIMEOUT);
broadcastScene = async ( broadcastScene = async (
sceneType: SCENE.INIT | SCENE.UPDATE, updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
allElements: readonly ExcalidrawElement[], allElements: readonly ExcalidrawElement[],
syncAll: boolean, syncAll: boolean,
) => { ) => {
if (sceneType === SCENE.INIT && !syncAll) { if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT"); throw new Error("syncAll must be true when sending SCENE.INIT");
} }
@ -152,8 +156,8 @@ class Portal {
[] as BroadcastedExcalidrawElement[], [] as BroadcastedExcalidrawElement[],
); );
const data: SocketUpdateDataSource[typeof sceneType] = { const data: SocketUpdateDataSource[typeof updateType] = {
type: sceneType, type: updateType,
payload: { payload: {
elements: syncableElements, elements: syncableElements,
}, },
@ -166,20 +170,9 @@ class Portal {
); );
} }
const broadcastPromise = this._broadcastSocketData(
data as SocketUpdateData,
);
this.queueFileUpload(); this.queueFileUpload();
if (syncAll && this.collab.isCollaborating()) { await this._broadcastSocketData(data as SocketUpdateData);
await Promise.all([
broadcastPromise,
this.collab.saveCollabRoomToFirebase(syncableElements),
]);
} else {
await broadcastPromise;
}
}; };
broadcastIdleChange = (userState: UserIdleState) => { broadcastIdleChange = (userState: UserIdleState) => {

View File

@ -78,8 +78,14 @@ export const reconcileElements = (
continue; continue;
} }
// Mark duplicate for removal as it'll be replaced with the remote element
if (local) { if (local) {
// mark for removal since it'll be replaced with the remote element // Unless the ramote and local elements are the same element in which case
// we need to keep it as we'd otherwise discard it from the resulting
// array.
if (local[0] === remoteElement) {
continue;
}
duplicates.set(local[0], true); duplicates.set(local[0], true);
} }

View File

@ -2,11 +2,17 @@ import { ExcalidrawElement, FileId } from "../../element/types";
import { getSceneVersion } from "../../element"; import { getSceneVersion } from "../../element";
import Portal from "../collab/Portal"; import Portal from "../collab/Portal";
import { restoreElements } from "../../data/restore"; import { restoreElements } from "../../data/restore";
import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types"; import {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "../../types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants"; import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "../../data/encode"; import { decompressData } from "../../data/encode";
import { encryptData, decryptData } from "../../data/encryption"; import { encryptData, decryptData } from "../../data/encryption";
import { MIME_TYPES } from "../../constants"; import { MIME_TYPES } from "../../constants";
import { reconcileElements } from "../collab/reconciliation";
// private // private
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -108,11 +114,13 @@ const encryptElements = async (
}; };
const decryptElements = async ( const decryptElements = async (
key: string, data: FirebaseStoredScene,
iv: Uint8Array, roomKey: string,
ciphertext: ArrayBuffer | Uint8Array,
): Promise<readonly ExcalidrawElement[]> => { ): Promise<readonly ExcalidrawElement[]> => {
const decrypted = await decryptData(iv, ciphertext, key); const ciphertext = data.ciphertext.toUint8Array();
const iv = data.iv.toUint8Array();
const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode( const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted), new Uint8Array(decrypted),
); );
@ -171,57 +179,86 @@ export const saveFilesToFirebase = async ({
return { savedFiles, erroredFiles }; return { savedFiles, erroredFiles };
}; };
export const saveToFirebase = async ( const createFirebaseSceneDocument = async (
portal: Portal, firebase: ResolutionType<typeof loadFirestore>,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
roomKey: string,
) => { ) => {
const { roomId, roomKey, socket } = portal;
if (
// if no room exists, consider the room saved because there's nothing we can
// do at this point
!roomId ||
!roomKey ||
!socket ||
isSavedToFirebase(portal, elements)
) {
return true;
}
const firebase = await loadFirestore();
const sceneVersion = getSceneVersion(elements); const sceneVersion = getSceneVersion(elements);
const { ciphertext, iv } = await encryptElements(roomKey, elements); const { ciphertext, iv } = await encryptElements(roomKey, elements);
return {
const nextDocData = {
sceneVersion, sceneVersion,
ciphertext: firebase.firestore.Blob.fromUint8Array( ciphertext: firebase.firestore.Blob.fromUint8Array(
new Uint8Array(ciphertext), new Uint8Array(ciphertext),
), ),
iv: firebase.firestore.Blob.fromUint8Array(iv), iv: firebase.firestore.Blob.fromUint8Array(iv),
} as FirebaseStoredScene; } as FirebaseStoredScene;
};
const db = firebase.firestore(); export const saveToFirebase = async (
const docRef = db.collection("scenes").doc(roomId); portal: Portal,
const didUpdate = await db.runTransaction(async (transaction) => { elements: readonly ExcalidrawElement[],
const doc = await transaction.get(docRef); appState: AppState,
if (!doc.exists) { ) => {
transaction.set(docRef, nextDocData); const { roomId, roomKey, socket } = portal;
return true; if (
} // bail if no room exists as there's nothing we can do at this point
!roomId ||
const prevDocData = doc.data() as FirebaseStoredScene; !roomKey ||
if (prevDocData.sceneVersion >= nextDocData.sceneVersion) { !socket ||
return false; isSavedToFirebase(portal, elements)
} ) {
return false;
transaction.update(docRef, nextDocData);
return true;
});
if (didUpdate) {
firebaseSceneVersionCache.set(socket, sceneVersion);
} }
return didUpdate; const firebase = await loadFirestore();
const firestore = firebase.firestore();
const docRef = firestore.collection("scenes").doc(roomId);
const savedData = await firestore.runTransaction(async (transaction) => {
const snapshot = await transaction.get(docRef);
if (!snapshot.exists) {
const sceneDocument = await createFirebaseSceneDocument(
firebase,
elements,
roomKey,
);
transaction.set(docRef, sceneDocument);
return {
sceneVersion: sceneDocument.sceneVersion,
reconciledElements: null,
};
}
const prevDocData = snapshot.data() as FirebaseStoredScene;
const prevElements = await decryptElements(prevDocData, roomKey);
const reconciledElements = reconcileElements(
elements,
prevElements,
appState,
);
const sceneDocument = await createFirebaseSceneDocument(
firebase,
reconciledElements,
roomKey,
);
transaction.update(docRef, sceneDocument);
return {
reconciledElements,
sceneVersion: sceneDocument.sceneVersion,
};
});
firebaseSceneVersionCache.set(socket, savedData.sceneVersion);
return savedData;
}; };
export const loadFromFirebase = async ( export const loadFromFirebase = async (
@ -238,10 +275,7 @@ export const loadFromFirebase = async (
return null; return null;
} }
const storedScene = doc.data() as FirebaseStoredScene; const storedScene = doc.data() as FirebaseStoredScene;
const ciphertext = storedScene.ciphertext.toUint8Array(); const elements = await decryptElements(storedScene, roomKey);
const iv = storedScene.iv.toUint8Array();
const elements = await decryptElements(roomKey, iv, ciphertext);
if (socket) { if (socket) {
firebaseSceneVersionCache.set(socket, getSceneVersion(elements)); firebaseSceneVersionCache.set(socket, getSceneVersion(elements));

View File

@ -455,7 +455,7 @@ const ExcalidrawWrapper = () => {
files: BinaryFiles, files: BinaryFiles,
) => { ) => {
if (collabAPI?.isCollaborating()) { if (collabAPI?.isCollaborating()) {
collabAPI.broadcastElements(elements); collabAPI.syncElements(elements);
} }
// this check is redundant, but since this is a hot path, it's best // this check is redundant, but since this is a hot path, it's best

View File

@ -9,36 +9,60 @@ import { randomInteger } from "../random";
import { AppState } from "../types"; import { AppState } from "../types";
type Id = string; type Id = string;
type Ids = Id[]; type ElementLike = {
id: string;
version: number;
versionNonce: number;
parent?: string | null;
};
type Cache = Record<string, ExcalidrawElement | undefined>; type Cache = Record<string, ExcalidrawElement | undefined>;
const parseId = (uid: string) => { const createElement = (opts: { uid: string } | ElementLike) => {
const [, parent, id, version] = uid.match( let uid: string;
/^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/, let id: string;
)!; let version: number | null;
let parent: string | null = null;
let versionNonce: number | null = null;
if ("uid" in opts) {
const match = opts.uid.match(
/^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
)!;
parent = match[1];
id = match[2];
version = match[3] ? parseInt(match[3]) : null;
uid = version ? `${id}:${version}` : id;
} else {
({ id, version, versionNonce } = opts);
parent = parent || null;
uid = id;
}
return { return {
uid: version ? `${id}:${version}` : id, uid,
id, id,
version: version ? parseInt(version) : null, version,
versionNonce: versionNonce || randomInteger(),
parent: parent || null, parent: parent || null,
}; };
}; };
const idsToElements = ( const idsToElements = (
ids: Ids, ids: (Id | ElementLike)[],
cache: Cache = {}, cache: Cache = {},
): readonly ExcalidrawElement[] => { ): readonly ExcalidrawElement[] => {
return ids.reduce((acc, _uid, idx) => { return ids.reduce((acc, _uid, idx) => {
const { uid, id, version, parent } = parseId(_uid); const { uid, id, version, parent, versionNonce } = createElement(
typeof _uid === "string" ? { uid: _uid } : _uid,
);
const cached = cache[uid]; const cached = cache[uid];
const elem = { const elem = {
id, id,
version: version ?? 0, version: version ?? 0,
versionNonce: randomInteger(), versionNonce,
...cached, ...cached,
parent, parent,
} as BroadcastedExcalidrawElement; } as BroadcastedExcalidrawElement;
// @ts-ignore
cache[uid] = elem; cache[uid] = elem;
acc.push(elem); acc.push(elem);
return acc; return acc;
@ -67,8 +91,8 @@ const cleanElements = (elements: ReconciledElements) => {
const cloneDeep = (data: any) => JSON.parse(JSON.stringify(data)); const cloneDeep = (data: any) => JSON.parse(JSON.stringify(data));
const test = <U extends `${string}:${"L" | "R"}`>( const test = <U extends `${string}:${"L" | "R"}`>(
local: Ids, local: (Id | ElementLike)[],
remote: Ids, remote: (Id | ElementLike)[],
target: U[], target: U[],
bidirectional = true, bidirectional = true,
) => { ) => {
@ -80,6 +104,7 @@ const test = <U extends `${string}:${"L" | "R"}`>(
return (source === "L" ? _local : _remote).find((e) => e.id === id)!; return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
}) as any as ReconciledElements; }) as any as ReconciledElements;
const remoteReconciled = reconcileElements(_local, _remote, {} as AppState); const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
expect(target.length).equal(remoteReconciled.length);
expect(cleanElements(remoteReconciled)).deep.equal( expect(cleanElements(remoteReconciled)).deep.equal(
cleanElements(_target), cleanElements(_target),
"remote reconciliation", "remote reconciliation",
@ -301,4 +326,92 @@ describe("elements reconciliation", () => {
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]); test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]); test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
}); });
it("test identical elements reconciliation", () => {
const testIdentical = (
local: ElementLike[],
remote: ElementLike[],
expected: Id[],
) => {
const ret = reconcileElements(
local as any as ExcalidrawElement[],
remote as any as ExcalidrawElement[],
{} as AppState,
);
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
throw new Error("reconcileElements: duplicate elements found");
}
expect(ret.map((x) => x.id)).to.deep.equal(expected);
};
// identical id/version/versionNonce
// -------------------------------------------------------------------------
testIdentical(
[{ id: "A", version: 1, versionNonce: 1 }],
[{ id: "A", version: 1, versionNonce: 1 }],
["A"],
);
testIdentical(
[
{ id: "A", version: 1, versionNonce: 1 },
{ id: "B", version: 1, versionNonce: 1 },
],
[
{ id: "B", version: 1, versionNonce: 1 },
{ id: "A", version: 1, versionNonce: 1 },
],
["B", "A"],
);
testIdentical(
[
{ id: "A", version: 1, versionNonce: 1 },
{ id: "B", version: 1, versionNonce: 1 },
],
[
{ id: "B", version: 1, versionNonce: 1 },
{ id: "A", version: 1, versionNonce: 1 },
],
["B", "A"],
);
// actually identical (arrays and element objects)
// -------------------------------------------------------------------------
const elements1 = [
{
id: "A",
version: 1,
versionNonce: 1,
parent: null,
},
{
id: "B",
version: 1,
versionNonce: 1,
parent: null,
},
];
testIdentical(elements1, elements1, ["A", "B"]);
testIdentical(elements1, elements1.slice(), ["A", "B"]);
testIdentical(elements1.slice(), elements1, ["A", "B"]);
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
const el1 = {
id: "A",
version: 1,
versionNonce: 1,
parent: null,
};
const el2 = {
id: "B",
version: 1,
versionNonce: 1,
parent: null,
};
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
});
}); });