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>