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:
+173
-92
@@ -573,73 +573,92 @@
|
|||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="prompt">$</span>
|
<span class="prompt">$</span>
|
||||||
<span class="title">// settings</span>
|
<span class="title">// settings</span>
|
||||||
|
<span class="meta">@{currentUser?.handle ?? "?"}</span>
|
||||||
</div>
|
</div>
|
||||||
<section class="settings">
|
<section class="settings">
|
||||||
<h3 class="settings__h3">// account</h3>
|
<!-- Account — X-style rows: label left, value right, full-width clickable -->
|
||||||
<dl class="settings__rows">
|
<div class="settings__group">
|
||||||
<div>
|
<h3 class="settings__h3">// account</h3>
|
||||||
<dt>handle</dt>
|
<div class="settings__list">
|
||||||
<dd>@{currentUser?.handle ?? "?"}</dd>
|
<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>
|
||||||
<div>
|
<div class="settings__actions">
|
||||||
<dt>did</dt>
|
<button
|
||||||
<dd class="did-cell">{currentUser?.did ?? "?"}</dd>
|
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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<h3 class="settings__h3">// about</h3>
|
<!-- Backend / connection info — same row pattern -->
|
||||||
<dl class="settings__rows">
|
<div class="settings__group">
|
||||||
<div>
|
<h3 class="settings__h3">// backend</h3>
|
||||||
<dt>app</dt>
|
<div class="settings__list">
|
||||||
<dd>maarcadetweet</dd>
|
<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>
|
||||||
<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 class="settings__signout">
|
<!-- Sign-out — separate danger zone at the bottom, like X's "Log out" row -->
|
||||||
<button
|
<div class="settings__group settings__group--danger">
|
||||||
class="btn btn--ghost btn--danger"
|
<div class="settings__list">
|
||||||
type="button"
|
<button
|
||||||
onclick={handleLogout}
|
class="settings__action settings__action--danger"
|
||||||
>sign out</button>
|
type="button"
|
||||||
|
onclick={handleLogout}
|
||||||
|
>
|
||||||
|
<span>sign out</span>
|
||||||
|
<span class="settings__action-hint">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if view === "search"}
|
{:else if view === "search"}
|
||||||
@@ -946,57 +965,119 @@
|
|||||||
border-color: var(--red);
|
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 {
|
.settings__h3 {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-50);
|
font-size: var(--fs-50);
|
||||||
color: var(--text-dim);
|
color: var(--orange);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: var(--tracking-label);
|
||||||
margin: var(--s-4) 0 var(--s-2);
|
margin: 0;
|
||||||
font-weight: 400;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
.settings__list {
|
||||||
.did-cell {
|
|
||||||
word-break: break-all;
|
|
||||||
font-size: var(--fs-50);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings {
|
|
||||||
padding: 0 var(--s-3);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: var(--s-1);
|
justify-content: space-between;
|
||||||
padding: var(--s-2) var(--s-4);
|
gap: var(--s-3);
|
||||||
margin: 0 0 var(--s-4);
|
padding: var(--s-3) var(--s-4);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-50);
|
font-size: var(--fs-50);
|
||||||
}
|
}
|
||||||
.settings__rows > div {
|
.settings__list .settings__row:last-child {
|
||||||
display: flex;
|
border-bottom: 0;
|
||||||
gap: var(--s-3);
|
|
||||||
}
|
}
|
||||||
.settings__rows dt {
|
.settings__label {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: var(--tracking-label);
|
||||||
min-width: 9rem;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.settings__rows dd {
|
.settings__value {
|
||||||
margin: 0;
|
|
||||||
color: var(--text);
|
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 {
|
.settings__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: var(--s-2);
|
background: var(--bg-elev);
|
||||||
margin: 0 0 var(--s-4);
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.settings__signout {
|
.settings__action {
|
||||||
margin-top: var(--s-4);
|
display: flex;
|
||||||
padding-top: var(--s-4);
|
align-items: center;
|
||||||
border-top: 1px dashed var(--line);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -124,36 +124,27 @@
|
|||||||
> | null = $state(null);
|
> | null = $state(null);
|
||||||
|
|
||||||
$effect.pre(() => {
|
$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 likeKey = localStorageKey(`liked:${post.did}:${post.rkey}`);
|
||||||
const repostKey = localStorageKey(`reposted:${post.did}:${post.rkey}`);
|
const repostKey = localStorageKey(`reposted:${post.did}:${post.rkey}`);
|
||||||
likedBox = useLocalStorage(likeKey, { liked: false, uri: null });
|
untrack(() => {
|
||||||
const storedLike = likedBox.get();
|
likedBox = useLocalStorage(likeKey, { liked: false, uri: null });
|
||||||
liked = storedLike.liked;
|
const storedLike = likedBox.get();
|
||||||
likedUri = storedLike.uri;
|
liked = storedLike.liked;
|
||||||
|
likedUri = storedLike.uri;
|
||||||
|
|
||||||
repostedBox = useLocalStorage(repostKey, { reposted: false, uri: null });
|
repostedBox = useLocalStorage(repostKey, { reposted: false, uri: null });
|
||||||
const storedRepost = repostedBox.get();
|
const storedRepost = repostedBox.get();
|
||||||
reposted = storedRepost.reposted;
|
reposted = storedRepost.reposted;
|
||||||
repostUri = storedRepost.uri;
|
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onLikeClick(event: MouseEvent) {
|
async function onLikeClick(event: MouseEvent) {
|
||||||
@@ -168,11 +159,16 @@
|
|||||||
const previousDelta = likeDelta;
|
const previousDelta = likeDelta;
|
||||||
liked = !wasLiked;
|
liked = !wasLiked;
|
||||||
likeDelta += wasLiked ? -1 : 1;
|
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 {
|
try {
|
||||||
if (wasLiked) {
|
if (wasLiked) {
|
||||||
if (!likedUri) {
|
if (!likedUri) {
|
||||||
liked = wasLiked;
|
liked = wasLiked;
|
||||||
likeDelta = previousDelta;
|
likeDelta = previousDelta;
|
||||||
|
likedBox?.set({ liked, uri: likedUri });
|
||||||
showError("can't unlike: missing like URI");
|
showError("can't unlike: missing like URI");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -182,9 +178,11 @@
|
|||||||
const response = await likePost(post.uri, post.cid);
|
const response = await likePost(post.uri, post.cid);
|
||||||
likedUri = response.uri;
|
likedUri = response.uri;
|
||||||
}
|
}
|
||||||
|
likedBox?.set({ liked, uri: likedUri });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
liked = wasLiked;
|
liked = wasLiked;
|
||||||
likeDelta = previousDelta;
|
likeDelta = previousDelta;
|
||||||
|
likedBox?.set({ liked, uri: likedUri });
|
||||||
showError(`like failed: ${error}`);
|
showError(`like failed: ${error}`);
|
||||||
} finally {
|
} finally {
|
||||||
likeBusy = false;
|
likeBusy = false;
|
||||||
@@ -203,11 +201,13 @@
|
|||||||
const previousDelta = repostDelta;
|
const previousDelta = repostDelta;
|
||||||
reposted = !wasReposted;
|
reposted = !wasReposted;
|
||||||
repostDelta += wasReposted ? -1 : 1;
|
repostDelta += wasReposted ? -1 : 1;
|
||||||
|
repostedBox?.set({ reposted, uri: repostUri });
|
||||||
try {
|
try {
|
||||||
if (wasReposted) {
|
if (wasReposted) {
|
||||||
if (!repostUri) {
|
if (!repostUri) {
|
||||||
reposted = wasReposted;
|
reposted = wasReposted;
|
||||||
repostDelta = previousDelta;
|
repostDelta = previousDelta;
|
||||||
|
repostedBox?.set({ reposted, uri: repostUri });
|
||||||
showError("can't unrepost: missing repost URI");
|
showError("can't unrepost: missing repost URI");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -217,9 +217,11 @@
|
|||||||
const response = await repostPost(post.uri, post.cid);
|
const response = await repostPost(post.uri, post.cid);
|
||||||
repostUri = response.uri;
|
repostUri = response.uri;
|
||||||
}
|
}
|
||||||
|
repostedBox?.set({ reposted, uri: repostUri });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reposted = wasReposted;
|
reposted = wasReposted;
|
||||||
repostDelta = previousDelta;
|
repostDelta = previousDelta;
|
||||||
|
repostedBox?.set({ reposted, uri: repostUri });
|
||||||
showError(`repost failed: ${error}`);
|
showError(`repost failed: ${error}`);
|
||||||
} finally {
|
} finally {
|
||||||
repostBusy = false;
|
repostBusy = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user