Skip to content

fix(ep-commerce): eleven pre-existing defects blocking the 0.4.0 publish - #448

Merged
field123 merged 21 commits into
masterfrom
fix/ep-0.4.0-prepublish-defects
Aug 21, 2026
Merged

fix(ep-commerce): eleven pre-existing defects blocking the 0.4.0 publish#448
field123 merged 21 commits into
masterfrom
fix/ep-0.4.0-prepublish-defects

Conversation

@field123

Copy link
Copy Markdown
Collaborator

Verifying 0.4.0's registered components against the EP Commerce Skeletons composition turned up no 0.4.0 regression, but eleven pre-existing defects — most shopper-facing, all reproducing on published 0.3.0. Publishing without them ships known-broken behaviour.

Bundles

  • Validation never fired: refine's path is relative to the schema being refined, so path: [componentKey] put every issue at [key, key] where nothing read it. Add-to-cart stayed enabled and EP's 500 was discarded. Validity and messages now come from one safeParse, which also covers a configuration the page arrived with — react-hook-form only fills formState.errors for fields it has seen written. use-bundle-validation.tsx was dead code; its wording moved into the schema.
  • A bundle with a parent-product option could not be added at all: deselecting held the option at quantity: 0 (EP rejects the whole request), and the catalog configuration was merged option-by-option so the bare parent reappeared beside a chosen parentId:childId ("too many selections").

Checkout

Products

  • The PDP quoted the parent's price while the cart charged the child's ($20 vs $15): the variation picker kept the resolved child in a private context. EPProductProvider publishes it as currentVariant, projected onto the product shape, and money is never inherited — a child reports only without_tax, and one with no price of its own cannot be bought at the parent's.

Slot defaults that showed labels or fake values
Six components shipped defaults that looked like content and weren't: the three checkout field groups (zero <input> elements), the step indicator ("Step Name" ×4), order totals ("$0.00" ×4), both shipping-rate repeaters (placeholder text and nothing rendered without slot content), both quantity buttons ("+" for decrement too), and the three facet components ("Filter Value (0)", "Category (0)", a chip reading "Brand: Leather"). All now render real, data-driven defaults; slot content still replaces them.

The facet lists stay non-interactive by design — they expose toggle/refine on the item data specifically so they need not pre-render a button, and a test now enforces that rather than leaving it to a comment.

Each fix has a test proven to fail against the pre-fix behaviour. 2095 jest tests in 131 suites, 113 vitest.

Not changed: Playstation 5 Bundle 1 has no price in the catalog and its t3 component demands two selections while offering one resolvable option — catalog data, not code. Both now surface as legible messages instead of a 500.

…tions EP rejects

A bundle reported itself valid however it was configured, and add-to-cart stayed
enabled: the schema's refinements passed `path: [componentKey]` to `refine`,
whose path is relative to the schema being refined, so every issue landed at
`[componentKey, componentKey]` while `useBundleForm` read only the top level.
One `superRefine` per component, no path, carries the count-aware messages that
`use-bundle-validation.tsx` has been holding unused since the package landed.

Two selection shapes made a bundle with a parent-product option impossible to
add. Deselecting held the option at quantity 0 instead of removing it, because
the max=1 branch zeroed its siblings and the removal branch read a stale
`selectedOptions`; EP fails the whole request on a single zero. And the catalog
configuration was merged option-by-option, so the bare parent reappeared next to
a chosen `parentId:childId` and EP counted two selections against a max of one.
`handleComponentSelection` now computes the component map in one pass,
`convertSelectionsForAPI` drops non-positive quantities and superseded parents,
and priority 2 fills only components nobody has spoken for.

The button no longer offers an add EP will refuse, and shows the reason when one
fails rather than swallowing it. Option names fall back to nothing rather than a
raw product id, and a component with no maximum stops printing 9007199254740991.

The tests that encoded the old setValue call shape are repointed; the new ones
run the real resolver over the real schema, which is what the mocked suite could
never catch.
…ts, and a working button

The provider built its summary only from `state.order`, which does not exist
until payment succeeds, and EPOrderTotalsBreakdown prefers that summary over the
cart — so the checkout page read "Subtotal $0.00 / Total $0.00" for the whole
flow, beside a cart summary showing the real money. The summary falls back to the
cart's display_price until an order exists, and the breakdown treats an all-zero
summary next to a funded cart as "nothing to report yet".

The three field groups defaulted their slot to text labels: a thing that looks
like a form, collects nothing, and leaves the flow unable to advance. An empty
slot now renders working inputs wired to the group's own setField — a country
dropdown included — and anything placed in the slot still replaces them.

EP Checkout Button outside a provider was permanently aria-disabled with a dead
click handler, which is exactly the cart-page composition the skeletons call for.
It now labels itself for the cart and navigates to `checkoutUrl`.

