chore: harden local schema hints and tests

This commit is contained in:
Ryan Di
2026-03-20 19:33:01 +11:00
parent eda7c8d6e9
commit 8168d46f87
4 changed files with 80 additions and 9 deletions
+25 -8
View File
@@ -54,6 +54,7 @@ import {
restoreElements, restoreElements,
} from "@excalidraw/excalidraw/data/restore"; } from "@excalidraw/excalidraw/data/restore";
import { import {
hasElementSchemaVersion,
migrateSceneElements, migrateSceneElements,
SCHEMA_VERSIONS, SCHEMA_VERSIONS,
} from "@excalidraw/excalidraw/data/schema"; } from "@excalidraw/excalidraw/data/schema";
@@ -231,6 +232,17 @@ const initializeScene = async (opts: {
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/); const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
const localDataState = importFromLocalStorage(); const localDataState = importFromLocalStorage();
const localStorageSchemaVersionSource = hasElementSchemaVersion(
localDataState?.elements,
)
? {
payloadSchemaVersion: undefined,
fallbackVersion: SCHEMA_VERSIONS.initial,
}
: {
payloadSchemaVersion: localDataState?.schemaVersion,
fallbackVersion: SCHEMA_VERSIONS.initial,
};
let scene: Omit< let scene: Omit<
RestoredDataState, RestoredDataState,
@@ -243,10 +255,7 @@ const initializeScene = async (opts: {
elements: restoreElements( elements: restoreElements(
migrateSceneElements( migrateSceneElements(
localDataState?.elements, localDataState?.elements,
{ localStorageSchemaVersionSource,
payloadSchemaVersion: localDataState?.schemaVersion,
fallbackVersion: SCHEMA_VERSIONS.initial,
},
), ),
null, null,
{ {
@@ -573,12 +582,20 @@ const ExcalidrawWrapper = () => {
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage(); const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage(); const username = importUsernameFromLocalStorage();
const localStorageSchemaVersionSource = hasElementSchemaVersion(
localDataState?.elements,
)
? {
payloadSchemaVersion: undefined,
fallbackVersion: SCHEMA_VERSIONS.initial,
}
: {
payloadSchemaVersion: localDataState?.schemaVersion,
fallbackVersion: SCHEMA_VERSIONS.initial,
};
const migratedElements = migrateSceneElements( const migratedElements = migrateSceneElements(
localDataState?.elements, localDataState?.elements,
{ localStorageSchemaVersionSource,
payloadSchemaVersion: localDataState?.schemaVersion,
fallbackVersion: SCHEMA_VERSIONS.initial,
},
); );
setLangCode(getPreferredLanguage()); setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
+6 -1
View File
@@ -87,9 +87,14 @@ const saveDataStateToLocalStorage = (
_appState.openSidebar = null; _appState.openSidebar = null;
} }
const persistedElements = getNonDeletedElements(elements).map((element) => ({
...element,
schemaVersion: SCHEMA_VERSIONS.latest,
}));
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(getNonDeletedElements(elements)), JSON.stringify(persistedElements),
); );
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
+40
View File
@@ -3,6 +3,7 @@ import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { import {
ALL_SCOPES, ALL_SCOPES,
hasElementSchemaVersion,
type SchemaMigration, type SchemaMigration,
migrateAPIElements, migrateAPIElements,
migrateClipboardElements, migrateClipboardElements,
@@ -179,6 +180,34 @@ describe("schema migration", () => {
expect(migrated[0].backgroundColor).toBe("#ff0000"); 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", () => { it("should prefer payload schema over per-element schema", () => {
const frame = API.createElement({ const frame = API.createElement({
type: "frame", type: "frame",
@@ -198,4 +227,15 @@ describe("schema migration", () => {
DEFAULT_ELEMENT_PROPS.backgroundColor, 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);
});
}); });
+9
View File
@@ -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 = ( export const validateSchemaMigrations = (
migrations: readonly SchemaMigration[], migrations: readonly SchemaMigration[],
) => { ) => {