fix(manacore/web): resolve effect_update_depth_exceeded and Dexie transaction errors

- Replace $effect + liveQuery().subscribe() with useLiveQueryWithDefault
  in 6 dashboard modules (todo, calendar, contacts, habits, notes, finance)
  to prevent cascading $state writes exceeding Svelte 5 effect depth limit
- Defer checkInlineSuggestion in Dexie hooks via setTimeout to avoid
  cross-table reads within a single-table transaction scope
- Add 5s timeout to trySSO fetch calls so app loads in guest mode when
  mana-auth is unreachable
- Fix guestMode reactivity by declaring with $state()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-04 10:38:57 +02:00
parent 502813f49c
commit 5fd9c1d11e
13 changed files with 61 additions and 105 deletions

View file

@ -412,9 +412,14 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
});
trackFirstContent(appId);
fireTrigger(appId, tableName, 'insert', { ...obj });
checkInlineSuggestion(appId, tableName, { ...obj }).then((sug) => {
if (sug) window.dispatchEvent(new CustomEvent('mana:automation-suggest', { detail: sug }));
});
// Defer cross-table reads outside the Dexie hook's transaction scope
const objCopy = { ...obj };
setTimeout(() => {
checkInlineSuggestion(appId, tableName, objCopy).then((sug) => {
if (sug)
window.dispatchEvent(new CustomEvent('mana:automation-suggest', { detail: sug }));
});
}, 0);
});
table.hook('updating', function (modifications, primKey) {

View file

@ -4,7 +4,7 @@
Clicking an event opens the detail view.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalEvent } from './types';
import { eventsStore } from './stores/events.svelte';
@ -29,7 +29,13 @@
}
}
let events = $state<LocalEvent[]>([]);
let events$ = useLiveQueryWithDefault(async () => {
return db
.table<LocalEvent>('events')
.toArray()
.then((all) => all.filter((e) => !e.deletedAt));
}, [] as LocalEvent[]);
let events = $derived(events$.value);
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
@ -52,18 +58,6 @@
.sort((a, b) => a.startDate.localeCompare(b.startDate))
);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalEvent>('events')
.toArray()
.then((all) => all.filter((e) => !e.deletedAt));
}).subscribe((val) => {
events = val ?? [];
});
return () => sub.unsubscribe();
});
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
}

View file

@ -4,7 +4,7 @@
Clicking a contact opens the detail view.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalContact } from './types';
import { contactsStore } from './stores/contacts.svelte';
@ -29,21 +29,15 @@
}
}
let contacts = $state<LocalContact[]>([]);
let contacts$ = useLiveQueryWithDefault(async () => {
return db
.table<LocalContact>('contacts')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt && !c.isArchived));
}, [] as LocalContact[]);
let contacts = $derived(contacts$.value);
let search = $state('');
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalContact>('contacts')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt && !c.isArchived));
}).subscribe((val) => {
contacts = val ?? [];
});
return () => sub.unsubscribe();
});
const filtered = $derived(() => {
if (!search.trim()) return contacts;
const q = search.toLowerCase();

View file

@ -22,22 +22,8 @@
let txs$ = useAllTransactions();
let cats$ = useAllCategories();
let txs = $state<Transaction[]>([]);
let categories = $state<FinanceCategory[]>([]);
$effect(() => {
const sub = txs$.subscribe((val) => {
txs = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = cats$.subscribe((val) => {
categories = val ?? [];
});
return () => sub.unsubscribe();
});
let txs = $derived(txs$.value);
let categories = $derived(cats$.value);
let month = currentMonth();
let monthTxs = $derived(getTransactionsForMonth(txs, month));

View file

@ -2,7 +2,7 @@
* Reactive Queries & Pure Helpers for Finance module.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type {
LocalTransaction,
@ -43,23 +43,23 @@ export function toCategory(local: LocalFinanceCategory): FinanceCategory {
// ─── Live Queries ──────────────────────────────────────────
export function useAllTransactions() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTransaction>('transactions').toArray();
return locals
.filter((t) => !t.deletedAt)
.map(toTransaction)
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt));
});
}, [] as Transaction[]);
}
export function useAllCategories() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalFinanceCategory>('financeCategories').toArray();
return locals
.filter((c) => !c.deletedAt)
.map(toCategory)
.sort((a, b) => a.order - b.order);
});
}, [] as FinanceCategory[]);
}
// ─── Pure Helpers ──────────────────────────────────────────

View file