EPShippingMethodSelector fetched /api/checkout/calculate-shipping on every valid
address — a route this package retired and answers with 410 Gone, because EP has
no shopper-facing rates endpoint. The dead path is gone, `loadingContent` follows
the session's own loading state, and useCheckout's calculateShipping fails with
the reason instead of issuing a request that cannot succeed.
EPVariationPicker resolved the matching child product and wrote its id to the
form, but kept the child itself in a context of its own — so nothing outside the
picker could read it. A buybox price therefore showed the parent's price while
the cart charged the child's: $20.00 on the page, $15.00 at checkout, for the
same click.

EPProductProvider publishes the chosen child as `currentVariant`, from inside
the form scope so it can watch the field and above the content so the buybox can
read it. EP Product Field prefers the variant for any field the variant answers
(price, SKU) and falls back to the product for the rest.
…d never inherit a price

A ChildProduct is flat — `name`, `sku`, `price` — while everything that reads a
product looks under `attributes` and `meta.display_price`, so publishing the
child as-is left `currentVariant` unreadable and the buybox still quoting the
parent. It is projected onto the product shape instead, a drop-in for
`currentProduct`.

Money is not inherited. A child reports only `without_tax`, so keeping the
parent's `with_tax` would answer a tax-inclusive binding with the parent's
number, and a child with no price of its own cannot be bought at the parent's
price at all — EP rejects the add. Once a variant is chosen, every money leaf
resolves against it and an absent price renders empty.
…ust the one they edit

react-hook-form fills `formState.errors` only for fields it has seen written, so
a bundle that arrived invalid — a shared `bundle_config` link, a catalog default
that cannot be satisfied — reported `isValid: false` with no message to go with
it. The button was correctly disabled and said nothing about why, which is the
same dead end as before from the shopper's side.

Validity and messages both come from one `safeParse` against the current
selections, so every path into the page is covered and the two can never
disagree. The mocked suite could not see this: it stubs the schema and asserts
against `formState`. Found in a browser.
An empty slot rendered the literal words "Step" and "Name", once per step —
four identical rows telling the shopper nothing. The default now gives the step
number and name, and marks the active one with aria-current.
…wrong glyph

A sweep of every static slot default found the same shape as the field groups,
three more times, each showing a value rather than a label:

- EP Order Totals Breakdown defaulted to four rows of the literal text "$0.00" —
  a page that looks priced and isn't. It renders the real figures, and the
  discount row only when there is one.
- EP Shipping Method Selector and EP Checkout Shipping Rates defaulted to
  "Shipping Method"/"$0.00", and their live repeaters rendered *nothing* without
  slot content. Each rate now gets a selectable row — radio semantics, keyboard
  included — because choosing one is a ref action the designer wires, so a
  read-only default would leave the rates unpickable. The session rows format
  their minor units through useMoneyFormat, so they follow a currency switch
  like every other total.
- Both quantity buttons defaulted their slot to "+", so a decrement button
  dropped in as-is read "+". The glyph follows `action`.

Facet placeholders in EP Refinement List, EP Hierarchical Menu and EP Current
Refinements are the same class but show a label, not a value, and sit on the
search surface this pass never exercised. Left alone deliberately.
Auditing the prepublish fixes turned up four places where behaviour changed and
nothing asserted it: EPBillingAddressFields' default inputs, the `currentVariant`
projection in EPProductProvider, EPCheckoutShippingRates' default row, and
useCheckout's calculateShipping.

Each was proven red by restoring the pre-fix behaviour, one revert at a time
rather than trusting a green run:

- billing: reverting the three fallback sites fails 3 of 4
- projection: removing CurrentVariantScope fails all 6; re-inheriting the
  parent's display_price fails only the no-price-of-its-own test; merging into
  the parent's display_price instead of replacing it fails only the
  tax-inclusive test — so each test earns its place
- rate rows: restoring the `children ? … : null` render fails 3 of 4
- calculateShipping: restoring the fetch fails 2 of 3

The projection needed its own harness: the existing EPProductProvider suite mocks
DataProvider without its `data`, so nothing published through it was observable.
…non-interactive

The last three placeholder defaults: "Filter Value (0)", "Category (0)", and a
correctly shaped chip reading "Brand: Leather" whatever the shopper had actually
applied.

Both facet lists deliberately pre-render no button or <a>, exposing `toggle` /
`refine` on the item data so the click is wired in Studio. The defaults honour
that: real label, count, refined state — and for the category tree a `data-depth`
attribute, since a flattened hierarchy is unreadable without indentation. Tests
assert the *absence* of any interactive element, so the constraint is now
enforced rather than just documented in a comment.

EP Current Refinements is the exception: its row already carries
`onClick={chip.refine}`, so the chip is interactive by design and its "×" means
something. Only the fiction was replaced; the chip styling stays.

