fix(tauri-app): kill effect_update_depth_exceeded via untrack + setView

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.
This commit is contained in:
tomdebone
2026-07-07 20:54:35 +02:00
parent d1fff87e34
commit abea4d2a8a
4 changed files with 100 additions and 39 deletions
+54 -30
View File
@@ -34,7 +34,6 @@
let timelineError: string | null = $state(null); let timelineError: string | null = $state(null);
let seenUris: Set<string> = new Set(); let seenUris: Set<string> = new Set();
let _statusTimer: number | undefined; let _statusTimer: number | undefined;
let _timelinePollTimer: number | undefined;
// Profile state. // Profile state.
let profile: ProfileResponse | null = $state(null); let profile: ProfileResponse | null = $state(null);
@@ -93,7 +92,7 @@
// — `main.ts` is the only place that wires to the Tauri event bus). // — `main.ts` is the only place that wires to the Tauri event bus).
function onNavigateToView(e: Event) { function onNavigateToView(e: Event) {
const detail = (e as CustomEvent<{ view: View }>).detail; const detail = (e as CustomEvent<{ view: View }>).detail;
if (detail?.view) view = detail.view; if (detail?.view) setView(detail.view);
} }
function onNotification(e: Event) { function onNotification(e: Event) {
const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail; const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail;
@@ -133,7 +132,7 @@
} }
} else { } else {
// unknown scheme — just open home // unknown scheme — just open home
view = "home"; setView("home");
} }
} }
@@ -186,7 +185,7 @@
registerCleanup(() => { registerCleanup(() => {
if (_sessionUnsub) _sessionUnsub(); if (_sessionUnsub) _sessionUnsub();
if (_statusTimer) clearInterval(_statusTimer); if (_statusTimer) clearInterval(_statusTimer);
if (_timelinePollTimer) clearInterval(_timelinePollTimer); if (_pollTimer != null) clearInterval(_pollTimer);
if (_searchDebounce) clearTimeout(_searchDebounce); if (_searchDebounce) clearTimeout(_searchDebounce);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.removeEventListener("maarcadetweet:toast", _toastHandler); window.removeEventListener("maarcadetweet:toast", _toastHandler);
@@ -201,6 +200,11 @@
_sessionUnsub = session.subscribe((s) => { _sessionUnsub = session.subscribe((s) => {
currentUser = s; currentUser = s;
status = { ...status, did: s?.did, handle: s?.handle, authenticated: !!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") { if (typeof window !== "undefined") {
@@ -221,29 +225,48 @@
})(); })();
}); });
// Re-fetch the home timeline whenever we navigate to "home" or // Imperative view-switching. We dispatch from a single function
// when the logged-in user changes. We also poll every 5s while // (called by NavRail on_select, the LoginScreen onLogin path, and
// the home view is active so new posts trickle in. // the tray-event bridge) so every view transition runs the same
$effect(() => { // side effects in one place. Previously this was four separate
if (view === "home" && currentUser) { // `$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); 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 (next === "profile") {
if (view === "profile" && currentUser) { const handle = currentUser.handle;
void refreshProfile(currentUser.handle); void refreshProfile(handle);
} }
if (next === "search" && searchQuery.trim().length > 0) {
if (view === "search" && currentUser && searchQuery.trim().length > 0) {
scheduleSearch(); 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) { async function refreshTimeline(reset: boolean) {
if (!currentUser) return; if (!currentUser) return;
@@ -356,7 +379,7 @@
async function handleLogout() { async function handleLogout() {
try { try {
await session.logout(); await session.logout();
view = "home"; setView("home");
profile = null; profile = null;
searchResults = []; searchResults = [];
threadRoot = null; threadRoot = null;
@@ -400,7 +423,7 @@
<LoginScreen <LoginScreen
onLogin={(s) => { onLogin={(s) => {
currentUser = s; currentUser = s;
view = "home"; setView("home");
}} }}
/> />
</div> </div>
@@ -408,9 +431,7 @@
<div class="shell"> <div class="shell">
<NavRail <NavRail
{view} {view}
on_select={(v) => { on_select={(v) => setView(v)}
view = v;
}}
/> />
<div class="main"> <div class="main">
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}> <Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
@@ -580,7 +601,7 @@
<button <button
class="btn btn--ghost" class="btn btn--ghost"
type="button" type="button"
onclick={() => (view = "home")} onclick={() => setView("home")}
>← back to timeline</button> >← back to timeline</button>
</div> </div>
@@ -720,8 +741,11 @@
"rail status"; "rail status";
min-height: 0; min-height: 0;
} }
.shell > :global(nav.rail) { grid-area: rail; } /* NavRail and StatusBar self-assign their own grid-area
.shell > :global(.statusbar) { grid-area: status; } (`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 { .main {
grid-area: main; grid-area: main;
overflow: auto; overflow: auto;
@@ -62,7 +62,15 @@
</nav> </nav>
<style> <style>
/* Self-assign the grid area so the parent App.svelte doesn't
need a `:global(nav.rail)` selector. Doing it via a parent
child-selector is fragile under Svelte 5's scoping — the
parent's `.shell.s-XXX > nav.rail` doesn't reliably match
`<nav class="rail s-YYY">` in the Tauri webview, which leaves
the buttons invisible to clicks. Owning the grid placement
here avoids the cross-component selector entirely. */
.rail { .rail {
grid-area: rail;
width: 88px; width: 88px;
background: var(--bg); background: var(--bg);
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { import {
fetchPost, fetchPost,
likePost, likePost,
@@ -23,13 +24,24 @@
let quotedErr: string | null = $state(null); let quotedErr: string | null = $state(null);
let quotedLoading: boolean = $state(false); let quotedLoading: boolean = $state(false);
// Resolve the embedded `app.bsky.embed.record` (a quoted post) by
// fetching the full record once per URI. We `untrack()` the
// in-flight check (`quoted` / `quotedLoading`) so a sync read+write
// of the same $state isn't reported as
// `effect_update_depth_exceeded` — without it, every time the
// effect re-fires (e.g. on parent re-render) Svelte 5's depth
// tracker saw `quotedLoading` read **and** flipped to `true`
// within the same tick.
$effect(() => { $effect(() => {
const rec = (post.embed?.$type === "app.bsky.embed.record" || post.embed?.$type === "app.bsky.embed.recordWithMedia") const rec = (post.embed?.$type === "app.bsky.embed.record" || post.embed?.$type === "app.bsky.embed.recordWithMedia")
? post.embed?.record ? post.embed?.record
: null; : null;
if (rec?.uri && !quoted && !quotedLoading) { const targetUri = rec?.uri;
if (!targetUri) return;
untrack(() => {
if (quoted || quotedLoading) return;
quotedLoading = true; quotedLoading = true;
fetchPost(rec.uri) fetchPost(targetUri)
.then((r) => { .then((r) => {
quoted = r.post; quoted = r.post;
}) })
@@ -39,7 +51,7 @@
.finally(() => { .finally(() => {
quotedLoading = false; quotedLoading = false;
}); });
} });
}); });
// Resolve the embed shape once at render time. We sniff $type to // Resolve the embed shape once at render time. We sniff $type to
@@ -99,13 +111,26 @@
$state(null); $state(null);
$effect.pre(() => { $effect.pre(() => {
const k = localStorageKey(`liked:${post.did}:${post.rkey}`); const k = localStorageKey(`liked:${post.did}:${post.rkey}`);
likedBox = useLocalStorage<{ liked: boolean; uri: string | null }>(k, { // Re-create the box whenever the post changes; the hydration
liked: false, // reads (`likedBox.get()`) and the writes that seed `liked` /
uri: null, // `likedUri` from localStorage all happen inside `untrack` so
// `likedBox` (which is $state) is **read and written in the same
// effect run**. Without untrack, Svelte 5's effect tracker would
// schedule `possible_effect_self_invalidation` on `likedBox`
// and the effect would loop until `effect_update_depth_exceeded`
// fires. See the explorer agent's read-out: this is THE loop
// that took down login with `process_fn x 95`. The outer
// `post.did` / `post.rkey` reads remain tracked so the effect
// still re-runs when navigating from one card to the next.
untrack(() => {
likedBox = useLocalStorage<{ liked: boolean; uri: string | null }>(k, {
liked: false,
uri: null,
});
const stored = likedBox.get();
liked = stored.liked;
likedUri = stored.uri;
}); });
const stored = likedBox.get();
liked = stored.liked;
likedUri = stored.uri;
}); });
$effect(() => { $effect(() => {
if (!likedBox) return; if (!likedBox) return;
@@ -53,7 +53,11 @@
</div> </div>
<style> <style>
/* Self-assign the grid area — see NavRail.svelte for why we don't
rely on the parent's `:global(.statusbar)` child selector
(Svelte 5 scoping makes it unreliable in the Tauri webview). */
.statusbar { .statusbar {
grid-area: status;
height: 24px; height: 24px;
background: var(--bg-elev); background: var(--bg-elev);
border-top: 1px solid var(--line); border-top: 1px solid var(--line);