diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 5d86cfc64a..30fa27f605 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -54,6 +54,7 @@ import { restoreElements, } from "@excalidraw/excalidraw/data/restore"; import { + hasElementSchemaVersion, migrateSceneElements, SCHEMA_VERSIONS, } from "@excalidraw/excalidraw/data/schema"; @@ -231,6 +232,17 @@ const initializeScene = async (opts: { const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/); const localDataState = importFromLocalStorage(); + const localStorageSchemaVersionSource = hasElementSchemaVersion( + localDataState?.elements, + ) + ? { + payloadSchemaVersion: undefined, + fallbackVersion: SCHEMA_VERSIONS.initial, + } + : { + payloadSchemaVersion: localDataState?.schemaVersion, + fallbackVersion: SCHEMA_VERSIONS.initial, + }; let scene: Omit< RestoredDataState, @@ -243,10 +255,7 @@ const initializeScene = async (opts: { elements: restoreElements( migrateSceneElements( localDataState?.elements, - { - payloadSchemaVersion: localDataState?.schemaVersion, - fallbackVersion: SCHEMA_VERSIONS.initial, - }, + localStorageSchemaVersionSource, ), null, { @@ -573,12 +582,20 @@ const ExcalidrawWrapper = () => { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { const localDataState = importFromLocalStorage(); const username = importUsernameFromLocalStorage(); + const localStorageSchemaVersionSource = hasElementSchemaVersion( + localDataState?.elements, + ) + ? { + payloadSchemaVersion: undefined, + fallbackVersion: SCHEMA_VERSIONS.initial, + } + : { + payloadSchemaVersion: localDataState?.schemaVersion, + fallbackVersion: SCHEMA_VERSIONS.initial, + }; const migratedElements = migrateSceneElements( localDataState?.elements, - { - payloadSchemaVersion: localDataState?.schemaVersion, - fallbackVersion: SCHEMA_VERSIONS.initial, - }, + localStorageSchemaVersionSource, ); setLangCode(getPreferredLanguage()); excalidrawAPI.updateScene({ diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index b4db90cd4e..e6d06e786e 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -87,9 +87,14 @@ const saveDataStateToLocalStorage = ( _appState.openSidebar = null; } + const persistedElements = getNonDeletedElements(elements).map((element) => ({ + ...element, + schemaVersion: SCHEMA_VERSIONS.latest, + })); + localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, - JSON.stringify(getNonDeletedElements(elements)), + JSON.stringify(persistedElements), ); localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, diff --git a/packages/excalidraw/data/schema.test.ts b/packages/excalidraw/data/schema.test.ts index dd3337ea22..77d068c193 100644 --- a/packages/excalidraw/data/schema.test.ts +++ b/packages/excalidraw/data/schema.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common"; import { API } from "../tests/helpers/api"; import { ALL_SCOPES, + hasElementSchemaVersion, type SchemaMigration, migrateAPIElements, migrateClipboardElements, @@ -179,6 +180,34 @@ describe("schema migration", () => { expect(migrated[0].backgroundColor).toBe("#ff0000"); }); + it("should migrate mixed-hint elements individually when payload schema is missing", () => { + const legacyFrame = API.createElement({ + type: "frame", + backgroundColor: "#ff0000", + }); + const modernFrame = API.createElement({ + type: "frame", + backgroundColor: "#00ff00", + }); + const modernFrameWithHint = { + ...modernFrame, + schemaVersion: SCHEMA_VERSIONS.latest, + } as typeof modernFrame & { schemaVersion: number }; + + const migrated = migrateSceneElements( + [legacyFrame, modernFrameWithHint], + { + payloadSchemaVersion: undefined, + fallbackVersion: SCHEMA_VERSIONS.initial, + }, + )!; + + expect(migrated[0].backgroundColor).toBe( + DEFAULT_ELEMENT_PROPS.backgroundColor, + ); + expect(migrated[1].backgroundColor).toBe("#00ff00"); + }); + it("should prefer payload schema over per-element schema", () => { const frame = API.createElement({ type: "frame", @@ -198,4 +227,15 @@ describe("schema migration", () => { DEFAULT_ELEMENT_PROPS.backgroundColor, ); }); + + it("should detect schema hints on elements", () => { + const frame = API.createElement({ type: "frame" }); + const withHint = { + ...frame, + schemaVersion: SCHEMA_VERSIONS.latest, + } as typeof frame & { schemaVersion: number }; + + expect(hasElementSchemaVersion([frame])).toBe(false); + expect(hasElementSchemaVersion([withHint])).toBe(true); + }); }); diff --git a/packages/excalidraw/data/schema.ts b/packages/excalidraw/data/schema.ts index 222c32800d..06e60ce2f0 100644 --- a/packages/excalidraw/data/schema.ts +++ b/packages/excalidraw/data/schema.ts @@ -85,6 +85,15 @@ const isValidSchemaVersion = ( ); }; +export const hasElementSchemaVersion = ( + elements: readonly ExcalidrawElement[] | null | undefined, +) => + !!elements?.some((element) => + isValidSchemaVersion( + (element as ExcalidrawElement & { schemaVersion?: number }).schemaVersion, + ), + ); + export const validateSchemaMigrations = ( migrations: readonly SchemaMigration[], ) => {