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
+173 -92
View File
@@ -573,73 +573,92 @@
<div class="head">
<span class="prompt">$</span>
<span class="title">// settings</span>
<span class="meta">@{currentUser?.handle ?? "?"}</span>
</div>
<section class="settings">
<h3 class="settings__h3">// account</h3>
<dl class="settings__rows">
<div>
<dt>handle</dt>
<dd>@{currentUser?.handle ?? "?"}</dd>
<!-- Account — X-style rows: label left, value right, full-width clickable -->
<div class="settings__group">
<h3 class="settings__h3">// account</h3>
<div class="settings__list">
<div class="settings__row">
<span class="settings__label">handle</span>
<span class="settings__value">@{currentUser?.handle ?? "?"}</span>
</div>
<div class="settings__row">
<span class="settings__label">did</span>
<code class="settings__value settings__value--mono">{currentUser?.did ?? "?"}</code>
</div>
<div class="settings__row">
<span class="settings__label">posts cached</span>
<span class="settings__value">{userPosts.length}</span>
</div>
</div>
<div>
<dt>did</dt>
<dd class="did-cell">{currentUser?.did ?? "?"}</dd>
<div class="settings__actions">
<button
class="settings__action"
type="button"
onclick={() =>
currentUser && copyToClipboard(currentUser.did)}
>
<span>copy did</span>
<span class="settings__action-hint">atproto</span>
</button>
<button
class="settings__action"
type="button"
onclick={() =>
openExternalUrl(
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
)}
>
<span>open profile in browser</span>
<span class="settings__action-hint">↗ bsky.app</span>
</button>
<button
class="settings__action"
type="button"
onclick={() => setView("home")}
>
<span>← back to timeline</span>
</button>
</div>
<div>
<dt>posts in cache</dt>
<dd>{userPosts.length}</dd>
</div>
</dl>
<h3 class="settings__h3">// actions</h3>
<div class="settings__actions">
<button
class="btn btn--ghost"
type="button"
onclick={() =>
currentUser && copyToClipboard(currentUser.did)}
>copy my did</button>
<button
class="btn btn--ghost"
type="button"
onclick={() =>
openExternalUrl(
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
)}
>open profile in browser</button>
<button
class="btn btn--ghost"
type="button"
onclick={() => setView("home")}
>← back to timeline</button>
</div>
<h3 class="settings__h3">// about</h3>
<dl class="settings__rows">
<div>
<dt>app</dt>
<dd>maarcadetweet</dd>
<!-- Backend / connection info — same row pattern -->
<div class="settings__group">
<h3 class="settings__h3">// backend</h3>
<div class="settings__list">
<div class="settings__row">
<span class="settings__label">app</span>
<span class="settings__value">maarcadetweet</span>
</div>
<div class="settings__row">
<span class="settings__label">version</span>
<span class="settings__value">0.1.0</span>
</div>
<div class="settings__row">
<span class="settings__label">pds</span>
<code class="settings__value settings__value--mono">{pdsBase()}</code>
</div>
<div class="settings__row">
<span class="settings__label">appview</span>
<code class="settings__value settings__value--mono">{appviewBase()}</code>
</div>
</div>
<div>
<dt>version</dt>
<dd>0.1.0</dd>
</div>
<div>
<dt>backend</dt>
<dd>{pdsBase()}</dd>
</div>
<div>
<dt>appview</dt>
<dd>{appviewBase()}</dd>
</div>
</dl>
</div>
<div class="settings__signout">
<button
class="btn btn--ghost btn--danger"
type="button"
onclick={handleLogout}
>sign out</button>
<!-- Sign-out — separate danger zone at the bottom, like X's "Log out" row -->
<div class="settings__group settings__group--danger">
<div class="settings__list">
<button
class="settings__action settings__action--danger"
type="button"
onclick={handleLogout}
>
<span>sign out</span>
<span class="settings__action-hint">→</span>
</button>
</div>
</div>
</section>
{: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);
}
</style>
@@ -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;