Skip to content

Fix cart mutation races with transaction projections - #3906

Merged
fredericoo merged 11 commits into
previewfrom
port-fb-transactions-model
Aug 6, 2026
Merged

fredericoo merged 11 commits into
previewfrom
port-fb-transactions-model

Conversation

@fredericoo

Copy link
Copy Markdown
Contributor

TL;DR: Replace mutable optimistic baselines with keyed transaction projections, and coalesce ambiguous overlapping mutations into one authoritative cart refresh.

Before

Each cart domain managed separate controllers and rollback baselines. Concurrent responses could arrive out of order, allowing a stale snapshot to resurrect removed lines or overwrite unrelated optimistic work.

Treating the last-started response as the latest cart was also insufficient. Cart responses expose lines(first: 250), so the loaded line connection can be partial. Local arithmetic over visible lines cannot recover authoritative totalQuantity for hidden lines, and currency amounts cannot be calculated safely on the client.

After

Visible cart state is derived from settled data, pending transactions, and keyed error projections. Line transactions reconcile only their owned lines and preserve quantities outside the loaded connection.

When mutations overlap, their cart-wide snapshots are ambiguous. The store keeps the scoped projections, marks state.revalidating, and performs one authoritative cart refresh after the burst settles. This refresh supplies the complete quantity and server-derived cost truth without issuing one fetch per mutation.

What this changes

  • Replaces cart mutation internals with a typed transaction registry.
  • Prevents out-of-order remove responses from resurrecting lines or leaving totalQuantity stale.
  • Coalesces overlapping mutation recovery into one cancellable, trailing cart refresh.
  • Waits for returned mutation transports, not only Standard Event promises, before refreshing.
  • Correlates delayed internal events with per-dispatch UUIDs so identical external events remain independent.
  • Rejects stale refreshes by cart identity, store generation, and mutation revision.
  • Preserves omitted custom fields, line metadata, and enriched merchandise when applying authoritative data.
  • Serialises only the first identity-establishing add transport, then releases queued adds with the created cartId.
  • Surfaces refresh failures through keyed network errors that clear on the next refresh.
  • Keeps totals and analytics unconfirmed while state.revalidating is true.
  • Keeps cart IDs out of endpoint URLs and marks cart GET responses private and non-cacheable across browsers, CDNs, and surrogate caches.

Developer impact

Includes a patch changeset for @shopify/hydrogen. CartState gains an additive optional revalidating flag. There are no new exports or required migrations; consumers can use the flag to distinguish cart-wide authoritative reconciliation from entity-level pending work.

The packaged cart and analytics skills document the new settlement signal. Reference examples use it for totals styling, analytics suppression, live status announcements, and refresh errors. Cart drawer guidance opens optimistic state before submission when the canonical trigger is an anchor.

Bundle impact

Measured from the same source entry with tsdown before and after this branch:

Measurement Before After Delta
Published cart.mjs 38.67 kB 41.99 kB +3.32 kB (+8.6%)
Published cart.mjs gzip 8.86 kB 9.75 kB +0.89 kB (+10.0%)
Isolated minified cart 19.61 kB 22.15 kB +2.54 kB (+13.0%)
Isolated minified cart gzip 6.08 kB 7.29 kB +1.21 kB (+19.9%)

The isolated comparison used pnpm exec tsdown src/core/cart/cart.ts --no-config --format esm --minify --platform browser.

UX impact

  • Rapid cart interactions remain optimistic and controls stay enabled.
  • Stale totals remain visibly marked as updating until authoritative reconciliation completes.
  • Screen readers receive persistent update, completion, and failure announcements, including when the cart becomes empty.
  • Checkout controls retain full contrast while totals are unconfirmed.

Risk

  • Each overlapping mutation burst adds one authoritative cart GET. Revalidation is single-flight, abortable, and coalesced; a mutation during refresh schedules one trailing fetch.
  • First-cart adds queue transport until identity exists. Their optimistic projections still appear immediately.
  • Event correlation depends on Standard Actions preserving custom event detail. Both synchronous and asynchronous event paths have regression coverage.
  • Relative add requests remain independent; failed additions roll back only their own projected quantity.
  • The isolated minified cart gzip size increases by 1.21 kB due to transaction and ownership bookkeeping.

How to Test

  1. Run pnpm install && pnpm --filter @shopify/hydrogen build.
  2. Run pnpm dev:rr and open the local React Router storefront.
  3. Add multiple products, then remove several lines rapidly.
  4. Confirm every line disappears immediately, no line returns, and the final count and costs match the server cart.
  5. Rapidly increase and decrease different lines; confirm totals remain visibly updating until reconciliation completes.
  6. Starting from an empty cart, add the same product several times quickly. Confirm one cart is created and the final quantity includes every click.
  7. Simulate a failed cart refresh and confirm the local cart remains visible with an accessible network error.

@fredericoo fredericoo self-assigned this Aug 3, 2026
@fredericoo
fredericoo marked this pull request as ready for review August 3, 2026 17:14
@fredericoo
fredericoo requested a review from a team as a code owner August 3, 2026 17:14

