fix: smooth out schema-version edges

This commit is contained in:
Ryan Di
2026-03-20 14:03:22 +11:00
parent 21d26b1afe
commit b3b9b26979
13 changed files with 177 additions and 24 deletions
+46 -10
View File
@@ -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) => {
+1
View File
@@ -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",
+5
View File
@@ -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);
+2
View File
@@ -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(
+14 -1
View File
@@ -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 = () => {
@@ -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,
};
+5 -1
View File
@@ -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 (
+1
View File
@@ -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,
};
+26 -7
View File
@@ -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<LibraryItem["id"], LibraryItem>;
};
// 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<void>;
}
@@ -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<void>;
@@ -554,7 +560,13 @@ class AdapterTransaction {
new Promise<LibraryItems>(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
+12 -4
View File
@@ -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);
}
+1
View File
@@ -54,6 +54,7 @@ export interface ImportedDataState {
export interface ExportedLibraryData {
type: string;
version: typeof VERSIONS.excalidrawLibrary;
schemaVersion: number;
source: string;
libraryItems: LibraryItems;
}
+54 -1
View File
@@ -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",
@@ -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([]);