From 85fda7b5dfcafcd884ac2e46c8fe5c26a06ecf86 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 14:50:19 +0200 Subject: [PATCH] fix(mana/web): three runtime regressions from sprint 1-3 data layer rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. database.ts — defer pending-change writes out of the user transaction. `taskTable.add()` opens an implicit Dexie transaction scoped only to `tasks`. The creating-hook then tried to write to `_pendingChanges`, which is not in scope, throwing `NotFoundError: object store not in scope` and breaking every create across todo/calendar/contacts/etc. `queueMicrotask` is not enough — Dexie binds the active transaction to the current zone via Promise scheduling and treats microtasks as "still inside". `setTimeout(0)` breaks out cleanly so the deferred add() spawns its own implicit transaction. 2. workbench/AppPage.svelte — guard ListView reload by appId. The list-loader $effect read `app` (a $derived of getApp(appId)) and on every reactive churn cleared `ListComponent = null`, making the whole carousel flash a spinner. After a task create, liveQuery churn propagated up enough to retrigger this effect, which looked exactly like a full page reload to the user. Now we only reload when appId itself changes, with a stale-load guard for out-of-order awaits. 3. zitare/ListView.svelte — pull `quotesStore.initialize()` out of $effect. The effect called initialize() (which writes `currentQuote` $state) and then read `currentQuote` back, creating a classic write-then-read loop that hit `effect_update_depth_exceeded`. Initialize now runs in onMount; the effect is read-only. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/components/workbench/AppPage.svelte | 26 +++++++++++++++---- apps/mana/apps/web/src/lib/data/database.ts | 26 ++++++++++++++----- .../src/lib/modules/zitare/ListView.svelte | 10 ++++++- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/apps/mana/apps/web/src/lib/components/workbench/AppPage.svelte b/apps/mana/apps/web/src/lib/components/workbench/AppPage.svelte index 72fe92a1c..454266622 100644 --- a/apps/mana/apps/web/src/lib/components/workbench/AppPage.svelte +++ b/apps/mana/apps/web/src/lib/components/workbench/AppPage.svelte @@ -61,14 +61,30 @@ let ListComponent = $state(null); let loadError = $state(false); + // Track the last appId we loaded for so we only reload when it actually + // changes — not on every reactive churn from the data layer below us. + // Without this guard, any liveQuery update inside a child ListView (e.g. + // creating a task) can re-run this effect, which clears ListComponent and + // makes the whole carousel flash like a page reload. + let lastLoadedAppId: string | undefined = undefined; + $effect(() => { + if (appId === lastLoadedAppId) return; + lastLoadedAppId = appId; + ListComponent = null; loadError = false; - if (app) { - const loader = app.views.list.load; - loader().then( - (mod) => (ListComponent = mod.default), - () => (loadError = true) + const currentApp = app; + if (currentApp) { + currentApp.views.list.load().then( + (mod) => { + // Guard against an out-of-order load if appId changed again + // while we were awaiting. + if (lastLoadedAppId === appId) ListComponent = mod.default; + }, + () => { + if (lastLoadedAppId === appId) loadError = true; + } ); } }); diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index cb3ec6bef..c96e6e8fe 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -651,15 +651,27 @@ const pendingChangesTable = db.table('_pendingChanges'); * The Dexie creating/updating hook itself is synchronous and cannot await * a recovery, so we just dispatch the event and let the UI / sync engine * decide what to do (e.g. surface a toast, run cleanupTombstones). + * + * IMPORTANT: Dexie hooks fire inside the calling write's implicit transaction + * which only includes the user-facing table (e.g. `tasks`). Writing to + * `_pendingChanges` from there hits `NotFoundError: object store not in + * scope`. We defer the write to a microtask so it runs in a fresh + * transaction after the user's commit lands. */ function trackPendingChange(table: string, change: Record): void { - pendingChangesTable.add(change).catch((err: unknown) => { - if (isQuotaError(err)) { - notifyQuotaExceeded({ table, op: 'pending-change', cleaned: 0, recovered: false }); - } else { - console.error('[mana-sync] failed to record pending change:', err); - } - }); + // setTimeout (not queueMicrotask) is required: Dexie binds the active + // transaction to the current Zone via Promise scheduling, and a microtask + // is still considered "inside" the transaction. setTimeout(0) breaks out + // completely so the new add() spawns its own implicit transaction. + setTimeout(() => { + pendingChangesTable.add(change).catch((err: unknown) => { + if (isQuotaError(err)) { + notifyQuotaExceeded({ table, op: 'pending-change', cleaned: 0, recovered: false }); + } else { + console.error('[mana-sync] failed to record pending change:', err); + } + }); + }, 0); } /** diff --git a/apps/mana/apps/web/src/lib/modules/zitare/ListView.svelte b/apps/mana/apps/web/src/lib/modules/zitare/ListView.svelte index 33e52b848..41efa2496 100644 --- a/apps/mana/apps/web/src/lib/modules/zitare/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/zitare/ListView.svelte @@ -4,6 +4,7 @@ Supports tag drag-and-drop onto the current quote. -->