Built test-first, one behaviour at a time — each of the 15 tests written and seen
red before the code that satisfies it. Two mistakes that caught: a marker that
matched inside `export const …` and silently un-exported the component, and tests
written against `attribute` when the prop is `attributes` (a string, not an
array) — the first caught by the suite, the second only by tsc.
handleComponentSelection rebuilt the map from the render-time watch()
snapshot, so useVariationSelection's clear-then-set pair both read the same
stale object and the set reinstated the child the clear removed. Both
children then went to Elastic Path, which refused the lot. Reads
getValues(componentKey) instead; the mocked suite's getValues follows.
The slot kept a styled hbox as its defaultValue, so Studio materialised a
childless-but-truthy node on insert and the chip branch never ran — one empty
pill per active filter. Drops the default and hides the placeholder, matching
the other slots whose defaults were removed. The pill styling does not move
onto the chip: catalog-search components carry no appearance styles, which the
headless styling contract enforces.
Three defects in the summary the checkout provider publishes:

- hasDiscount was never set, so the default rows computed a discount and then
  dropped the row that shows it.
- A zero tax on a placed order read "Calculated at next step". Once the order
  exists, zero tax means zero.
- The cart's own meta.display_price.shipping was ignored, so a cart already
  carrying shipping showed "TBD" while the cart-fed path on the same page
  showed the figure. That amount is already inside with_tax, so it is now
  displayed without being added again; a rate the shopper selects here is
  local state that never reaches the cart, so it still adds to the total.

Also flips both hasDiscount predicates to `!== 0`: Elastic Path reports a cart
discount as a reduction, so `> 0` would never fire on a promoted cart.
normalizeChildProducts kept only the child's without_tax, so the variant
projection had no with_tax to publish and correctly refused to fall back to
the parent's — leaving a tax-inclusive PDP showing the parent's price before
selection and nothing after it.
…r variants

The earlier fix only covered callers that clear the old child first.
EPBundleVariationPicker never passes selectedVariationId, so
useVariationSelection's clear branch never fires and the set call arrives
alone — every switch left the previous child in the map. Browser-verified on
the bundle page: switching a size took the Gift component to "2 of 0-3" with
one option visibly selected.

An option resolves to exactly one variant, so the write itself now drops the
option's other variants and its bare parent instead of depending on the
caller.
…ate updater

React runs state updaters during render, so calling onSelectionChange from
inside setVariationSelections made the provider's setValue a render-phase
update on another component — "Cannot update a component
(EPBundleProviderInner) while rendering a different component
(EPBundleVariationPicker)" on every variation click — and StrictMode, which
invokes updaters twice, ran the whole selection twice.
…icked

Choosing a variation also toggles the option checkbox, which writes the bare
parent with no variationId. A variant write already superseded the bare
parent; the reverse was missing, so whichever write landed last decided the
count and the component read "2 of 0-3" for a single gift.
EP reports without_tax post-discount, so a Discount row beside it read
"Subtotal 13.50, Discount -1.50, Total 13.50". Both cart-derived paths now
take the subtotal from without_discount, falling back to without_tax when a
cart omits it. The order path is unchanged: it reports no discount, so its
rows are already consistent.
…heckout

- Bundle options no longer read as unavailable while the products are still
  loading: productsLoading was in the context and unused, so every option in
  the bundle flashed unavailable on first paint.
- The variant projection publishes the child's own images. The child fetch
  already asks for main_image and files; the normalizer discarded them, so
  selecting a variant changed name, SKU and price but kept the parent's photo.
  A child without an image of its own still inherits the parent's.
- The standalone checkout button handles Enter and Space, and its slot no
  longer defaults to "Continue" while the button calls itself "Checkout" —
  an empty slot now renders the step-aware label.
- Checkout field errors are tied to their inputs with aria-describedby.
- Both rate selectors wrap their default rows in a radiogroup, and only when
  those rows are the radios being rendered.
- An applied promotion comes back as a promotion_item; it is no longer
  rendered as a purchasable line or counted in itemCount. custom_item stays,
  being a real adjustment line.
EP references one file from both main_image and files, so both normalizers
pushed it twice and a gallery bound to images showed the same photo twice.
Seen in the browser on the Sandle PDP, where the parent published two images
that were the same file.
The applied state lived only in local component state, so any navigation lost
the "TEST1" chip while the discount was still live on the cart. The cart now
publishes its promotions separately from its lines — they are still not sold
as lines or counted in itemCount — and both promo input variants read the
applied promotion from there.

CartItem.type was typed as cart_item alone, which is what let promotion_item
be mistaken for a purchasable line; it now carries the union Elastic Path
actually side-loads.
@field123
field123 merged commit 86913fd into master Aug 21, 2026
9 checks passed
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.

1 participant