|
7 | 7 | import Heart from '@lucide/svelte/icons/heart'; |
8 | 8 | import LoaderCircle from '@lucide/svelte/icons/loader-circle'; |
9 | 9 |
|
10 | | - // Pages are prerendered, so the layout's ipHash is frozen at build time and |
11 | | - // never matches a real visitor — the liked state would always read false. |
12 | | - // Fetch the visitor's real hash at runtime and re-subscribe with it. |
13 | | - let clientIpHash = $state($page.data.ipHash ?? ''); |
| 10 | + // Pages are prerendered, so any ipHash baked in at build time never matches a |
| 11 | + // real visitor. We fetch the visitor's real hash at runtime; until that |
| 12 | + // resolves we must NOT trust the `liked` flag — the query would report |
| 13 | + // `liked: false` for a page the visitor has actually liked, and clicking the |
| 14 | + // button would then silently remove that like (the count drops). |
| 15 | + let clientIpHash = $state(''); |
| 16 | + let hashReal = $state(false); |
| 17 | + let hashAttempted = $state(false); |
14 | 18 |
|
15 | 19 | const query = useQuery( |
16 | 20 | api.likes.get, |
17 | 21 | () => ({ |
18 | 22 | url: $page.url.pathname, |
19 | 23 | ipHash: clientIpHash |
20 | 24 | }), |
21 | | - // The runtime ipHash re-subscription (see onMount) swaps the query args |
22 | | - // on every visit. Keep the prior result so the count doesn't flash back |
23 | | - // to the loading skeleton each load. |
| 25 | + // Keep the prior result so the count doesn't flash to the skeleton when |
| 26 | + // the ipHash re-subscription fires. `isStale` (below) tells us when the |
| 27 | + // retained data belongs to different args so we never treat data from a |
| 28 | + // previous page — or the pre-resolution hash — as authoritative. |
24 | 29 | { keepPreviousData: true } |
25 | 30 | ); |
26 | 31 |
|
|
29 | 34 | const res = await fetch('/api/ip-hash'); |
30 | 35 | if (res.ok) { |
31 | 36 | const data = await res.json(); |
32 | | - if (data.ipHash) clientIpHash = data.ipHash; |
| 37 | + if (data.ipHash) { |
| 38 | + clientIpHash = data.ipHash; |
| 39 | + hashReal = true; |
| 40 | + } |
33 | 41 | } |
34 | 42 | } catch { |
35 | | - // keep the fallback hash |
| 43 | + // keep the empty hash; liked state stays unknown |
| 44 | + } finally { |
| 45 | + hashAttempted = true; |
36 | 46 | } |
37 | 47 | }); |
38 | 48 |
|
|
41 | 51 | let likeErrorTimer = null; |
42 | 52 | let particles = $state([]); |
43 | 53 | let particleId = 0; |
44 | | - let buttonEl; |
45 | 54 |
|
46 | | - const likeCount = $derived(query.data?.count ?? 0); |
47 | | - const isLiked = $derived(query.data?.liked ?? false); |
48 | | - const loading = $derived(query.isLoading); |
| 55 | + // Pathname of the most recent fresh (non-stale) result, so we can tell |
| 56 | + // whether retained stale data still applies to the current page (an ipHash |
| 57 | + // swap on the same URL) or belongs to a post we navigated away from. |
| 58 | + let dataUrl = $state(null); |
| 59 | + $effect(() => { |
| 60 | + if (query.data && !query.isStale) dataUrl = $page.url.pathname; |
| 61 | + }); |
| 62 | +
|
| 63 | + const path = $derived($page.url.pathname); |
| 64 | + // The count is independent of the visitor's hash, so retained same-URL data |
| 65 | + // is still correct; only data carried over from another URL must be hidden. |
| 66 | + const countApplies = $derived(!query.isStale || dataUrl === path); |
| 67 | + // `liked` is per-visitor: trust it only once the real hash is in use and the |
| 68 | + // subscription holds fresh data for the current page. |
| 69 | + const likedReady = $derived(hashReal && !query.isStale && !!query.data && dataUrl === path); |
| 70 | +
|
| 71 | + const likeCount = $derived(countApplies ? (query.data?.count ?? 0) : 0); |
| 72 | + const isLiked = $derived(likedReady ? query.data.liked : false); |
| 73 | + const showCount = $derived(countApplies && !!query.data); |
| 74 | + // Allow clicks once liked is trustworthy, or once we've attempted the hash |
| 75 | + // fetch and it failed (so a broken /api/ip-hash doesn't disable the button |
| 76 | + // forever — the toggle itself is computed from the real IP server-side). |
| 77 | + const interactive = $derived( |
| 78 | + countApplies && |
| 79 | + (likedReady || (hashAttempted && !hashReal && !query.isStale && !!query.data)) |
| 80 | + ); |
| 81 | + const busy = $derived(!interactive || toggling); |
49 | 82 |
|
50 | 83 | function showLikeError(message) { |
51 | 84 | likeError = message; |
|
101 | 134 | </script> |
102 | 135 |
|
103 | 136 | <div class="flex items-center justify-between"> |
104 | | - {#if loading} |
| 137 | + {#if !showCount} |
105 | 138 | <span class="shimmer mr-2 inline-block h-6 w-16 rounded"></span> |
106 | 139 | {:else} |
107 | 140 | <span |
|
125 | 158 | {/each} |
126 | 159 |
|
127 | 160 | <button |
128 | | - bind:this={buttonEl} |
129 | 161 | onclick={toggleLike} |
130 | | - disabled={loading || toggling} |
| 162 | + disabled={busy} |
131 | 163 | aria-pressed={isLiked} |
132 | 164 | class="inline-flex items-center justify-center gap-1.5 rounded-full border px-4 py-2 text-sm font-medium transition-[background-color,border-color,color,transform] duration-200 ease-out active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-60 |
133 | 165 | {isLiked |
134 | 166 | ? 'border-rose-300/70 bg-rose-50 text-rose-600 hover:bg-rose-100 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300 dark:hover:bg-rose-950/60' |
135 | 167 | : 'border-neutral-200 bg-transparent text-neutral-700 hover:border-rose-200 hover:bg-rose-50 hover:text-rose-600 dark:border-neutral-800 dark:text-neutral-300 dark:hover:border-rose-900/50 dark:hover:bg-rose-950/30 dark:hover:text-rose-300'}" |
136 | 168 | > |
137 | | - {#if toggling} |
| 169 | + {#if busy} |
138 | 170 | <LoaderCircle size="14" strokeWidth="2" class="animate-spin" aria-hidden="true" /> |
139 | 171 | {:else} |
140 | 172 | <Heart |
|
0 commit comments