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); });