From 30b83213a8d0077c14e3962ead2762ed83ffca3e Mon Sep 17 00:00:00 2001 From: Giamir Buoncristiani Date: Wed, 28 Jan 2026 16:45:04 +0100 Subject: [PATCH 1/5] feat(vote): extract the vote state logic in an opt in svelte module --- .../src/components/Vote/Vote.stories.svelte | 49 +- .../src/components/Vote/Vote.svelte | 61 +- .../src/components/Vote/Vote.test.ts | 86 +-- .../components/Vote/vote-state.svelte.test.ts | 523 ++++++++++++++++++ .../src/components/Vote/vote-state.svelte.ts | 235 ++++++++ 5 files changed, 866 insertions(+), 88 deletions(-) create mode 100644 packages/stacks-svelte/src/components/Vote/vote-state.svelte.test.ts create mode 100644 packages/stacks-svelte/src/components/Vote/vote-state.svelte.ts diff --git a/packages/stacks-svelte/src/components/Vote/Vote.stories.svelte b/packages/stacks-svelte/src/components/Vote/Vote.stories.svelte index f8e4d3aa03..f83720c8d4 100644 --- a/packages/stacks-svelte/src/components/Vote/Vote.stories.svelte +++ b/packages/stacks-svelte/src/components/Vote/Vote.stories.svelte @@ -1,6 +1,7 @@ - {#snippet template(args)} - + {#snippet template({ total, ...args })} + {/snippet} @@ -70,3 +71,47 @@ {/snippet} + + + {@const voteState = createVoteState({ + total: 42, + upvotes: 50, + downvotes: 8, + onUpvote: async () => { + // Simulate server delay + await new Promise((resolve) => setTimeout(resolve, 300)); + console.log("Upvote sent to server"); + }, + onDownvote: async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + console.log("Downvote sent to server"); + }, + })} +
+ +

+ Click the vote buttons - state is managed automatically with + optimistic updates +

+
+
+ + + {@const voteState = createVoteState({ + total: 10, + onUpvote: async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + throw new Error("Server error - vote rolled back"); + }, + onDownvote: async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + throw new Error("Server error - vote rolled back"); + }, + })} +
+ +

+ Votes will rollback after 500ms (simulated server error) +

+
+
diff --git a/packages/stacks-svelte/src/components/Vote/Vote.svelte b/packages/stacks-svelte/src/components/Vote/Vote.svelte index de3ee00033..0c7febd723 100644 --- a/packages/stacks-svelte/src/components/Vote/Vote.svelte +++ b/packages/stacks-svelte/src/components/Vote/Vote.svelte @@ -79,15 +79,13 @@ i18nExpanded?: string | undefined; /** * Callback fired when the upvote button is clicked. - * Should return a promise that resolves on success. */ - onupvote?: () => Promise; + onupvoteclick?: () => void; /** * Callback fired when the downvote button is clicked. - * Should return a promise that resolves on success. */ - ondownvote?: () => Promise; + ondownvoteclick?: () => void; /** * Additional CSS classes added to the element @@ -97,8 +95,8 @@ const { total, - upvotes = undefined, - downvotes = undefined, + upvotes, + downvotes, horizontal, upvoteOnly = false, status = null, @@ -109,8 +107,8 @@ i18nDownvoted = "Downvoted", i18nExpand = "Show vote details", i18nExpanded = "Hide vote details", - onupvote = () => Promise.resolve(), - ondownvote = () => Promise.resolve(), + onupvoteclick = () => {}, + ondownvoteclick = () => {}, class: className = "", }: Props = $props(); @@ -140,39 +138,7 @@ let expandable = $derived( upvotes !== undefined && downvotes !== undefined && !horizontal ); - // svelte-ignore state_referenced_locally - // Props are intentionally captured as initial values. The component uses optimistic updates - // for vote actions, so local state may temporarily diverge from props during async operations. - let currentCount = $state(total || 0); - // svelte-ignore state_referenced_locally - let currentStatus = $state(status); const classes = $derived(getClasses(className, expanded, horizontal)); - - async function handleVote( - type: "upvoted" | "downvoted", - callback: () => Promise - ) { - const previousCount = currentCount; - const previousStatus = currentStatus; - const increment = type === "upvoted" ? 1 : -1; - - if (currentStatus === type) { - currentStatus = null; - currentCount -= increment; - } else { - currentCount += currentStatus ? increment * 2 : increment; - currentStatus = type; - } - - try { - await callback(); - } catch { - // Revert on failure - currentCount = previousCount; - currentStatus = previousStatus; - // Don't re-throw - the error should be handled by the callback if needed - } - } {#snippet votes()} @@ -186,9 +152,7 @@ +{formatCount(upvotes, 1000)} {/if} - {currentCount !== 0 || currentStatus !== null - ? formatCount(currentCount, 1000) - : i18nVote} + {total !== 0 ? formatCount(total, 1000) : i18nVote} {#if downvotes !== undefined} - {#if !upvoteOnly} {@render votes()} -