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:
@@ -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,6 +111,18 @@
|
|||||||
$state(null);
|
$state(null);
|
||||||
$effect.pre(() => {
|
$effect.pre(() => {
|
||||||
const k = localStorageKey(`liked:${post.did}:${post.rkey}`);
|
const k = localStorageKey(`liked:${post.did}:${post.rkey}`);
|
||||||
|
// Re-create the box whenever the post changes; the hydration
|
||||||
|
// reads (`likedBox.get()`) and the writes that seed `liked` /
|
||||||
|
// `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, {
|
likedBox = useLocalStorage<{ liked: boolean; uri: string | null }>(k, {
|
||||||
liked: false,
|
liked: false,
|
||||||
uri: null,
|
uri: null,
|
||||||
@@ -107,6 +131,7 @@
|
|||||||
liked = stored.liked;
|
liked = stored.liked;
|
||||||
likedUri = stored.uri;
|
likedUri = stored.uri;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!likedBox) return;
|
if (!likedBox) return;
|
||||||
likedBox.set({ liked, uri: likedUri });
|
likedBox.set({ liked, uri: likedUri });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user