fix: TRAC-284 Coalesce rapid cart quantity/delete clicks#3103
Merged
chanceaclark merged 1 commit intoJul 14, 2026
Conversation
🦋 Changeset detectedLatest commit: 0db0a90 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
Bundle Size ReportComparing against baseline from
Per-Route First Load JS
|
Contributor
Unlighthouse Performance Comparison — VercelComparing PR preview deployment Unlighthouse scores vs production Unlighthouse scores. Summary ScoreAggregate score across all categories as reported by Unlighthouse.
Category Scores
Core Web Vitals
|
f31f644 to
9e9e048
Compare
Contributor
Author
ProofScreen.Recording.2026-07-13.at.15.06.16.mov |
jairo-bc
approved these changes
Jul 14, 2026
Server actions run strictly serially, so rapid clicks on cart quantity +/- or delete buttons queued unboundedly, and navigating away while the queue drained could freeze the page until a hard refresh. Quantity clicks now coalesce into a single absolute-quantity update per line item via a debounced, single-flight dispatcher; deletes are serialized the same way. Each control is still a real progressive-enhancement form (posting straight to the server action) so it keeps working before hydration or if the app bundle fails to load; JS intercepts the submit to route through the dispatcher instead. The cart section is now keyed by cart entityId only (previously entityId-version), since remounting on every mutation could swallow clicks landing during the DOM swap and drop queued actions, leaving the UI stuck pending until a hard refresh. Cart revalidation is unified on revalidateTag (previously a mix of that and revalidatePath). Also fixes the checkout button spinning forever if its navigation is cancelled (Esc, "Stay" on the leave-page prompt, a flaky connection): the URL-string checkout action awaited a promise that never resolved, so a cancelled redirect left retry clicks queued behind a dead action. It now settles after a short timeout or on bfcache restore. Fixes TRAC-284 Co-Authored-By: Claude <noreply@anthropic.com>
9e9e048 to
0db0a90
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Jira: TRAC-284
What/Why?
Server actions run strictly serially with no abort/parallelism, so rapid clicks on the cart's quantity +/- or delete buttons queued up unboundedly. Combined with optimistic UI, users could click far ahead of the server, and navigating away while the queue drained had historically frozen client-side routing until a hard refresh. See Linear LTRAC-284 for the full prior investigation and options considered.
The fix: a per-line-item pending-intent map + debounced, single-flight dispatcher in
CartClient. Rapid clicks coalesce into one absolute-quantityupdaterequest (replacing the old per-clickincrement/decrementintents, which also collapsed ~150 duplicated lines in the server action into one branch). Deletes flush immediately and are serialized the same way. At most one action is ever in flight, so a failed request only rolls back one gesture instead of an entire queue.Progressive enhancement: each +/-/delete control is its own
display: contentsmicro-form carrying a real, pre-computed absolute quantity (or delete) straight to the server action. Without JS (or before hydration), clicking still works as a normal page round-trip. With JS,onSubmitintercepts and routes through the dispatcher instead. Quantity is clamped server-side only (z.coerce.number().int().min(1)) — no client-side quantity clamp, since thedisabledattribute already prevents decrementing below 1 in every reachable path.Why the cart section is now keyed by
entityIdonly (previouslyentityId-version): keying onversionremounted the whole section on every mutation. React drops actions dispatched into a hook that unmounts before they run, which could deadlock the old design, and clicks landing mid-DOM-swap hit handler-less nodes — this was the actual mechanism behind the "stuck forever" freeze a merchant reported on video. Dispatcher state now lives at the component instance level (not module scope), so if anything ever does remount mid-flight, the failure mode is a harmless snap-back to server truth, not a deadlock.Also fixed: the checkout button spinning forever if its navigation was cancelled (pressing Esc, choosing "Stay" on the leave-page confirm, a dropped connection). The URL-string checkout action awaited a promise around
window.location.assignthat never resolved by design; a cancelled redirect left retry clicks queued behind that dead action forever. It now settles after 8s or onpageshow(bfcache restore), re-enabling the button as a retry.Cart revalidation is also unified on
revalidateTag(TAGS.cart)(previously a mix of that andrevalidatePath('/cart')).This branch went through a full review pass (code-review at high effort with two background verifier agents on timing edge cases, simplify, security-review) before this PR was opened; all findings were resolved and re-verified.
Rollout/Rollback
Standard PR merge to
canary; no feature flag, migration, or experiment involved. Rollback is a straight revert — no schema or data changes.Testing
Verified end-to-end against a live sandbox store with Playwright, covering:
<Stream>/Suspense fallback swap), quantity +/- and delete all work as real page round-trips against the server action.Manual repro steps from the original ticket: add 10+ variants to cart, rapidly click delete/quantity buttons, then click a nav link — no freeze, no hard refresh required.