@ -23,22 +23,8 @@
let habits$ = useAllHabits();
let logs$ = useAllHabitLogs();
let habits = $state<Habit[]>([]);
let logs = $state<HabitLog[]>([]);
$effect(() => {
const sub = habits$.subscribe((val) => {
habits = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = logs$.subscribe((val) => {
logs = val ?? [];
});
return () => sub.unsubscribe();
});
let habits = $derived(habits$.value);
let logs = $derived(logs$.value);
let activeHabits = $derived(getActiveHabits(habits));
let todayCounts = $derived(getTodayCounts(habits, logs));

View file

@ -1,10 +1,10 @@
/**
* Reactive Queries & Pure Helpers for Habits module.
*
* Uses Dexie liveQuery on the unified database.
* Uses useLiveQueryWithDefault on the unified database.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalHabit, LocalHabitLog, Habit, HabitLog } from './types';
import { EMOJI_TO_ICON_MAP } from './types';
@ -38,21 +38,21 @@ export function toHabitLog(local: LocalHabitLog): HabitLog {
// ─── Live Queries ──────────────────────────────────────────
export function useAllHabits() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalHabit>('habits').orderBy('order').toArray();
return locals.filter((h) => !h.deletedAt).map(toHabit);
});
}, [] as Habit[]);
}
export function useAllHabitLogs() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalHabitLog>('habitLogs').toArray();
return locals.filter((l) => !l.deletedAt).map(toHabitLog);
});
}, [] as HabitLog[]);
}
export function useHabitLogsForHabit(habitId: string) {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db
.table<LocalHabitLog>('habitLogs')
.where('habitId')
@ -62,7 +62,7 @@ export function useHabitLogsForHabit(habitId: string) {
.filter((l) => !l.deletedAt)
.map(toHabitLog)
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
});
}, [] as HabitLog[]);
}
// ─── Pure Helpers ──────────────────────────────────────────

View file

@ -13,14 +13,7 @@
let { navigate, goBack, params }: ViewProps = $props();
let notes$ = useAllNotes();
let notes = $state<Note[]>([]);
$effect(() => {
const sub = notes$.subscribe((val) => {
notes = val ?? [];
});
return () => sub.unsubscribe();
});
let notes = $derived(notes$.value);
let searchQuery = $state('');
let editingId = $state<string | null>(null);

View file

@ -2,7 +2,7 @@
* Reactive Queries & Pure Helpers for Notes module.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalNote, Note } from './types';
@ -24,7 +24,7 @@ export function toNote(local: LocalNote): Note {
// ─── Live Queries ──────────────────────────────────────────
export function useAllNotes() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalNote>('notes').toArray();
return locals
.filter((n) => !n.deletedAt && !n.isArchived)
@ -33,7 +33,7 @@ export function useAllNotes() {
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
return b.updatedAt.localeCompare(a.updatedAt);
});
});
}, [] as Note[]);
}
// ─── Pure Helpers ──────────────────────────────────────────

View file

@ -50,14 +50,7 @@
let filter = $state<ViewFilter>('inbox');
let newTitle = $state('');
let tasks$ = useAllTasks();
let tasks = $state<import('./types').Task[]>([]);
$effect(() => {
const sub = tasks$.subscribe((val) => {
tasks = val ?? [];
});
return () => sub.unsubscribe();
});
let tasks = $derived(tasks$.value);
const stats = $derived(getTaskStats(tasks));
const filtered = $derived(() => {

View file

@ -2,7 +2,7 @@
* Reactive queries & pure helpers for Todo uses Dexie liveQuery on the unified DB.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type {
LocalTask,
@ -43,34 +43,34 @@ export function toTask(local: LocalTask): Task {
// ─── Live Queries ──────────────────────────────────────────
export function useAllTasks() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTask>('tasks').orderBy('order').toArray();
return locals.filter((t) => !t.deletedAt).map(toTask);
});
}, [] as Task[]);
}
// Labels/Tags: use shared global tags from @manacore/shared-stores
export { useAllTags as useAllLabels } from '@manacore/shared-stores';
export function useAllBoardViews() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalBoardView>('boardViews').orderBy('order').toArray();
return locals.filter((v) => !v.deletedAt);
});
}, [] as LocalBoardView[]);
}
export function useAllReminders() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalReminder>('reminders').toArray();
return locals.filter((r) => !r.deletedAt);
});
}, [] as LocalReminder[]);
}
export function useAllProjects() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTodoProject>('todoProjects').orderBy('order').toArray();
return locals.filter((p) => !p.deletedAt);
});
}, [] as LocalTodoProject[]);
}
// ─── Pure Filter Functions ────────────────────────────────

View file

@ -265,7 +265,7 @@
}
// ── Guest Mode ──────────────────────────────────────────
let guestMode: GuestMode | null = null;
let guestMode = $state<GuestMode | null>(null);
// ── Onboarding ──────────────────────────────────────────
function handleOnboardingComplete() {

View file

@ -1110,12 +1110,15 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa
}
// Try to get session from cookie (credentials: 'include' sends cookies)
// Use AbortController with timeout so the app doesn't hang when auth is unreachable
const ssoAbort = AbortSignal.timeout(5000);
const response = await fetch(`${baseUrl}${endpoints.getSession}`, {
method: 'GET',
credentials: 'include', // Send cookies cross-origin
headers: {
'Content-Type': 'application/json',
},
signal: ssoAbort,
});
if (!response.ok) {
@ -1132,12 +1135,14 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa
// Now get tokens by signing in with the session
// We need to exchange the session for JWT tokens
const tokenAbort = AbortSignal.timeout(5000);
const tokenResponse = await fetch(`${baseUrl}/api/v1/auth/session-to-token`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
signal: tokenAbort,
});
if (!tokenResponse.ok) {