Review fixes

This commit is contained in:
Marcel Mraz 2025-05-02 15:41:43 +02:00
parent d671730971
commit 1ed4590c69
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
6 changed files with 102 additions and 79 deletions

View file

@ -735,6 +735,25 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
return acc; return acc;
}, [] as Node<T>[]); }, [] as Node<T>[]);
/**
* Converts a readonly array or map into an iterable.
* Useful for avoiding entry allocations when iterating object / map on each iteration.
*/
export const toIterable = <T>(
values: readonly T[] | ReadonlyMap<string, T>,
): Iterable<T> => {
return Array.isArray(values) ? values : values.values();
};
/**
* Converts a readonly array or map into an array.
*/
export const toArray = <T>(
values: readonly T[] | ReadonlyMap<string, T>,
): T[] => {
return Array.from(toIterable(values));
};
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST; export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT; export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;

View file

@ -6,14 +6,13 @@ import {
toBrandedType, toBrandedType,
isDevEnv, isDevEnv,
isTestEnv, isTestEnv,
isReadonlyArray, toArray,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element"; import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element/groups"; import { getElementsInGroup } from "@excalidraw/element/groups";
import { import {
orderByFractionalIndex,
syncInvalidIndices, syncInvalidIndices,
syncMovedIndices, syncMovedIndices,
validateFractionalIndices, validateFractionalIndices,
@ -268,19 +267,13 @@ class Scene {
} }
replaceAllElements(nextElements: ElementsMapOrArray) { replaceAllElements(nextElements: ElementsMapOrArray) {
// ts doesn't like `Array.isArray` of `instanceof Map` // we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
if (!isReadonlyArray(nextElements)) { const _nextElements = toArray(nextElements);
// need to order by fractional indices to get the correct order
nextElements = orderByFractionalIndex(
Array.from(nextElements.values()) as OrderedExcalidrawElement[],
);
}
const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(nextElements); validateIndicesThrottled(_nextElements);
this.elements = syncInvalidIndices(nextElements); this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear(); this.elementsMap.clear();
this.elements.forEach((element) => { this.elements.forEach((element) => {
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {

View file

@ -316,7 +316,7 @@ export class Delta<T> {
} }
/** /**
* Returns all the object1 keys that have distinct values. * Returns sorted object1 keys that have distinct values.
*/ */
public static getLeftDifferences<T extends {}>( public static getLeftDifferences<T extends {}>(
object1: T, object1: T,
@ -325,11 +325,11 @@ export class Delta<T> {
) { ) {
return Array.from( return Array.from(
this.distinctKeysIterator("left", object1, object2, skipShallowCompare), this.distinctKeysIterator("left", object1, object2, skipShallowCompare),
); ).sort();
} }
/** /**
* Returns all the object2 keys that have distinct values. * Returns sorted object2 keys that have distinct values.
*/ */
public static getRightDifferences<T extends {}>( public static getRightDifferences<T extends {}>(
object1: T, object1: T,
@ -338,7 +338,7 @@ export class Delta<T> {
) { ) {
return Array.from( return Array.from(
this.distinctKeysIterator("right", object1, object2, skipShallowCompare), this.distinctKeysIterator("right", object1, object2, skipShallowCompare),
); ).sort();
} }
/** /**
@ -430,7 +430,8 @@ export class AppStateDelta implements DeltaContainer<AppState> {
const delta = Delta.calculate( const delta = Delta.calculate(
prevAppState, prevAppState,
nextAppState, nextAppState,
undefined, // making the order of keys in deltas stable for hashing purposes
AppStateDelta.orderAppStateKeys,
AppStateDelta.postProcess, AppStateDelta.postProcess,
); );
@ -539,40 +540,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return Delta.isEmpty(this.delta); return Delta.isEmpty(this.delta);
} }
/**
* It is necessary to post process the partials in case of reference values,
* for which we need to calculate the real diff between `deleted` and `inserted`.
*/
private static postProcess<T extends ObservedAppState>(
deleted: Partial<T>,
inserted: Partial<T>,
): [Partial<T>, Partial<T>] {
try {
Delta.diffObjects(
deleted,
inserted,
"selectedElementIds",
// ts language server has a bit trouble resolving this, so we are giving it a little push
(_) => true as ValueOf<T["selectedElementIds"]>,
);
Delta.diffObjects(
deleted,
inserted,
"selectedGroupIds",
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
);
} catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess appstate change deltas.`);
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return [deleted, inserted];
}
}
/** /**
* Mutates `nextAppState` be filtering out state related to deleted elements. * Mutates `nextAppState` be filtering out state related to deleted elements.
* *
@ -807,6 +774,51 @@ export class AppStateDelta implements DeltaContainer<AppState> {
ObservedElementsAppState ObservedElementsAppState
>; >;
} }
/**
* It is necessary to post process the partials in case of reference values,
* for which we need to calculate the real diff between `deleted` and `inserted`.
*/
private static postProcess<T extends ObservedAppState>(
deleted: Partial<T>,
inserted: Partial<T>,
): [Partial<T>, Partial<T>] {
try {
Delta.diffObjects(
deleted,
inserted,
"selectedElementIds",
// ts language server has a bit trouble resolving this, so we are giving it a little push
(_) => true as ValueOf<T["selectedElementIds"]>,
);
Delta.diffObjects(
deleted,
inserted,
"selectedGroupIds",
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
);
} catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess appstate change deltas.`);
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return [deleted, inserted];
}
}
private static orderAppStateKeys(partial: Partial<ObservedAppState>) {
const orderedPartial: { [key: string]: unknown } = {};
for (const key of Object.keys(partial).sort()) {
// relying on insertion order
orderedPartial[key] = partial[key as keyof ObservedAppState];
}
return orderedPartial as Partial<ObservedAppState>;
}
} }
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit< type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<

View file

@ -1,3 +1,5 @@
import { toIterable } from "@excalidraw/common";
import { isInvisiblySmallElement } from "./sizeHelpers"; import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks"; import { isLinearElementType } from "./typeChecks";
@ -5,6 +7,7 @@ import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ElementsMapOrArray,
} from "./types"; } from "./types";
/** /**
@ -16,12 +19,10 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
/** /**
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters. * Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
*/ */
export const hashElementsVersion = ( export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
elements: readonly ExcalidrawElement[],
): number => {
let hash = 5381; let hash = 5381;
for (let i = 0; i < elements.length; i++) { for (const element of toIterable(elements)) {
hash = (hash << 5) + hash + elements[i].versionNonce; hash = (hash << 5) + hash + element.versionNonce;
} }
return hash >>> 0; // Ensure unsigned 32-bit integer return hash >>> 0; // Ensure unsigned 32-bit integer
}; };

View file

@ -6,6 +6,7 @@ import {
isTestEnv, isTestEnv,
randomId, randomId,
Emitter, Emitter,
toIterable,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { DTO, ValueOf } from "@excalidraw/common/utility-types"; import type { DTO, ValueOf } from "@excalidraw/common/utility-types";
@ -158,25 +159,25 @@ export class Store {
): SceneElementsMap { ): SceneElementsMap {
const movedElements = new Map<string, ExcalidrawElement>(); const movedElements = new Map<string, ExcalidrawElement>();
for (const [id, prevElement] of prevElements.entries()) { for (const prevElement of toIterable(prevElements)) {
const nextElement = nextElements.get(id); const nextElement = nextElements.get(prevElement.id);
if (!nextElement) { if (!nextElement) {
// Nothing to care about here, element was forcefully deleted // Nothing to care about here, element was forcefully deleted
continue; continue;
} }
const elementSnapshot = this.snapshot.elements.get(id); const elementSnapshot = this.snapshot.elements.get(prevElement.id);
// Checks for in progress async user action // Checks for in progress async user action
if (!elementSnapshot) { if (!elementSnapshot) {
// Detected yet uncomitted local element // Detected yet uncomitted local element
nextElements.delete(id); nextElements.delete(prevElement.id);
} else if (elementSnapshot.version < prevElement.version) { } else if (elementSnapshot.version < prevElement.version) {
// Element was already commited, but the snapshot version is lower than current local version // Element was already commited, but the snapshot version is lower than current local version
nextElements.set(id, elementSnapshot); nextElements.set(prevElement.id, elementSnapshot);
// Mark the element as potentially moved, as it could have // Mark the element as potentially moved, as it could have
movedElements.set(id, elementSnapshot); movedElements.set(prevElement.id, elementSnapshot);
} }
} }
@ -601,10 +602,10 @@ export class StoreSnapshot {
public getChangedElements(prevSnapshot: StoreSnapshot) { public getChangedElements(prevSnapshot: StoreSnapshot) {
const changedElements: Record<string, OrderedExcalidrawElement> = {}; const changedElements: Record<string, OrderedExcalidrawElement> = {};
for (const [id, nextElement] of this.elements.entries()) { for (const nextElement of toIterable(this.elements)) {
// Due to the structural clone inside `maybeClone`, we can perform just these reference checks // Due to the structural clone inside `maybeClone`, we can perform just these reference checks
if (prevSnapshot.elements.get(id) !== nextElement) { if (prevSnapshot.elements.get(nextElement.id) !== nextElement) {
changedElements[id] = nextElement; changedElements[nextElement.id] = nextElement;
} }
} }
@ -794,26 +795,26 @@ export class StoreSnapshot {
const changedElements: SceneElementsMap = new Map() as SceneElementsMap; const changedElements: SceneElementsMap = new Map() as SceneElementsMap;
for (const [id, prevElement] of this.elements) { for (const prevElement of toIterable(this.elements)) {
const nextElement = nextElements.get(id); const nextElement = nextElements.get(prevElement.id);
if (!nextElement) { if (!nextElement) {
// element was deleted // element was deleted
changedElements.set( changedElements.set(
id, prevElement.id,
newElementWith(prevElement, { isDeleted: true }), newElementWith(prevElement, { isDeleted: true }),
); );
} }
} }
for (const [id, nextElement] of nextElements) { for (const nextElement of toIterable(nextElements)) {
const prevElement = this.elements.get(id); const prevElement = this.elements.get(nextElement.id);
if ( if (
!prevElement || // element was added !prevElement || // element was added
prevElement.version < nextElement.version // element was updated prevElement.version < nextElement.version // element was updated
) { ) {
changedElements.set(id, nextElement); changedElements.set(nextElement.id, nextElement);
} }
} }
@ -821,9 +822,7 @@ export class StoreSnapshot {
return; return;
} }
const changedElementsHash = hashElementsVersion( const changedElementsHash = hashElementsVersion(changedElements);
Array.from(changedElements.values()),
);
if ( if (
options.shouldCompareHashes && options.shouldCompareHashes &&
@ -843,15 +842,15 @@ export class StoreSnapshot {
private createElementsSnapshot(changedElements: SceneElementsMap) { private createElementsSnapshot(changedElements: SceneElementsMap) {
const clonedElements = new Map() as SceneElementsMap; const clonedElements = new Map() as SceneElementsMap;
for (const [id, prevElement] of this.elements) { for (const prevElement of toIterable(this.elements)) {
// Clone previous elements, never delete, in case nextElements would be just a subset of previous elements // Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
// i.e. during collab, persist or whenenever isDeleted elements get cleared // i.e. during collab, persist or whenenever isDeleted elements get cleared
clonedElements.set(id, prevElement); clonedElements.set(prevElement.id, prevElement);
} }
for (const [id, changedElement] of changedElements) { for (const changedElement of toIterable(changedElements)) {
// TODO: consider just creating new instance, once we can ensure that all reference properties on every element are immutable // TODO: consider just creating new instance, once we can ensure that all reference properties on every element are immutable
clonedElements.set(id, deepCopyElement(changedElement)); clonedElements.set(changedElement.id, deepCopyElement(changedElement));
} }
return clonedElements; return clonedElements;

View file

@ -307,7 +307,6 @@ import Scene from "@excalidraw/element/Scene";
import { import {
Store, Store,
CaptureUpdateAction, CaptureUpdateAction,
StoreIncrement,
} from "@excalidraw/element/store"; } from "@excalidraw/element/store";
import type { ElementUpdate } from "@excalidraw/element/mutateElement"; import type { ElementUpdate } from "@excalidraw/element/mutateElement";