test: cover collab field preservation

This commit is contained in:
Ryan Di
2026-03-20 19:55:40 +11:00
parent 8168d46f87
commit 16838ea792
12 changed files with 174 additions and 94 deletions
+4 -7
View File
@@ -286,13 +286,10 @@ const initializeScene = async (opts: {
scene = { scene = {
elements: bumpElementVersions( elements: bumpElementVersions(
restoreElements( restoreElements(
migrateSceneElements( migrateSceneElements(imported.elements, {
imported.elements, payloadSchemaVersion: imported.schemaVersion,
{ fallbackVersion: SCHEMA_VERSIONS.initial,
payloadSchemaVersion: imported.schemaVersion, }),
fallbackVersion: SCHEMA_VERSIONS.initial,
},
),
null, null,
{ {
repairBindings: true, repairBindings: true,
+6 -4
View File
@@ -87,10 +87,12 @@ const saveDataStateToLocalStorage = (
_appState.openSidebar = null; _appState.openSidebar = null;
} }
const persistedElements = getNonDeletedElements(elements).map((element) => ({ const persistedElements = getNonDeletedElements(elements).map(
...element, (element) => ({
schemaVersion: SCHEMA_VERSIONS.latest, ...element,
})); schemaVersion: SCHEMA_VERSIONS.latest,
}),
);
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
+92
View File
@@ -69,6 +69,98 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/ */
describe("collaboration", () => { describe("collaboration", () => {
it("should preserve future element fields across collab reconciliation", async () => {
await render(<ExcalidrawApp />);
const frame = API.createElement({
type: "frame",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "#ff0000",
});
const frameWithFutureFields = {
...frame,
schemaVersion: 2,
futureField: "keep-me",
} as typeof frame & {
schemaVersion: number;
futureField: string;
};
API.updateScene({
elements: [frameWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toBe("keep-me");
expect((h.elements[0] as any).schemaVersion).toBe(2);
expect(h.elements[0].backgroundColor).toBe("#ff0000");
});
const remoteMovedFrame = newElementWith(h.elements[0] as any, {
x: 120,
y: 80,
});
const reconciled = (window.collab as any)._reconcileElements([
remoteMovedFrame,
]);
expect(reconciled[0]).toEqual(
expect.objectContaining({
x: 120,
y: 80,
backgroundColor: "#ff0000",
}),
);
expect((reconciled[0] as any).futureField).toBe("keep-me");
expect((reconciled[0] as any).schemaVersion).toBe(2);
});
it("should preserve future element fields on local edits before broadcast", async () => {
await render(<ExcalidrawApp />);
const rect = API.createElement({
type: "rectangle",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
});
const rectWithFutureFields = {
...rect,
schemaVersion: 2,
futureField: { value: "keep-me" },
} as typeof rect & {
schemaVersion: number;
futureField: { value: string };
};
API.updateScene({
elements: [rectWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
const locallyEdited = newElementWith(h.elements[0] as any, { x: 200 });
API.updateScene({
elements: [locallyEdited],
captureUpdate: CaptureUpdateAction.NEVER,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toEqual({ value: "keep-me" });
expect((h.elements[0] as any).schemaVersion).toBe(2);
expect(h.elements[0]).toEqual(expect.objectContaining({ x: 200 }));
});
});
it("should emit two ephemeral increments even though updates get batched", async () => { it("should emit two ephemeral increments even though updates get batched", async () => {
const durableIncrements: DurableIncrement[] = []; const durableIncrements: DurableIncrement[] = [];
const ephemeralIncrements: EphemeralIncrement[] = []; const ephemeralIncrements: EphemeralIncrement[] = [];
+2 -1
View File
@@ -35,12 +35,13 @@ import {
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import { SCHEMA_VERSIONS } from "./data/schema";
import type { FileSystemHandle } from "./data/filesystem"; import type { FileSystemHandle } from "./data/filesystem";
import type { Spreadsheet } from "./charts"; import type { Spreadsheet } from "./charts";
import type { BinaryFiles } from "./types"; import type { BinaryFiles } from "./types";
import { SCHEMA_VERSIONS } from "./data/schema";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
+7 -14
View File
@@ -2822,13 +2822,10 @@ class App extends React.Component<AppProps, AppState> {
? SCHEMA_VERSIONS.initial ? SCHEMA_VERSIONS.initial
: SCHEMA_VERSIONS.latest; : SCHEMA_VERSIONS.latest;
const restoredElements = restoreElements( const restoredElements = restoreElements(
migrateSceneElements( migrateSceneElements(initialData?.elements, {
initialData?.elements, payloadSchemaVersion: initialData?.schemaVersion,
{ fallbackVersion: initialDataSchemaFallback,
payloadSchemaVersion: initialData?.schemaVersion, }),
fallbackVersion: initialDataSchemaFallback,
},
),
null, null,
{ {
repairBindings: true, repairBindings: true,
@@ -3764,13 +3761,9 @@ class App extends React.Component<AppProps, AppState> {
? migrateLibraryElements(opts.elements, opts.schemaVersionSource) ? migrateLibraryElements(opts.elements, opts.schemaVersionSource)
: migrateAPIElements(opts.elements, opts.schemaVersionSource); : migrateAPIElements(opts.elements, opts.schemaVersionSource);
const elements = restoreElements( const elements = restoreElements(migratedElements, null, {
migratedElements, deleteInvisibleElements: true,
null, });
{
deleteInvisibleElements: true,
},
);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
@@ -9,6 +9,7 @@ import {
chunk, chunk,
getExportSource, getExportSource,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { SCHEMA_VERSIONS } from "../data/schema"; import { SCHEMA_VERSIONS } from "../data/schema";
import { EditorLocalStorage } from "../data/EditorLocalStorage"; import { EditorLocalStorage } from "../data/EditorLocalStorage";
+9 -19
View File
@@ -24,10 +24,7 @@ import {
restoreElements, restoreElements,
restoreLibraryItems, restoreLibraryItems,
} from "./restore"; } from "./restore";
import { import { migrateSceneElements, SCHEMA_VERSIONS } from "./schema";
migrateSceneElements,
SCHEMA_VERSIONS,
} from "./schema";
import type { AppState, DataURL, LibraryItem } from "../types"; import type { AppState, DataURL, LibraryItem } from "../types";
@@ -161,13 +158,10 @@ export const loadSceneOrLibraryFromBlob = async (
throw error; throw error;
} }
if (isValidExcalidrawData(data)) { if (isValidExcalidrawData(data)) {
const migratedElements = migrateSceneElements( const migratedElements = migrateSceneElements(data.elements, {
data.elements, payloadSchemaVersion: data.schemaVersion,
{ fallbackVersion: SCHEMA_VERSIONS.initial,
payloadSchemaVersion: data.schemaVersion, });
fallbackVersion: SCHEMA_VERSIONS.initial,
},
);
return { return {
type: MIME_TYPES.excalidraw, type: MIME_TYPES.excalidraw,
data: { data: {
@@ -233,14 +227,10 @@ export const parseLibraryJSON = (
throw new Error("Invalid library"); throw new Error("Invalid library");
} }
const libraryItems = data.libraryItems || data.library; const libraryItems = data.libraryItems || data.library;
return restoreLibraryItems( return restoreLibraryItems(libraryItems, defaultStatus, {
libraryItems, payloadSchemaVersion: data.schemaVersion,
defaultStatus, fallbackVersion: SCHEMA_VERSIONS.initial,
{ });
payloadSchemaVersion: data.schemaVersion,
fallbackVersion: SCHEMA_VERSIONS.initial,
},
);
}; };
export const loadLibraryFromBlob = async ( export const loadLibraryFromBlob = async (
+12 -14
View File
@@ -91,9 +91,10 @@ export interface LibraryPersistenceAdapter {
* purposes, in which case host app can implement more aggressive caching. * purposes, in which case host app can implement more aggressive caching.
*/ */
source: LibraryAdatapterSource; source: LibraryAdatapterSource;
}): MaybePromise< }): MaybePromise<{
{ libraryItems: LibraryItems_anyVersion; schemaVersion?: number } | null libraryItems: LibraryItems_anyVersion;
>; schemaVersion?: number;
} | null>;
/** Should persist to the database as is (do no change the data structure). */ /** Should persist to the database as is (do no change the data structure). */
save(libraryData: LibraryPersistedData): MaybePromise<void>; save(libraryData: LibraryPersistedData): MaybePromise<void>;
} }
@@ -103,9 +104,10 @@ export interface LibraryMigrationAdapter {
* loads data from legacy data source. Returns `null` if no data is * loads data from legacy data source. Returns `null` if no data is
* to be migrated. * to be migrated.
*/ */
load(): MaybePromise< load(): MaybePromise<{
{ libraryItems: LibraryItems_anyVersion; schemaVersion?: number } | null libraryItems: LibraryItems_anyVersion;
>; schemaVersion?: number;
} | null>;
/** clears entire storage afterwards */ /** clears entire storage afterwards */
clear(): MaybePromise<void>; clear(): MaybePromise<void>;
@@ -561,14 +563,10 @@ class AdapterTransaction {
try { try {
const data = await adapter.load({ source }); const data = await adapter.load({ source });
resolve( resolve(
restoreLibraryItems( restoreLibraryItems(data?.libraryItems || [], "published", {
data?.libraryItems || [], payloadSchemaVersion: data?.schemaVersion,
"published", fallbackVersion: SCHEMA_VERSIONS.initial,
{ }),
payloadSchemaVersion: data?.schemaVersion,
fallbackVersion: SCHEMA_VERSIONS.initial,
},
),
); );
} catch (error: any) { } catch (error: any) {
reject(error); reject(error);
+19 -12
View File
@@ -81,6 +81,7 @@ import {
getNormalizedGridStep, getNormalizedGridStep,
getNormalizedZoom, getNormalizedZoom,
} from "../scene"; } from "../scene";
import { import {
migrateLibraryElements, migrateLibraryElements,
type SchemaVersionSource, type SchemaVersionSource,
@@ -984,12 +985,15 @@ export const restoreLibraryItems = (
for (const item of libraryItems) { for (const item of libraryItems) {
// migrate older libraries // migrate older libraries
if (Array.isArray(item)) { if (Array.isArray(item)) {
const restoredItem = restoreLibraryItem({ const restoredItem = restoreLibraryItem(
status: defaultStatus, {
elements: item, status: defaultStatus,
id: randomId(), elements: item,
created: Date.now(), id: randomId(),
}, schemaVersionSource); created: Date.now(),
},
schemaVersionSource,
);
if (restoredItem) { if (restoredItem) {
restoredItems.push(restoredItem); restoredItems.push(restoredItem);
} }
@@ -998,12 +1002,15 @@ export const restoreLibraryItems = (
LibraryItem, LibraryItem,
"id" | "status" | "created" "id" | "status" | "created"
>; >;
const restoredItem = restoreLibraryItem({ const restoredItem = restoreLibraryItem(
..._item, {
id: _item.id || randomId(), ..._item,
status: _item.status || defaultStatus, id: _item.id || randomId(),
created: _item.created || Date.now(), status: _item.status || defaultStatus,
}, schemaVersionSource); created: _item.created || Date.now(),
},
schemaVersionSource,
);
if (restoredItem) { if (restoredItem) {
restoredItems.push(restoredItem); restoredItems.push(restoredItem);
} }
+5 -7
View File
@@ -1,6 +1,7 @@
import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common"; import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { import {
ALL_SCOPES, ALL_SCOPES,
hasElementSchemaVersion, hasElementSchemaVersion,
@@ -194,13 +195,10 @@ describe("schema migration", () => {
schemaVersion: SCHEMA_VERSIONS.latest, schemaVersion: SCHEMA_VERSIONS.latest,
} as typeof modernFrame & { schemaVersion: number }; } as typeof modernFrame & { schemaVersion: number };
const migrated = migrateSceneElements( const migrated = migrateSceneElements([legacyFrame, modernFrameWithHint], {
[legacyFrame, modernFrameWithHint], payloadSchemaVersion: undefined,
{ fallbackVersion: SCHEMA_VERSIONS.initial,
payloadSchemaVersion: undefined, })!;
fallbackVersion: SCHEMA_VERSIONS.initial,
},
)!;
expect(migrated[0].backgroundColor).toBe( expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor, DEFAULT_ELEMENT_PROPS.backgroundColor,
+13 -14
View File
@@ -15,7 +15,7 @@ export const SCHEMA_MIGRATION_SCOPES = [
"api", "api",
] as const; ] as const;
export type SchemaMigrationScope = (typeof SCHEMA_MIGRATION_SCOPES)[number]; export type SchemaMigrationScope = typeof SCHEMA_MIGRATION_SCOPES[number];
export const ALL_SCOPES: readonly SchemaMigrationScope[] = export const ALL_SCOPES: readonly SchemaMigrationScope[] =
SCHEMA_MIGRATION_SCOPES; SCHEMA_MIGRATION_SCOPES;
@@ -135,7 +135,9 @@ export const validateSchemaMigrations = (
); );
} }
if (!migration.scope.length) { if (!migration.scope.length) {
errors.push(`Migration "${migration.title}" must declare at least one scope.`); errors.push(
`Migration "${migration.title}" must declare at least one scope.`,
);
} }
for (const scope of migration.scope) { for (const scope of migration.scope) {
if (!SCHEMA_MIGRATION_SCOPES.includes(scope)) { if (!SCHEMA_MIGRATION_SCOPES.includes(scope)) {
@@ -155,12 +157,13 @@ export const validateSchemaMigrations = (
return errors; return errors;
}; };
const schemaMigrationValidationErrors = validateSchemaMigrations( const schemaMigrationValidationErrors =
SCHEMA_MIGRATIONS, validateSchemaMigrations(SCHEMA_MIGRATIONS);
);
if (schemaMigrationValidationErrors.length) { if (schemaMigrationValidationErrors.length) {
throw new Error( throw new Error(
`Invalid schema migration configuration:\n${schemaMigrationValidationErrors.join("\n")}`, `Invalid schema migration configuration:\n${schemaMigrationValidationErrors.join(
"\n",
)}`,
); );
} }
@@ -235,23 +238,19 @@ const migrateElementsByScope = (
export const migrateSceneElements = ( export const migrateSceneElements = (
elements: readonly ExcalidrawElement[] | null | undefined, elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource, source: SchemaVersionSource,
) => ) => migrateElementsByScope(elements, "scene", source);
migrateElementsByScope(elements, "scene", source);
export const migrateLibraryElements = ( export const migrateLibraryElements = (
elements: readonly ExcalidrawElement[] | null | undefined, elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource, source: SchemaVersionSource,
) => ) => migrateElementsByScope(elements, "library", source);
migrateElementsByScope(elements, "library", source);
export const migrateClipboardElements = ( export const migrateClipboardElements = (
elements: readonly ExcalidrawElement[] | null | undefined, elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource, source: SchemaVersionSource,
) => ) => migrateElementsByScope(elements, "clipboard", source);
migrateElementsByScope(elements, "clipboard", source);
export const migrateAPIElements = ( export const migrateAPIElements = (
elements: readonly ExcalidrawElement[] | null | undefined, elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource, source: SchemaVersionSource,
) => ) => migrateElementsByScope(elements, "api", source);
migrateElementsByScope(elements, "api", source);
@@ -121,7 +121,8 @@ describe("restoreElements", () => {
); );
expect( expect(
(restoredLibraryItems[0].elements[0] as ExcalidrawElement).backgroundColor, (restoredLibraryItems[0].elements[0] as ExcalidrawElement)
.backgroundColor,
).toBe("#a5d8ff"); ).toBe("#a5d8ff");
}); });
@@ -145,7 +146,8 @@ describe("restoreElements", () => {
); );
expect( expect(
(restoredLibraryItems[0].elements[0] as ExcalidrawElement).backgroundColor, (restoredLibraryItems[0].elements[0] as ExcalidrawElement)
.backgroundColor,
).toBe(DEFAULT_ELEMENT_PROPS.backgroundColor); ).toBe(DEFAULT_ELEMENT_PROPS.backgroundColor);
}); });