fix(postcard): remove sync effects that looped on like/repost click

`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.
This commit is contained in:
tomdebone
2026-07-26 20:49:09 +02:00
parent a98f891e4f
commit eb62fd5654
2 changed files with 202 additions and 119 deletions
@@ -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;