@frandiox frandiox left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved again with extra review, left a few comments. Thanks!

Comment thread packages/hydrogen/src/core/cart/cart.ts
Comment thread packages/hydrogen/src/core/cart/cart.ts Outdated
Comment thread packages/hydrogen/src/core/cart/cart.ts
Model optimistic cart state as settled server state plus keyed transaction and error projections to make cancellation, rollback, and error ownership explicit.

Keep the prototype unexported, define transaction payloads and behavior in one typed registry, preserve unchanged line references, and cover unkeyed add batches, keyed line updates, and retained error clearing.

Assisted-By: devx/daf1d5ae-aad3-4351-83a1-b2a9b3cd79ad
Promote the transaction prototype to the production cart store so optimistic updates, cancellation, settlement, and errors have explicit keyed ownership.

Handle asynchronous Standard Events and both endpoint response shapes, synchronize uncontrolled quantity inputs, preserve visible pending states, and include focused regression coverage plus a patch changeset.

Assisted-By: devx/daf1d5ae-aad3-4351-83a1-b2a9b3cd79ad
Discover a finite-stock variant by seeding a 9,999-unit cart and carrying the server-accepted maximum into the portable cart contract.

Hold the over-stock mutation to assert max plus one optimistically, then release it and verify the visible quantity rolls back after settlement.

Assisted-By: devx/daf1d5ae-aad3-4351-83a1-b2a9b3cd79ad
Keep the portable storefront contract independent of cookie names, storage choices, and framework-specific cart identity.

Use GraphQL only to discover the enforced maximum, then add and edit the cart through buyer-visible controls before asserting the over-stock optimistic rollback. Document this rule in the Playwright test skill.

Assisted-By: devx/daf1d5ae-aad3-4351-83a1-b2a9b3cd79ad
Wait for streamed product controls and visible aria-busy settlement instead of internal response timing. Open the cart through its accessible UI control while the intentionally gated update is pending, preserving portable buyer-visible setup across both examples.

Assisted-By: devx/daf1d5ae-aad3-4351-83a1-b2a9b3cd79ad
Coalesce one authoritative refresh after every overlapping cart transport settles so stale response snapshots cannot overwrite optimistic state, including paginated carts whose total quantity cannot be derived client-side.

Track mutation projections and correlated Standard Events, expose revalidation state across examples, prevent cart responses from being shared-cached, and align the packaged guidance with the new behaviour.

Assisted-By: devx/45f1d451-bce8-40a8-b6e4-c76ea132486e
Keep server-provided cart updatedAt in transaction snapshots so analytics trackers can detect confirmed cart changes after optimistic mutations.

Restore the Hydrogen example cart analytics payload converter used by CART_VIEWED while keeping the shared useCartAnalytics path for cart-update tracking.

Assisted-By: devx/1989e7c0-4c66-425f-8939-1c355f5bdc90
Keep the template-bundled agent skills in sync with packages/hydrogen/skills after rebasing onto the latest main.

This satisfies the CI template skill freshness check and adds the newly synced Hydrogen Oxygen skill files to both templates.

Assisted-By: devx/1989e7c0-4c66-425f-8939-1c355f5bdc90
Merge authoritative discount results without clobbering overlapping mutation state, expose pending cost as an optional public state affordance, and make analytics wait for pending or revalidating cart state.

Adds explicit cart totals status signalling for examples/templates and updates the storefront e2e contract to wait on that signal instead of absence of aria-busy. Keeps template code compatible with the published preview package while preferring pending.cost when available.

Updates cart, analytics, e2e, template, skill, dependency-map, and changeset docs to match the new behaviour.

