diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx
index 30fa27f605..e3691c6d72 100644
--- a/excalidraw-app/App.tsx
+++ b/excalidraw-app/App.tsx
@@ -286,13 +286,10 @@ const initializeScene = async (opts: {
scene = {
elements: bumpElementVersions(
restoreElements(
- migrateSceneElements(
- imported.elements,
- {
- payloadSchemaVersion: imported.schemaVersion,
- fallbackVersion: SCHEMA_VERSIONS.initial,
- },
- ),
+ migrateSceneElements(imported.elements, {
+ payloadSchemaVersion: imported.schemaVersion,
+ fallbackVersion: SCHEMA_VERSIONS.initial,
+ }),
null,
{
repairBindings: true,
diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts
index e6d06e786e..c81d6b092d 100644
--- a/excalidraw-app/data/LocalData.ts
+++ b/excalidraw-app/data/LocalData.ts
@@ -87,10 +87,12 @@ const saveDataStateToLocalStorage = (
_appState.openSidebar = null;
}
- const persistedElements = getNonDeletedElements(elements).map((element) => ({
- ...element,
- schemaVersion: SCHEMA_VERSIONS.latest,
- }));
+ const persistedElements = getNonDeletedElements(elements).map(
+ (element) => ({
+ ...element,
+ schemaVersion: SCHEMA_VERSIONS.latest,
+ }),
+ );
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
diff --git a/excalidraw-app/tests/collab.test.tsx b/excalidraw-app/tests/collab.test.tsx
index 4998c007aa..d2f5051958 100644
--- a/excalidraw-app/tests/collab.test.tsx
+++ b/excalidraw-app/tests/collab.test.tsx
@@ -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.
*/
describe("collaboration", () => {
+ it("should preserve future element fields across collab reconciliation", async () => {
+ await render();
+
+ 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();
+
+ 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 () => {
const durableIncrements: DurableIncrement[] = [];
const ephemeralIncrements: EphemeralIncrement[] = [];
diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts
index be5db302ef..07bbfb39e0 100644
--- a/packages/excalidraw/clipboard.ts
+++ b/packages/excalidraw/clipboard.ts
@@ -35,12 +35,13 @@ import {
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
+import { SCHEMA_VERSIONS } from "./data/schema";
+
import type { FileSystemHandle } from "./data/filesystem";
import type { Spreadsheet } from "./charts";
import type { BinaryFiles } from "./types";
-import { SCHEMA_VERSIONS } from "./data/schema";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx
index 42ce0b2ae7..1f8b4f41d7 100644
--- a/packages/excalidraw/components/App.tsx
+++ b/packages/excalidraw/components/App.tsx
@@ -2822,13 +2822,10 @@ class App extends React.Component {
? SCHEMA_VERSIONS.initial
: SCHEMA_VERSIONS.latest;
const restoredElements = restoreElements(
- migrateSceneElements(
- initialData?.elements,
- {
- payloadSchemaVersion: initialData?.schemaVersion,
- fallbackVersion: initialDataSchemaFallback,
- },
- ),
+ migrateSceneElements(initialData?.elements, {
+ payloadSchemaVersion: initialData?.schemaVersion,
+ fallbackVersion: initialDataSchemaFallback,
+ }),
null,
{
repairBindings: true,
@@ -3764,13 +3761,9 @@ class App extends React.Component {
? migrateLibraryElements(opts.elements, opts.schemaVersionSource)
: migrateAPIElements(opts.elements, opts.schemaVersionSource);
- const elements = restoreElements(
- migratedElements,
- null,
- {
- deleteInvisibleElements: true,
- },
- );
+ const elements = restoreElements(migratedElements, null, {
+ deleteInvisibleElements: true,
+ });
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2;
diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx
index 3be3bb61e0..23d711d2e5 100644
--- a/packages/excalidraw/components/PublishLibrary.tsx
+++ b/packages/excalidraw/components/PublishLibrary.tsx
@@ -9,6 +9,7 @@ import {
chunk,
getExportSource,
} from "@excalidraw/common";
+
import { SCHEMA_VERSIONS } from "../data/schema";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts
index 31a62d63c4..978da1680a 100644
--- a/packages/excalidraw/data/blob.ts
+++ b/packages/excalidraw/data/blob.ts
@@ -24,10 +24,7 @@ import {
restoreElements,
restoreLibraryItems,
} from "./restore";
-import {
- migrateSceneElements,
- SCHEMA_VERSIONS,
-} from "./schema";
+import { migrateSceneElements, SCHEMA_VERSIONS } from "./schema";
import type { AppState, DataURL, LibraryItem } from "../types";
@@ -161,13 +158,10 @@ export const loadSceneOrLibraryFromBlob = async (
throw error;
}
if (isValidExcalidrawData(data)) {
- const migratedElements = migrateSceneElements(
- data.elements,
- {
- payloadSchemaVersion: data.schemaVersion,
- fallbackVersion: SCHEMA_VERSIONS.initial,
- },
- );
+ const migratedElements = migrateSceneElements(data.elements, {
+ payloadSchemaVersion: data.schemaVersion,
+ fallbackVersion: SCHEMA_VERSIONS.initial,
+ });
return {
type: MIME_TYPES.excalidraw,
data: {
@@ -233,14 +227,10 @@ export const parseLibraryJSON = (
throw new Error("Invalid library");
}
const libraryItems = data.libraryItems || data.library;
- return restoreLibraryItems(
- libraryItems,
- defaultStatus,
- {
- payloadSchemaVersion: data.schemaVersion,
- fallbackVersion: SCHEMA_VERSIONS.initial,
- },
- );
+ return restoreLibraryItems(libraryItems, defaultStatus, {
+ payloadSchemaVersion: data.schemaVersion,
+ fallbackVersion: SCHEMA_VERSIONS.initial,
+ });
};
export const loadLibraryFromBlob = async (
diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts
index 99fccceb7d..fc05126e2c 100644
--- a/packages/excalidraw/data/library.ts
+++ b/packages/excalidraw/data/library.ts
@@ -91,9 +91,10 @@ export interface LibraryPersistenceAdapter {
* purposes, in which case host app can implement more aggressive caching.
*/
source: LibraryAdatapterSource;
- }): MaybePromise<
- { libraryItems: LibraryItems_anyVersion; schemaVersion?: number } | null
- >;
+ }): MaybePromise<{
+ libraryItems: LibraryItems_anyVersion;
+ schemaVersion?: number;
+ } | null>;
/** Should persist to the database as is (do no change the data structure). */
save(libraryData: LibraryPersistedData): MaybePromise;
}
@@ -103,9 +104,10 @@ export interface LibraryMigrationAdapter {
* loads data from legacy data source. Returns `null` if no data is
* to be migrated.
*/
- load(): MaybePromise<
- { libraryItems: LibraryItems_anyVersion; schemaVersion?: number } | null
- >;
+ load(): MaybePromise<{
+ libraryItems: LibraryItems_anyVersion;
+ schemaVersion?: number;
+ } | null>;
/** clears entire storage afterwards */
clear(): MaybePromise;
@@ -561,14 +563,10 @@ class AdapterTransaction {
try {
const data = await adapter.load({ source });
resolve(
- restoreLibraryItems(
- data?.libraryItems || [],
- "published",
- {
- payloadSchemaVersion: data?.schemaVersion,
- fallbackVersion: SCHEMA_VERSIONS.initial,
- },
- ),
+ restoreLibraryItems(data?.libraryItems || [], "published", {
+ payloadSchemaVersion: data?.schemaVersion,
+ fallbackVersion: SCHEMA_VERSIONS.initial,
+ }),
);
} catch (error: any) {
reject(error);
diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts
index abd8bd218e..d7eb3d2216 100644
--- a/packages/excalidraw/data/restore.ts
+++ b/packages/excalidraw/data/restore.ts
@@ -81,6 +81,7 @@ import {
getNormalizedGridStep,
getNormalizedZoom,
} from "../scene";
+
import {
migrateLibraryElements,
type SchemaVersionSource,
@@ -984,12 +985,15 @@ export const restoreLibraryItems = (
for (const item of libraryItems) {
// migrate older libraries
if (Array.isArray(item)) {
- const restoredItem = restoreLibraryItem({
- status: defaultStatus,
- elements: item,
- id: randomId(),
- created: Date.now(),
- }, schemaVersionSource);
+ const restoredItem = restoreLibraryItem(
+ {
+ status: defaultStatus,
+ elements: item,
+ id: randomId(),
+ created: Date.now(),
+ },
+ schemaVersionSource,
+ );
if (restoredItem) {
restoredItems.push(restoredItem);
}
@@ -998,12 +1002,15 @@ export const restoreLibraryItems = (
LibraryItem,
"id" | "status" | "created"
>;
- const restoredItem = restoreLibraryItem({
- ..._item,
- id: _item.id || randomId(),
- status: _item.status || defaultStatus,
- created: _item.created || Date.now(),
- }, schemaVersionSource);
+ const restoredItem = restoreLibraryItem(
+ {
+ ..._item,
+ id: _item.id || randomId(),
+ status: _item.status || defaultStatus,
+ created: _item.created || Date.now(),
+ },
+ schemaVersionSource,
+ );
if (restoredItem) {
restoredItems.push(restoredItem);
}
diff --git a/packages/excalidraw/data/schema.test.ts b/packages/excalidraw/data/schema.test.ts
index 77d068c193..f8a5803716 100644
--- a/packages/excalidraw/data/schema.test.ts
+++ b/packages/excalidraw/data/schema.test.ts
@@ -1,6 +1,7 @@
import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common";
import { API } from "../tests/helpers/api";
+
import {
ALL_SCOPES,
hasElementSchemaVersion,
@@ -194,13 +195,10 @@ describe("schema migration", () => {
schemaVersion: SCHEMA_VERSIONS.latest,
} as typeof modernFrame & { schemaVersion: number };
- const migrated = migrateSceneElements(
- [legacyFrame, modernFrameWithHint],
- {
- payloadSchemaVersion: undefined,
- fallbackVersion: SCHEMA_VERSIONS.initial,
- },
- )!;
+ const migrated = migrateSceneElements([legacyFrame, modernFrameWithHint], {
+ payloadSchemaVersion: undefined,
+ fallbackVersion: SCHEMA_VERSIONS.initial,
+ })!;
expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
diff --git a/packages/excalidraw/data/schema.ts b/packages/excalidraw/data/schema.ts
index 06e60ce2f0..6c6dcf2dde 100644
--- a/packages/excalidraw/data/schema.ts
+++ b/packages/excalidraw/data/schema.ts
@@ -15,7 +15,7 @@ export const SCHEMA_MIGRATION_SCOPES = [
"api",
] as const;
-export type SchemaMigrationScope = (typeof SCHEMA_MIGRATION_SCOPES)[number];
+export type SchemaMigrationScope = typeof SCHEMA_MIGRATION_SCOPES[number];
export const ALL_SCOPES: readonly SchemaMigrationScope[] =
SCHEMA_MIGRATION_SCOPES;
@@ -135,7 +135,9 @@ export const validateSchemaMigrations = (
);
}
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) {
if (!SCHEMA_MIGRATION_SCOPES.includes(scope)) {
@@ -155,12 +157,13 @@ export const validateSchemaMigrations = (
return errors;
};
-const schemaMigrationValidationErrors = validateSchemaMigrations(
- SCHEMA_MIGRATIONS,
-);
+const schemaMigrationValidationErrors =
+ validateSchemaMigrations(SCHEMA_MIGRATIONS);
if (schemaMigrationValidationErrors.length) {
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 = (
elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource,
-) =>
- migrateElementsByScope(elements, "scene", source);
+) => migrateElementsByScope(elements, "scene", source);
export const migrateLibraryElements = (
elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource,
-) =>
- migrateElementsByScope(elements, "library", source);
+) => migrateElementsByScope(elements, "library", source);
export const migrateClipboardElements = (
elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource,
-) =>
- migrateElementsByScope(elements, "clipboard", source);
+) => migrateElementsByScope(elements, "clipboard", source);
export const migrateAPIElements = (
elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource,
-) =>
- migrateElementsByScope(elements, "api", source);
+) => migrateElementsByScope(elements, "api", source);
diff --git a/packages/excalidraw/tests/data/restore.test.ts b/packages/excalidraw/tests/data/restore.test.ts
index f610fa7c5e..cdbeb7d128 100644
--- a/packages/excalidraw/tests/data/restore.test.ts
+++ b/packages/excalidraw/tests/data/restore.test.ts
@@ -121,7 +121,8 @@ describe("restoreElements", () => {
);
expect(
- (restoredLibraryItems[0].elements[0] as ExcalidrawElement).backgroundColor,
+ (restoredLibraryItems[0].elements[0] as ExcalidrawElement)
+ .backgroundColor,
).toBe("#a5d8ff");
});
@@ -145,7 +146,8 @@ describe("restoreElements", () => {
);
expect(
- (restoredLibraryItems[0].elements[0] as ExcalidrawElement).backgroundColor,
+ (restoredLibraryItems[0].elements[0] as ExcalidrawElement)
+ .backgroundColor,
).toBe(DEFAULT_ELEMENT_PROPS.backgroundColor);
});