diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 64cc94f551..8ef4ffbf95 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -127,7 +127,15 @@ if (appDraftHandle) UserDraft.stopSync('app', appDraftPath) // Prefer a prior-session autosave over the prop (the route seeds an empty // template on `new_draft=true`; it calls `UserDraft.remove` to force a reset). - const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app)) + // A draft is a stored value that can predate any schema migration, so whichever + // value is adopted needs the same normalization as the prop above. + const stateApp = $state( + untrack(() => { + const adopted = appDraftHandle?.draft ?? app + migrateApp(adopted) + return adopted + }) + ) const appStore = writable(stateApp) // Mirror `stateApp` mutations into the autosave cell. The first mirror is the // seed — `acquireEntry`'s `skipNextWrite` swallows it; later writes POST. diff --git a/frontend/src/lib/components/apps/migrateApp.ts b/frontend/src/lib/components/apps/migrateApp.ts index 7e6d4f12cb..323d020d60 100644 --- a/frontend/src/lib/components/apps/migrateApp.ts +++ b/frontend/src/lib/components/apps/migrateApp.ts @@ -4,10 +4,11 @@ import { allItems } from './editor/appUtilsCore' /** * Normalize an `App` in place to the current schema: default `hiddenInlineScripts` - * type, migrate the legacy `doNotRecomputeOnInputChanged` flag, and default - * `fullHeight` on every grid item. Lives in its own light module (no app-editor - * component imports) so non-editor callers — e.g. the localStorage→DB draft - * migration — can reuse it without pulling the whole `apps/utils` graph. + * type, migrate the legacy `doNotRecomputeOnInputChanged` flag, default a missing + * `grid`, and default `fullHeight` on every grid item. Lives in its own light + * module (no app-editor component imports) so non-editor callers, e.g. the + * localStorage→DB draft migration, can reuse it without pulling the whole + * `apps/utils` graph. */ export function migrateApp(app: App) { ;(app?.hiddenInlineScripts ?? []).forEach((x) => { @@ -22,6 +23,14 @@ export function migrateApp(app: App) { } }) + // A stored app value can have no `grid` at all: a persisted draft row is the + // confirmed case, and the editor renders a draft in place of the deployed + // value. The grid components dereference it unguarded, so normalize it here, + // the one hook every load path runs through. + if (!Array.isArray(app.grid)) { + app.grid = [] + } + allItems(app.grid, app.subgrids).forEach((x) => { gridColumns.forEach((column: number) => { if (x?.[column]?.fullHeight === undefined) {