Assisted-By: devx/1989e7c0-4c66-425f-8939-1c355f5bdc90
@fredericoo
fredericoo force-pushed the port-fb-transactions-model branch from a218b82 to fba9015 Compare August 4, 2026 17:08
@fredericoo
fredericoo marked this pull request as draft August 4, 2026 17:09
Why: the dependency-map convention is no longer used in this repo; the
file was introduced as a one-off by the logging PR (#3905) and nothing
in the repo references or enforces it.

How: delete .docs/dependencies.md, leaving no .docs/ directory in the
tree.

Assisted-By: devx/c2be4a65-f674-4cf3-8a3e-b6c1c25f6068
Why: add transaction settlement still had edge cases where stale add responses could reapply unrelated lines, selling-plan or attribute-specific adds could merge into the wrong line, and add warnings could outlive a resolved line retry.

How: reconcile only add-owned response lines, include selling plan and attributes in add identity, attach add errors to resolved line keys, add default line identity fields to the cart fragment, and cover stale, minimal, selling-plan, attribute, and warning-clearing regressions.
Assisted-By: devx/88362b31-9bef-4e6a-be6f-f6f1c57ed9fa
@fredericoo fredericoo added the gsd:50917 New Hydrogen label Aug 6, 2026
@fredericoo
fredericoo marked this pull request as ready for review August 6, 2026 10:26
@fredericoo
fredericoo requested a review from frandiox August 6, 2026 10:26
@fredericoo
fredericoo merged commit 94c4df5 into preview Aug 6, 2026
4 checks passed
@fredericoo
fredericoo deleted the port-fb-transactions-model branch August 6, 2026 10:28
IgorOffline pushed a commit to IgorOffline/Shopify-hydrogen that referenced this pull request Oct 10, 2026
* feat(cart): prototype transaction-based cart store

Model optimistic cart state as settled server state plus keyed transaction and error projections to make cancellation, rollback, and error ownership explicit.

Keep the prototype unexported, define transaction payloads and behavior in one typed registry, preserve unchanged line references, and cover unkeyed add batches, keyed line updates, and retained error clearing.

Assisted-By: devx/daf1d5ae-aad3-4351-83a1-b2a9b3cd79ad

* feat(cart): replace cart store with transaction model

Promote the transaction prototype to the production cart store so optimistic updates, cancellation, settlement, and errors have explicit keyed ownership.

Handle asynchronous Standard Events and both endpoint response shapes, synchronize uncontrolled quantity inputs, preserve visible pending states, and include focused regression coverage plus a patch changeset.

Assisted-By: devx/daf1d5ae-aad3-4351-83a1-b2a9b3cd79ad

* test(cart): cover optimistic stock rollback

Discover a finite-stock variant by seeding a 9,999-unit cart and carrying the server-accepted maximum into the portable cart contract.

Hold the over-stock mutation to assert max plus one optimistically, then release it and verify the visible quantity rolls back after settlement.

Assisted-By: devx/daf1d5ae-aad3-4351-83a1-b2a9b3cd79ad

* test(e2e): seed stock rollback through UI

Keep the portable storefront contract independent of cookie names, storage choices, and framework-specific cart identity.

Use GraphQL only to discover the enforced maximum, then add and edit the cart through buyer-visible controls before asserting the over-stock optimistic rollback. Document this rule in the Playwright test skill.

Assisted-By: devx/daf1d5ae-aad3-4351-83a1-b2a9b3cd79ad

* test(e2e): stabilize optimistic cart flow

Wait for streamed product controls and visible aria-busy settlement instead of internal response timing. Open the cart through its accessible UI control while the intentionally gated update is pending, preserving portable buyer-visible setup across both examples.

Assisted-By: devx/daf1d5ae-aad3-4351-83a1-b2a9b3cd79ad

* fix(cart): reconcile overlapping mutations

Coalesce one authoritative refresh after every overlapping cart transport settles so stale response snapshots cannot overwrite optimistic state, including paginated carts whose total quantity cannot be derived client-side.

Track mutation projections and correlated Standard Events, expose revalidation state across examples, prevent cart responses from being shared-cached, and align the packaged guidance with the new behaviour.

Assisted-By: devx/45f1d451-bce8-40a8-b6e4-c76ea132486e

* fix(cart): preserve analytics cart snapshots

Keep server-provided cart updatedAt in transaction snapshots so analytics trackers can detect confirmed cart changes after optimistic mutations.

Restore the Hydrogen example cart analytics payload converter used by CART_VIEWED while keeping the shared useCartAnalytics path for cart-update tracking.

Assisted-By: devx/1989e7c0-4c66-425f-8939-1c355f5bdc90

* chore(templates): sync bundled skills

Keep the template-bundled agent skills in sync with packages/hydrogen/skills after rebasing onto the latest main.

This satisfies the CI template skill freshness check and adds the newly synced Hydrogen Oxygen skill files to both templates.

Assisted-By: devx/1989e7c0-4c66-425f-8939-1c355f5bdc90

* fix(cart): address transaction review feedback

Merge authoritative discount results without clobbering overlapping mutation state, expose pending cost as an optional public state affordance, and make analytics wait for pending or revalidating cart state.

Adds explicit cart totals status signalling for examples/templates and updates the storefront e2e contract to wait on that signal instead of absence of aria-busy. Keeps template code compatible with the published preview package while preferring pending.cost when available.

Updates cart, analytics, e2e, template, skill, dependency-map, and changeset docs to match the new behaviour.

Assisted-By: devx/1989e7c0-4c66-425f-8939-1c355f5bdc90

* docs: remove .docs/dependencies.md dependency map

Why: the dependency-map convention is no longer used in this repo; the
file was introduced as a one-off by the logging PR (Shopify#3905) and nothing
in the repo references or enforces it.

How: delete .docs/dependencies.md, leaving no .docs/ directory in the
tree.

Assisted-By: devx/c2be4a65-f674-4cf3-8a3e-b6c1c25f6068

* fix(cart): address add transaction review feedback

Why: add transaction settlement still had edge cases where stale add responses could reapply unrelated lines, selling-plan or attribute-specific adds could merge into the wrong line, and add warnings could outlive a resolved line retry.

How: reconcile only add-owned response lines, include selling plan and attributes in add identity, attach add errors to resolved line keys, add default line identity fields to the cart fragment, and cover stale, minimal, selling-plan, attribute, and warning-clearing regressions.
Assisted-By: devx/88362b31-9bef-4e6a-be6f-f6f1c57ed9fa
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants