From eb62fd565472b009518da7f8de3b84d71ce80b97 Mon Sep 17 00:00:00 2001 From: tomdebone Date: Sun, 26 Jul 2026 20:49:09 +0200 Subject: [PATCH] fix(postcard): remove sync effects that looped on like/repost click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PostCard.svelte` had two `$effect`s (line 140 + 152) that persisted `liked` / `reposted` state into a `useLocalStorage` box via `box.set(...)`. The pre effect (line 126) created a fresh box on every post-prop change and read its stored value into the local `liked` / `likedUri` $states. The sync effects then noticed the mismatch between the in-memory state and the box's internal `current` and called `set` to reconcile. When the user clicked the heart, `liked` flipped and `likeDelta` incremented. The sync effect re-ran, called `box.set({liked, uri: likedUri})`, which mutated the box's closure `current`. In Svelte 5 the depth tracker flagged the re-entry as `effect_update_depth_exceeded` once the user clicked enough times to exceed the per-tick limit. The error was caught by the Svelte error boundary and shown as a red overlay; the page kept rendering but the like-state path was broken. Fix: * Wrap the pre effect's writes in `untrack(() => ...)` so its reactive dep set is just `[post.did, post.rkey]` — without untrack, every `liked = ...` would re-enter the effect. * Drop both sync effects entirely. localStorage writes now happen directly in the click handler (`likedBox?.set(...)`) and on rollback — no Svelte state is touched by the box's internal updates. Also: * Settings view reworked to X-style: sectioned cards with label-left / value-right rows, clickable action rows with right-side hints ("atproto", "↗ bsky.app"), and a separate red danger zone for sign out. Stays monospace + orange accent + `//` terminal comments. `npm run check` 0 errors. `npm run test` 20/20 passing. The error no longer fires when clicking the heart. --- crates/tauri-app/src/App.svelte | 265 ++++++++++++------ .../src/lib/components/PostCard.svelte | 56 ++-- 2 files changed, 202 insertions(+), 119 deletions(-) diff --git a/crates/tauri-app/src/App.svelte b/crates/tauri-app/src/App.svelte index f14ff17..9d619d3 100644 --- a/crates/tauri-app/src/App.svelte +++ b/crates/tauri-app/src/App.svelte @@ -573,73 +573,92 @@
$ // settings + @{currentUser?.handle ?? "?"}
-

// account

-
-
-
handle
-
@{currentUser?.handle ?? "?"}
+ +
+

// account

+
+
+ handle + @{currentUser?.handle ?? "?"} +
+
+ did + {currentUser?.did ?? "?"} +
+
+ posts cached + {userPosts.length} +
-
-
did
-
{currentUser?.did ?? "?"}
+
+ + +
-
-
posts in cache
-
{userPosts.length}
-
-
- -

// actions

-
- - -
-

// about

-
-
-
app
-
maarcadetweet
+ +
+

// backend

+
+
+ app + maarcadetweet +
+
+ version + 0.1.0 +
+
+ pds + {pdsBase()} +
+
+ appview + {appviewBase()} +
-
-
version
-
0.1.0
-
-
-
backend
-
{pdsBase()}
-
-
-
appview
-
{appviewBase()}
-
-
+ -
- + +
+
+ +
{:else if view === "search"} @@ -946,57 +965,119 @@ border-color: var(--red); } + /* X-style settings page: sectioned cards with label-left / + value-right rows, then a list of clickable action rows, then + a danger zone at the bottom. Stays monospace + terminal- + commented, but the structure is the same as X's. */ + .settings { + padding: 0 var(--s-3) var(--s-6); + display: flex; + flex-direction: column; + gap: var(--s-4); + } + .settings__group { + display: flex; + flex-direction: column; + gap: var(--s-2); + } .settings__h3 { font-family: var(--font-mono); font-size: var(--fs-50); - color: var(--text-dim); - letter-spacing: 0.04em; - margin: var(--s-4) 0 var(--s-2); - font-weight: 400; + color: var(--orange); + letter-spacing: var(--tracking-label); + margin: 0; + font-weight: 700; } - - .did-cell { - word-break: break-all; - font-size: var(--fs-50); - } - - .settings { - padding: 0 var(--s-3); + .settings__list { display: flex; flex-direction: column; - gap: var(--s-2); + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: var(--r-md); + overflow: hidden; } - .settings__rows { + /* Each row is a label-left / value-right flex line, separated + by a hairline (X uses a single border on each row except the + last). */ + .settings__row { display: flex; - flex-direction: column; - gap: var(--s-1); - padding: var(--s-2) var(--s-4); - margin: 0 0 var(--s-4); + align-items: center; + justify-content: space-between; + gap: var(--s-3); + padding: var(--s-3) var(--s-4); + border-bottom: 1px solid var(--line); font-family: var(--font-mono); font-size: var(--fs-50); } - .settings__rows > div { - display: flex; - gap: var(--s-3); + .settings__list .settings__row:last-child { + border-bottom: 0; } - .settings__rows dt { + .settings__label { color: var(--text-dim); - letter-spacing: 0.04em; - min-width: 9rem; + letter-spacing: var(--tracking-label); + flex: 0 0 auto; } - .settings__rows dd { - margin: 0; + .settings__value { color: var(--text); + text-align: right; + word-break: break-all; + min-width: 0; } + .settings__value--mono { + font-size: var(--fs-50); + } + /* Actions live in their own list — same border-radius but each + item is a full-width clickable button. The hint on the right + (e.g. "atproto", "↗ bsky.app") is a dim secondary label, the + same way X shows the destination on follow / open-in-app + rows. */ .settings__actions { display: flex; - flex-wrap: wrap; - gap: var(--s-2); - margin: 0 0 var(--s-4); + flex-direction: column; + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: var(--r-md); + overflow: hidden; } - .settings__signout { - margin-top: var(--s-4); - padding-top: var(--s-4); - border-top: 1px dashed var(--line); + .settings__action { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-3); + padding: var(--s-3) var(--s-4); + background: transparent; + border: 0; + border-bottom: 1px solid var(--line); + color: var(--text); + font-family: var(--font-mono); + font-size: var(--fs-100); + text-align: left; + cursor: pointer; + transition: background-color var(--dur) var(--ease), + color var(--dur) var(--ease); + } + .settings__actions .settings__action:last-child { + border-bottom: 0; + } + .settings__action:hover { + background: var(--orange-8); + color: var(--orange); + } + .settings__action-hint { + color: var(--text-dim); + font-size: var(--fs-50); + } + .settings__action:hover .settings__action-hint { + color: var(--orange); + } + .settings__group--danger .settings__action { + color: var(--red); + } + .settings__group--danger .settings__action:hover { + background: rgba(255, 59, 48, 0.08); + color: var(--red); + } + .settings__group--danger { + margin-top: var(--s-3); } diff --git a/crates/tauri-app/src/lib/components/PostCard.svelte b/crates/tauri-app/src/lib/components/PostCard.svelte index e6da4ab..937f48b 100644 --- a/crates/tauri-app/src/lib/components/PostCard.svelte +++ b/crates/tauri-app/src/lib/components/PostCard.svelte @@ -124,36 +124,27 @@ > | null = $state(null); $effect.pre(() => { + // Restore like/repost state from localStorage on mount and on + // every post-prop change (so navigating between posts restores + // the right per-post state). The writes are inside `untrack` so + // the effect's reactive dep set is just `[post.did, post.rkey]` + // — without untrack, each write to `liked` / `reposted` / etc. + // would re-enter the effect and the depth tracker would abort + // with `effect_update_depth_exceeded` as soon as the user + // clicks the heart or repost button. const likeKey = localStorageKey(`liked:${post.did}:${post.rkey}`); const repostKey = localStorageKey(`reposted:${post.did}:${post.rkey}`); - likedBox = useLocalStorage(likeKey, { liked: false, uri: null }); - const storedLike = likedBox.get(); - liked = storedLike.liked; - likedUri = storedLike.uri; + untrack(() => { + likedBox = useLocalStorage(likeKey, { liked: false, uri: null }); + const storedLike = likedBox.get(); + liked = storedLike.liked; + likedUri = storedLike.uri; - repostedBox = useLocalStorage(repostKey, { reposted: false, uri: null }); - const storedRepost = repostedBox.get(); - reposted = storedRepost.reposted; - repostUri = storedRepost.uri; - }); - - $effect(() => { - // Skip the write when the box's stored value already matches the - // current state — on first mount, `$effect.pre` reads the stored - // value into `liked` / `likedUri`, and without this guard the very - // first reactive pass would re-write the same bytes and trigger - // an empty `notify()` to subscribers. - if (!likedBox) return; - const current = likedBox.get(); - if (current.liked === liked && current.uri === likedUri) return; - likedBox.set({ liked, uri: likedUri }); - }); - - $effect(() => { - if (!repostedBox) return; - const current = repostedBox.get(); - if (current.reposted === reposted && current.uri === repostUri) return; - repostedBox.set({ reposted, uri: repostUri }); + repostedBox = useLocalStorage(repostKey, { reposted: false, uri: null }); + const storedRepost = repostedBox.get(); + reposted = storedRepost.reposted; + repostUri = storedRepost.uri; + }); }); async function onLikeClick(event: MouseEvent) { @@ -168,11 +159,16 @@ const previousDelta = likeDelta; liked = !wasLiked; likeDelta += wasLiked ? -1 : 1; + // Persist the optimistic state — the localStorage write is + // synchronous so a page reload reflects the in-flight like even + // if the server roundtrip is still pending. + likedBox?.set({ liked, uri: likedUri }); try { if (wasLiked) { if (!likedUri) { liked = wasLiked; likeDelta = previousDelta; + likedBox?.set({ liked, uri: likedUri }); showError("can't unlike: missing like URI"); return; } @@ -182,9 +178,11 @@ const response = await likePost(post.uri, post.cid); likedUri = response.uri; } + likedBox?.set({ liked, uri: likedUri }); } catch (error) { liked = wasLiked; likeDelta = previousDelta; + likedBox?.set({ liked, uri: likedUri }); showError(`like failed: ${error}`); } finally { likeBusy = false; @@ -203,11 +201,13 @@ const previousDelta = repostDelta; reposted = !wasReposted; repostDelta += wasReposted ? -1 : 1; + repostedBox?.set({ reposted, uri: repostUri }); try { if (wasReposted) { if (!repostUri) { reposted = wasReposted; repostDelta = previousDelta; + repostedBox?.set({ reposted, uri: repostUri }); showError("can't unrepost: missing repost URI"); return; } @@ -217,9 +217,11 @@ const response = await repostPost(post.uri, post.cid); repostUri = response.uri; } + repostedBox?.set({ reposted, uri: repostUri }); } catch (error) { reposted = wasReposted; repostDelta = previousDelta; + repostedBox?.set({ reposted, uri: repostUri }); showError(`repost failed: ${error}`); } finally { repostBusy = false;