From b3b9b26979995f44bf1923ec6376dfbd12cb5e13 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 20 Mar 2026 14:03:22 +1100 Subject: [PATCH] fix: smooth out schema-version edges --- excalidraw-app/App.tsx | 56 +++++++++++++++---- excalidraw-app/app_constants.ts | 1 + excalidraw-app/data/LocalData.ts | 5 ++ excalidraw-app/data/index.ts | 2 + excalidraw-app/data/localStorage.ts | 15 ++++- .../excalidraw/components/PublishLibrary.tsx | 2 + packages/excalidraw/data/blob.ts | 6 +- packages/excalidraw/data/json.ts | 1 + packages/excalidraw/data/library.ts | 33 ++++++++--- packages/excalidraw/data/restore.ts | 16 ++++-- packages/excalidraw/data/types.ts | 1 + .../excalidraw/tests/data/restore.test.ts | 55 +++++++++++++++++- packages/excalidraw/tests/library.test.tsx | 8 +++ 13 files changed, 177 insertions(+), 24 deletions(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 2e71cc6788..da7e73d51e 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -53,6 +53,11 @@ import { restoreAppState, restoreElements, } from "@excalidraw/excalidraw/data/restore"; +import { + migrateElementsBySchema, + resolveSchemaVersion, + SCHEMA_VERSIONS, +} from "@excalidraw/excalidraw/data/schema"; import { newElementWith } from "@excalidraw/element"; import { isInitializedImageElement } from "@excalidraw/element"; import clsx from "clsx"; @@ -236,10 +241,20 @@ const initializeScene = async (opts: { > & { scrollToContent?: boolean; } = { - elements: restoreElements(localDataState?.elements, null, { - repairBindings: true, - deleteInvisibleElements: true, - }), + elements: restoreElements( + migrateElementsBySchema( + localDataState?.elements, + resolveSchemaVersion( + localDataState?.schemaVersion, + SCHEMA_VERSIONS.initial, + ), + ), + null, + { + repairBindings: true, + deleteInvisibleElements: true, + }, + ), appState: restoreAppState(localDataState?.appState, null), }; @@ -262,11 +277,21 @@ const initializeScene = async (opts: { scene = { elements: bumpElementVersions( - restoreElements(imported.elements, null, { - repairBindings: true, - deleteInvisibleElements: true, - }), - localDataState?.elements, + restoreElements( + migrateElementsBySchema( + imported.elements, + resolveSchemaVersion( + imported.schemaVersion, + SCHEMA_VERSIONS.initial, + ), + ), + null, + { + repairBindings: true, + deleteInvisibleElements: true, + }, + ), + scene.elements, ), appState: restoreAppState( imported.appState, @@ -549,9 +574,20 @@ const ExcalidrawWrapper = () => { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { const localDataState = importFromLocalStorage(); const username = importUsernameFromLocalStorage(); + const migratedElements = migrateElementsBySchema( + localDataState?.elements, + resolveSchemaVersion( + localDataState?.schemaVersion, + SCHEMA_VERSIONS.initial, + ), + ); setLangCode(getPreferredLanguage()); excalidrawAPI.updateScene({ - ...localDataState, + elements: restoreElements(migratedElements, null, { + repairBindings: true, + deleteInvisibleElements: true, + }), + appState: restoreAppState(localDataState?.appState, null), captureUpdate: CaptureUpdateAction.NEVER, }); LibraryIndexedDBAdapter.load().then((data) => { diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index e4370e7986..5842360360 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -39,6 +39,7 @@ export const ROOM_ID_BYTES = 10; export const STORAGE_KEYS = { LOCAL_STORAGE_ELEMENTS: "excalidraw", LOCAL_STORAGE_APP_STATE: "excalidraw-state", + LOCAL_STORAGE_SCHEMA_VERSION: "excalidraw-schema-version", LOCAL_STORAGE_COLLAB: "excalidraw-collab", LOCAL_STORAGE_THEME: "excalidraw-theme", LOCAL_STORAGE_DEBUG: "excalidraw-debug", diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index 13cdf09ac4..b4db90cd4e 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -28,6 +28,7 @@ import { import { appJotaiStore, atom } from "excalidraw-app/app-jotai"; import { getNonDeletedElements } from "@excalidraw/element"; +import { SCHEMA_VERSIONS } from "@excalidraw/excalidraw/data/schema"; import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; @@ -94,6 +95,10 @@ const saveDataStateToLocalStorage = ( STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, JSON.stringify(_appState), ); + localStorage.setItem( + STORAGE_KEYS.LOCAL_STORAGE_SCHEMA_VERSION, + `${SCHEMA_VERSIONS.latest}`, + ); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); if (localStorageQuotaExceeded) { appJotaiStore.set(localStorageQuotaExceededAtom, false); diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index bf765941a2..1afcb020a3 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -196,6 +196,7 @@ const legacy_decodeFromBackend = async ({ return { elements: data.elements || null, appState: data.appState || null, + schemaVersion: data.schemaVersion, }; }; @@ -226,6 +227,7 @@ export const importFromBackend = async ( return { elements: data.elements || null, appState: data.appState || null, + schemaVersion: data.schemaVersion, }; } catch (error: any) { console.warn( diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts index 28c166cd74..4f6036a36a 100644 --- a/excalidraw-app/data/localStorage.ts +++ b/excalidraw-app/data/localStorage.ts @@ -2,6 +2,10 @@ import { clearAppStateForLocalStorage, getDefaultAppState, } from "@excalidraw/excalidraw/appState"; +import { + resolveSchemaVersion, + SCHEMA_VERSIONS, +} from "@excalidraw/excalidraw/data/schema"; import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { AppState } from "@excalidraw/excalidraw/types"; @@ -37,10 +41,14 @@ export const importUsernameFromLocalStorage = (): string | null => { export const importFromLocalStorage = () => { let savedElements = null; let savedState = null; + let savedSchemaVersion = null; try { savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS); savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); + savedSchemaVersion = localStorage.getItem( + STORAGE_KEYS.LOCAL_STORAGE_SCHEMA_VERSION, + ); } catch (error: any) { // Unable to access localStorage console.error(error); @@ -70,7 +78,12 @@ export const importFromLocalStorage = () => { // Do nothing because appState is already null } } - return { elements, appState }; + const schemaVersion = resolveSchemaVersion( + Number(savedSchemaVersion), + SCHEMA_VERSIONS.initial, + ); + + return { elements, appState, schemaVersion }; }; export const getElementsStorageSize = () => { diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx index 0376f812a5..3be3bb61e0 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"; import { canvasToBlob, resizeImageFile } from "../data/blob"; @@ -280,6 +281,7 @@ const PublishLibrary = ({ const libContent: ExportedLibraryData = { type: EXPORT_DATA_TYPES.excalidrawLibrary, version: VERSIONS.excalidrawLibrary, + schemaVersion: SCHEMA_VERSIONS.latest, source: getExportSource(), libraryItems: clonedLibItems, }; diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index 086bfc4494..f211329f17 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -235,7 +235,11 @@ export const parseLibraryJSON = ( throw new Error("Invalid library"); } const libraryItems = data.libraryItems || data.library; - return restoreLibraryItems(libraryItems, defaultStatus); + return restoreLibraryItems( + libraryItems, + defaultStatus, + resolveSchemaVersion(data.schemaVersion, SCHEMA_VERSIONS.initial), + ); }; export const loadLibraryFromBlob = async ( diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts index ea3e83b159..e2da81813b 100644 --- a/packages/excalidraw/data/json.ts +++ b/packages/excalidraw/data/json.ts @@ -131,6 +131,7 @@ export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => { const data: ExportedLibraryData = { type: EXPORT_DATA_TYPES.excalidrawLibrary, version: VERSIONS.excalidrawLibrary, + schemaVersion: SCHEMA_VERSIONS.latest, source: getExportSource(), libraryItems, }; diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index abe2fec853..0d0ca23e46 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -33,6 +33,7 @@ import { t } from "../i18n"; import { loadLibraryFromBlob } from "./blob"; import { restoreLibraryItems } from "./restore"; +import { resolveSchemaVersion, SCHEMA_VERSIONS } from "./schema"; import type App from "../components/App"; @@ -65,9 +66,10 @@ type LibraryUpdate = { updatedItems: Map; }; -// an object so that we can later add more properties to it without breaking, -// such as schema version -export type LibraryPersistedData = { libraryItems: LibraryItems }; +export type LibraryPersistedData = { + libraryItems: LibraryItems; + schemaVersion?: number; +}; const onLibraryUpdateEmitter = new Emitter< [update: LibraryUpdate, libraryItems: LibraryItems] @@ -89,7 +91,9 @@ export interface LibraryPersistenceAdapter { * purposes, in which case host app can implement more aggressive caching. */ source: LibraryAdatapterSource; - }): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | 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; } @@ -99,7 +103,9 @@ export interface LibraryMigrationAdapter { * loads data from legacy data source. Returns `null` if no data is * to be migrated. */ - load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; + load(): MaybePromise< + { libraryItems: LibraryItems_anyVersion; schemaVersion?: number } | null + >; /** clears entire storage afterwards */ clear(): MaybePromise; @@ -554,7 +560,13 @@ class AdapterTransaction { new Promise(async (resolve, reject) => { try { const data = await adapter.load({ source }); - resolve(restoreLibraryItems(data?.libraryItems || [], "published")); + resolve( + restoreLibraryItems( + data?.libraryItems || [], + "published", + resolveSchemaVersion(data?.schemaVersion, SCHEMA_VERSIONS.initial), + ), + ); } catch (error: any) { reject(error); } @@ -662,7 +674,10 @@ const persistLibraryUpdate = async ( const version = getLibraryItemsHash(nextLibraryItems); if (version !== lastSavedLibraryItemsHash) { - await adapter.save({ libraryItems: nextLibraryItems }); + await adapter.save({ + libraryItems: nextLibraryItems, + schemaVersion: SCHEMA_VERSIONS.latest, + }); } lastSavedLibraryItemsHash = version; @@ -862,6 +877,10 @@ export const useHandleLibrary = ( restoredData = restoreLibraryItems( libraryData.libraryItems || [], "published", + resolveSchemaVersion( + libraryData.schemaVersion, + SCHEMA_VERSIONS.initial, + ), ); // we don't queue this operation because it's running inside diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 4a029cc2be..0c5d4f0b43 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -81,6 +81,7 @@ import { getNormalizedGridStep, getNormalizedZoom, } from "../scene"; +import { migrateElementsBySchema, SCHEMA_VERSIONS } from "./schema"; import type { AppState, @@ -956,9 +957,15 @@ export const restoreAppState = ( }; }; -const restoreLibraryItem = (libraryItem: LibraryItem) => { +const restoreLibraryItem = ( + libraryItem: LibraryItem, + schemaVersion: number, +) => { const elements = restoreElements( - getNonDeletedElements(libraryItem.elements), + migrateElementsBySchema( + getNonDeletedElements(libraryItem.elements), + schemaVersion, + ), null, ); return elements.length ? { ...libraryItem, elements } : null; @@ -967,6 +974,7 @@ const restoreLibraryItem = (libraryItem: LibraryItem) => { export const restoreLibraryItems = ( libraryItems: ImportedDataState["libraryItems"] = [], defaultStatus: LibraryItem["status"], + schemaVersion: number = SCHEMA_VERSIONS.latest, ) => { const restoredItems: LibraryItem[] = []; for (const item of libraryItems) { @@ -977,7 +985,7 @@ export const restoreLibraryItems = ( elements: item, id: randomId(), created: Date.now(), - }); + }, schemaVersion); if (restoredItem) { restoredItems.push(restoredItem); } @@ -991,7 +999,7 @@ export const restoreLibraryItems = ( id: _item.id || randomId(), status: _item.status || defaultStatus, created: _item.created || Date.now(), - }); + }, schemaVersion); if (restoredItem) { restoredItems.push(restoredItem); } diff --git a/packages/excalidraw/data/types.ts b/packages/excalidraw/data/types.ts index b2e149eb0d..615a59b29d 100644 --- a/packages/excalidraw/data/types.ts +++ b/packages/excalidraw/data/types.ts @@ -54,6 +54,7 @@ export interface ImportedDataState { export interface ExportedLibraryData { type: string; version: typeof VERSIONS.excalidrawLibrary; + schemaVersion: number; source: string; libraryItems: LibraryItems; } diff --git a/packages/excalidraw/tests/data/restore.test.ts b/packages/excalidraw/tests/data/restore.test.ts index 9a2e911d44..f610fa7c5e 100644 --- a/packages/excalidraw/tests/data/restore.test.ts +++ b/packages/excalidraw/tests/data/restore.test.ts @@ -1,7 +1,12 @@ import { pointFrom } from "@excalidraw/math"; import { vi } from "vitest"; -import { DEFAULT_SIDEBAR, FONT_FAMILY, ROUNDNESS } from "@excalidraw/common"; +import { + DEFAULT_ELEMENT_PROPS, + DEFAULT_SIDEBAR, + FONT_FAMILY, + ROUNDNESS, +} from "@excalidraw/common"; import { newElementWith } from "@excalidraw/element"; import * as sizeHelpers from "@excalidraw/element"; @@ -19,6 +24,7 @@ import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types"; import { API } from "../helpers/api"; import * as restore from "../../data/restore"; +import { SCHEMA_VERSIONS } from "../../data/schema"; import { getDefaultAppState } from "../../appState"; import type { ImportedDataState } from "../../data/types"; @@ -96,6 +102,53 @@ describe("restoreElements", () => { expect(restoredFrame.backgroundColor).toBe("#ffc9c9"); }); + it("should restore library frame backgrounds by default", () => { + const frame = API.createElement({ + type: "frame", + backgroundColor: "#a5d8ff", + }); + + const restoredLibraryItems = restore.restoreLibraryItems( + [ + { + id: "library-item-1", + status: "published", + elements: [frame], + created: Date.now(), + }, + ], + "published", + ); + + expect( + (restoredLibraryItems[0].elements[0] as ExcalidrawElement).backgroundColor, + ).toBe("#a5d8ff"); + }); + + it("should normalize legacy library frame backgrounds when schema is old", () => { + const frame = API.createElement({ + type: "frame", + backgroundColor: "#a5d8ff", + }); + + const restoredLibraryItems = restore.restoreLibraryItems( + [ + { + id: "library-item-1", + status: "published", + elements: [frame], + created: Date.now(), + }, + ], + "published", + SCHEMA_VERSIONS.initial, + ); + + expect( + (restoredLibraryItems[0].elements[0] as ExcalidrawElement).backgroundColor, + ).toBe(DEFAULT_ELEMENT_PROPS.backgroundColor); + }); + it("should restore text element correctly passing value for each attribute", () => { const textElement = API.createElement({ type: "text", diff --git a/packages/excalidraw/tests/library.test.tsx b/packages/excalidraw/tests/library.test.tsx index 7a8dab860f..e3f0ee2233 100644 --- a/packages/excalidraw/tests/library.test.tsx +++ b/packages/excalidraw/tests/library.test.tsx @@ -11,6 +11,7 @@ import type { ExcalidrawGenericElement } from "@excalidraw/element/types"; import { parseLibraryJSON } from "../data/blob"; import { serializeLibraryAsJSON } from "../data/json"; import { distributeLibraryItemsOnSquareGrid } from "../data/library"; +import { SCHEMA_VERSIONS } from "../data/schema"; import { Excalidraw } from "../index"; import { API } from "./helpers/api"; @@ -167,6 +168,13 @@ describe("library", () => { }); }); + it("should include schema version when serializing library", () => { + const serialized = serializeLibraryAsJSON([]); + const parsed = JSON.parse(serialized); + + expect(parsed.schemaVersion).toBe(SCHEMA_VERSIONS.latest); + }); + // NOTE: mocked to test logic, not actual drag&drop via UI it("drop library item onto canvas", async () => { expect(h.elements).toEqual([]);