From abea4d2a8a9aefea3a46768b36878ad2238214dd Mon Sep 17 00:00:00 2001 From: tomdebone Date: Tue, 7 Jul 2026 20:54:35 +0200 Subject: [PATCH] fix(tauri-app): kill effect_update_depth_exceeded via untrack + setView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent Svelte 5 effect-loop bugs that triggered the same 'effect_update_depth_exceeded' guard: 1. PostCard.svelte: the embed-quote-fetch $effect and the likedBox $effect.pre read a state variable (quotedLoading / likedBox) and then synchronously wrote to it in the same effect run. Svelte 5's effect tracker schedules possible_effect_self_invalidation on the touched state, the effect re-fires immediately, and the cycle trips the 'flush_count > 1000' guard. With ~30 PostCards mounting on login the per-card loop compounds into the depth exceeded error. Wrap the read+write blocks in untrack() so the hydration flags don't contribute to the effect's dep set; the outer 'post.embed.uri / post.did / post.rkey' reads remain tracked so navigation between cards still triggers a fresh hydrate. 2. App.svelte: the four $effect blocks (home-refresh, home-poll, profile-refresh, search-debounce) lived and died together. Even after splitting, Svelte 5 still flagged the call chain into refreshTimeline / refreshProfile because their sync prelude writes 'timelineLoading = true' / 'profileLoading = true' while the effect already tracks the same downstream state via the proxy. Drive everything imperatively through a single setView(v) function and move the 5s poll into the session.subscribe callback, which fires only on actual login/logout transitions. setView is the single point that flips view AND triggers the right refresh per destination — NavRail on_select, LoginScreen onLogin, handleLogout, the 'back to timeline' button, and the tray navigate-event bridge all route through it now. Also: NavRail and StatusBar self-style their grid-area in their own component styles ('grid-area: rail' / 'status') so App.svelte doesn't need the fragile '$state.s-XXX > nav.rail' cross-component selector that Svelte 5 was failing to match in the Tauri webview, leaving rail buttons invisible to clicks. --- crates/tauri-app/src/App.svelte | 84 ++++++++++++------- .../src/lib/components/NavRail.svelte | 8 ++ .../src/lib/components/PostCard.svelte | 43 ++++++++-- .../src/lib/components/StatusBar.svelte | 4 + 4 files changed, 100 insertions(+), 39 deletions(-) diff --git a/crates/tauri-app/src/App.svelte b/crates/tauri-app/src/App.svelte index 075476d..ec34535 100644 --- a/crates/tauri-app/src/App.svelte +++ b/crates/tauri-app/src/App.svelte @@ -34,7 +34,6 @@ let timelineError: string | null = $state(null); let seenUris: Set = new Set(); let _statusTimer: number | undefined; - let _timelinePollTimer: number | undefined; // Profile state. let profile: ProfileResponse | null = $state(null); @@ -93,7 +92,7 @@ // — `main.ts` is the only place that wires to the Tauri event bus). function onNavigateToView(e: Event) { const detail = (e as CustomEvent<{ view: View }>).detail; - if (detail?.view) view = detail.view; + if (detail?.view) setView(detail.view); } function onNotification(e: Event) { const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail; @@ -133,7 +132,7 @@ } } else { // unknown scheme — just open home - view = "home"; + setView("home"); } } @@ -186,7 +185,7 @@ registerCleanup(() => { if (_sessionUnsub) _sessionUnsub(); if (_statusTimer) clearInterval(_statusTimer); - if (_timelinePollTimer) clearInterval(_timelinePollTimer); + if (_pollTimer != null) clearInterval(_pollTimer); if (_searchDebounce) clearTimeout(_searchDebounce); if (typeof window !== "undefined") { window.removeEventListener("maarcadetweet:toast", _toastHandler); @@ -201,6 +200,11 @@ _sessionUnsub = session.subscribe((s) => { currentUser = s; status = { ...status, did: s?.did, handle: s?.handle, authenticated: !!s }; + // Drive the 5s poll off the session lifecycle instead of a + // reactive effect — the effect form kept tripping Svelte 5's + // depth guard. + if (s) startPoll(); + else stopPoll(); }); if (typeof window !== "undefined") { @@ -221,29 +225,48 @@ })(); }); - // Re-fetch the home timeline whenever we navigate to "home" or - // when the logged-in user changes. We also poll every 5s while - // the home view is active so new posts trickle in. - $effect(() => { - if (view === "home" && currentUser) { + // Imperative view-switching. We dispatch from a single function + // (called by NavRail on_select, the LoginScreen onLogin path, and + // the tray-event bridge) so every view transition runs the same + // side effects in one place. Previously this was four separate + // `$effect` blocks that read `view` / `currentUser` and called + // `refreshTimeline` / `refreshProfile` / `scheduleSearch`. Svelte + // 5's depth tracker kept aborting with `effect_update_depth_exceeded` + // because the sync portions of those refresh functions (`timelineLoading + // = true`, `profileLoading = true`) wrote $state that the effect's + // proxy-tracking had flagged as a self-write. Driving everything + // imperatively from a setter sidesteps the reactive cycle. + function setView(next: View) { + const prev = view; + view = next; + if (!currentUser) return; + + // Entering home from elsewhere — pull a fresh timeline and + // (re)start the poll timer. Leaving home clears it. + if (next === "home" && prev !== "home") { void refreshTimeline(true); - if (_timelinePollTimer) clearInterval(_timelinePollTimer); - _timelinePollTimer = window.setInterval(() => { - void refreshTimeline(false); // poll = prepend new posts, don't wipe - }, 5000); - } else if (_timelinePollTimer) { - clearInterval(_timelinePollTimer); - _timelinePollTimer = undefined; } - - if (view === "profile" && currentUser) { - void refreshProfile(currentUser.handle); + if (next === "profile") { + const handle = currentUser.handle; + void refreshProfile(handle); } - - if (view === "search" && currentUser && searchQuery.trim().length > 0) { + if (next === "search" && searchQuery.trim().length > 0) { scheduleSearch(); } - }); + } + + let _pollTimer: number | undefined; + function startPoll() { + if (_pollTimer != null) return; + _pollTimer = window.setInterval(() => { + if (view === "home") void refreshTimeline(false); + }, 5000); + } + function stopPoll() { + if (_pollTimer == null) return; + window.clearInterval(_pollTimer); + _pollTimer = undefined; + } async function refreshTimeline(reset: boolean) { if (!currentUser) return; @@ -356,7 +379,7 @@ async function handleLogout() { try { await session.logout(); - view = "home"; + setView("home"); profile = null; searchResults = []; threadRoot = null; @@ -400,7 +423,7 @@ { currentUser = s; - view = "home"; + setView("home"); }} /> @@ -408,9 +431,7 @@
{ - view = v; - }} + on_select={(v) => setView(v)} />
@@ -580,7 +601,7 @@
@@ -720,8 +741,11 @@ "rail status"; min-height: 0; } - .shell > :global(nav.rail) { grid-area: rail; } - .shell > :global(.statusbar) { grid-area: status; } + /* NavRail and StatusBar self-assign their own grid-area + (`grid-area: rail` / `grid-area: status`) in their component + styles, so the parent doesn't need any :global() child + selectors. The `.main` slot is just the next sibling; we set + its grid-area explicitly below. */ .main { grid-area: main; overflow: auto; diff --git a/crates/tauri-app/src/lib/components/NavRail.svelte b/crates/tauri-app/src/lib/components/NavRail.svelte index 9768c51..3eb31a5 100644 --- a/crates/tauri-app/src/lib/components/NavRail.svelte +++ b/crates/tauri-app/src/lib/components/NavRail.svelte @@ -62,7 +62,15 @@