Skip to content

fix: TRAC-284 Coalesce rapid cart quantity/delete clicks#3103

Merged
chanceaclark merged 1 commit into
canaryfrom
chancellorclark/ltrac-284-cart-page-breaks-when-clicking-around-during-a-quantity
Jul 14, 2026
Merged

fix: TRAC-284 Coalesce rapid cart quantity/delete clicks#3103
chanceaclark merged 1 commit into
canaryfrom
chancellorclark/ltrac-284-cart-page-breaks-when-clicking-around-during-a-quantity

Conversation

@chanceaclark

@chanceaclark chanceaclark commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

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-quantity update request (replacing the old per-click increment/decrement intents, 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: contents micro-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, onSubmit intercepts and routes through the dispatcher instead. Quantity is clamped server-side only (z.coerce.number().int().min(1)) — no client-side quantity clamp, since the disabled attribute already prevents decrementing below 1 in every reachable path.

Why the cart section is now keyed by entityId only (previously entityId-version): keying on version remounted 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.assign that never resolved by design; a cancelled redirect left retry clicks queued behind that dead action forever. It now settles after 8s or on pageshow (bfcache restore), re-enabling the button as a retry.

Cart revalidation is also unified on revalidateTag(TAGS.cart) (previously a mix of that and revalidatePath('/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:

  • 10 rapid quantity-increase clicks coalesce into a single POST; final quantity and totals are correct.
  • Reload mid-burst and after settling shows consistent server-truth quantities (the "phantom items" symptom from the original report).
  • Clicking a nav link while a mutation is in flight completes navigation (confirm-dialog interceptors are still in place as a UX guard, kept intentionally).
  • Rapid delete across multiple rows removes all rows correctly, no resurrected items.
  • A net-zero burst (e.g. +3 then -3 within the debounce window) sends zero requests.
  • No-JS fallback: with the app's JS bundle blocked (so hydration never completes, while leaving the browser's JS engine on, since fully disabling JS also breaks this page's pre-existing, unrelated <Stream>/Suspense fallback swap), quantity +/- and delete all work as real page round-trips against the server action.
  • Checkout button recovers (re-enables, and a retry succeeds) after its navigation is cancelled.

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.

@changeset-bot

changeset-bot Bot commented Jul 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0db0a90

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@bigcommerce/catalyst-core Patch

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

@vercel

vercel Bot commented Jul 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
catalyst Ready Ready Preview, Comment Jul 14, 2026 2:57pm

Request Review

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Report

Comparing against baseline from bf58b13 (2026-07-14).

Metric Baseline Current Delta
Total JS 446.1 kB 447.8 kB +1.7 kB (+0.4%)

Per-Route First Load JS

Route Baseline Current Delta
/(default)/(auth)/change-password/page 331.7 kB 332.4 kB +0.7 kB (+0.2%)
/(default)/(auth)/login/forgot-password/page 330.9 kB 331.6 kB +0.7 kB (+0.2%)
/(default)/(auth)/login/page 331.3 kB 332 kB +0.7 kB (+0.2%)
/(default)/(auth)/register/page 367.5 kB 368.2 kB +0.7 kB (+0.2%)
/(default)/(faceted)/brand/[slug]/page 343.4 kB 344.1 kB +0.7 kB (+0.2%)
/(default)/(faceted)/category/[slug]/page 351.9 kB 352.6 kB +0.7 kB (+0.2%)
/(default)/(faceted)/search/page 343.4 kB 344.1 kB +0.7 kB (+0.2%)
/(default)/[...rest]/page 326.4 kB 327.1 kB +0.7 kB (+0.2%)
/(default)/account/addresses/page 370.9 kB 371.7 kB +0.8 kB (+0.2%)
/(default)/account/orders/[id]/page 334.5 kB 335.2 kB +0.7 kB (+0.2%)
/(default)/account/orders/page 335.5 kB 336.2 kB +0.7 kB (+0.2%)
/(default)/account/settings/page 372.4 kB 373.2 kB +0.8 kB (+0.2%)
/(default)/account/wishlists/[id]/page 349.3 kB 350 kB +0.7 kB (+0.2%)
/(default)/account/wishlists/page 344.4 kB 345.1 kB +0.7 kB (+0.2%)
/(default)/blog/[blogId]/page 326.4 kB 327.1 kB +0.7 kB (+0.2%)
/(default)/blog/page 327.4 kB 328.1 kB +0.7 kB (+0.2%)
/(default)/cart/page 347.2 kB 348.9 kB +1.7 kB (+0.5%)
/(default)/compare/page 338.6 kB 339.3 kB +0.7 kB (+0.2%)
/(default)/gift-certificates/balance/page 330.4 kB 331.1 kB +0.7 kB (+0.2%)
/(default)/gift-certificates/page 326.4 kB 327.1 kB +0.7 kB (+0.2%)
/(default)/gift-certificates/purchase/page 370 kB 370.7 kB +0.7 kB (+0.2%)
/(default)/page 343.6 kB 344.3 kB +0.7 kB (+0.2%)
/(default)/product/[slug]/page 399.5 kB 400.2 kB +0.7 kB (+0.2%)
/(default)/webpages/[id]/contact/page 368.4 kB 369.1 kB +0.7 kB (+0.2%)
/(default)/webpages/[id]/normal/page 334.5 kB 335.2 kB +0.7 kB (+0.2%)
/(default)/wishlist/[token]/page 339.2 kB 340 kB +0.8 kB (+0.2%)

Threshold: 5% increase. Routes with ⚠️ exceed the threshold.

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Unlighthouse Performance Comparison — Vercel

Comparing PR preview deployment Unlighthouse scores vs production Unlighthouse scores.

Summary Score

Aggregate score across all categories as reported by Unlighthouse.

Prod Desktop Prod Mobile Preview Desktop Preview Mobile
Score 90 93 92 95

Category Scores

Category Prod Desktop Prod Mobile Preview Desktop Preview Mobile
Performance 77 79 77 87
Accessibility 95 92 95 92
Best Practices 100 100 100 100
SEO 88 100 100 100

Core Web Vitals

Metric Prod Desktop Prod Mobile Preview Desktop Preview Mobile
LCP 3.7 s 5.5 s 3.5 s 4.1 s
CLS 0.001 0 0.001 0
FCP 1.2 s 1.2 s 1.2 s 1.1 s
TBT 0 ms 0 ms 0 ms 0 ms
Max Potential FID 40 ms 40 ms 50 ms 50 ms
Time to Interactive 3.7 s 5.5 s 3.6 s 4.1 s

Full Unlighthouse report →

@chanceaclark chanceaclark force-pushed the chancellorclark/ltrac-284-cart-page-breaks-when-clicking-around-during-a-quantity branch from f31f644 to 9e9e048 Compare July 13, 2026 23:02
@chanceaclark

Copy link
Copy Markdown
Contributor Author

Proof

Screen.Recording.2026-07-13.at.15.06.16.mov

@chanceaclark chanceaclark marked this pull request as ready for review July 13, 2026 23:04
@chanceaclark chanceaclark requested a review from a team as a code owner July 13, 2026 23:04
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>
@chanceaclark chanceaclark force-pushed the chancellorclark/ltrac-284-cart-page-breaks-when-clicking-around-during-a-quantity branch from 9e9e048 to 0db0a90 Compare July 14, 2026 14:56
@chanceaclark chanceaclark added this pull request to the merge queue Jul 14, 2026
Merged via the queue into canary with commit d09f8dc Jul 14, 2026
25 of 26 checks passed
@chanceaclark chanceaclark deleted the chancellorclark/ltrac-284-cart-page-breaks-when-clicking-around-during-a-quantity branch July 14, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants