Skip to content

Make storefront parts extensible without reimplementing their rules - #1511

Merged
sbolinger-godaddy merged 6 commits into
godaddy:mainfrom
sbolinger-godaddy:feat/storefront-extensibility
Oct 20, 2026
Merged

sbolinger-godaddy merged 6 commits into
godaddy:mainfrom
sbolinger-godaddy:feat/storefront-extensibility

Conversation

@sbolinger-godaddy

@sbolinger-godaddy sbolinger-godaddy commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #1509. Additive changes informed by a comparison with Shopify's Storefront Web Components, aimed at preventing bugs in host apps and avoiding cases where a host has to rewrite a part (and its rules) to make a small change.

Changes

Roots no longer depend on the route

  • ProductDetailsRoot takes productId (falls back to the route's :productId) for quick views or products outside their route.
  • urlState={false} keeps a root's selection out of the page URL, so two roots on one page no longer share option.*/sku.
  • A root with no product ID shows not-found (with a dev warning) instead of requesting /products/.
  • CatalogRoot/Catalog take pageSize (default 6). CatalogRoot takes urlState={false} for a second catalog on a page.
  • New CatalogEmpty part for a custom empty catalog, matching CartEmpty. While one is mounted, CatalogProducts leaves out its default message.
  • Existing URL param names are unchanged, so shared links keep working.

Replacement text that keeps behavior

  • CartButton, AddToCartButton, ProductDetailsAddToCart, ProductCardAction, CartCheckout, CartItemRemove, CartTitle, CartDescription and ProductDetailsBackLink take children, or a function of their state, e.g. ({ busy }) => ….
  • The controls keep their gating (for example, checkout still waits for the total and blocks double submits). Empty content falls back to the package text. A replaced label always starts the accessible name (WCAG 2.5.3): CartItemRemove adds the product name as visually hidden text instead of aria-label, so <CartItemRemove>Delete</CartItemRemove> is named "Delete Mug".
  • ProductDetailsRoot takes loading, notFound and error ({ message, retry }). These work like <Suspense fallback>: they replace the root's content before the product exists, so custom parts' hooks can still rely on a loaded product. The README states the rule: a part's text is its children, a state inside a loaded root is a part (CartEmpty, CatalogEmpty), and a state before the root has data is a root prop.

Locale-aware prices

  • locale on the provider (default en-US; an invalid tag falls back with a dev warning). It only formats; localized UI text will be a separate opt-in prop, so setting locale never changes wording in a minor release.
  • useMoney() for custom parts, an optional locale argument on money(), and an exported ProductImage.

Events

  • onEvent reports item-added, quantity-changed, item-removed, discount-applied, checkout-started and error. Sample events carry isSample: true.
  • Behavior fix: a host callback that throws (including onItemAdded) is now logged. Before, it failed the cart operation and showed its message as a cart error.

Stable styling hooks

  • data-commerce-part on every part and root, plus data-commerce-control and data-commerce-attribute where a part has several controls.
  • Documented as a stable styling API, with the specificity needed to beat package utilities. No elements were added or removed.

README sections added: Replacing text, Styling hooks, Events, plus a quick-view example. Minor changeset included.

Testing

  • typecheck, lint, build and test pass. 106 tests, including 20 new ones in src/extensibility.test.tsx.
  • commerce-component-test-app, base (Add composable cart parts #1509) vs this branch: smoke, parts, walkthrough and boutique all pass. Screenshots are pixel-identical (57/57, zero threshold) and there were no console errors, package warnings or DOM audit failures.

Screenshots

commerce-component-test-app running this branch's packed build (02cdeaf), with the mock store connected. Each one is pixel-identical to the #1509 baseline.

Catalog Product details (Blue / M, qty 3) Cart drawer
Catalog grid with four product cards Everyday shirt product page with Blue, M and quantity 3 selected Cart drawer with three line items and a $153.00 subtotal

- ProductDetailsRoot takes productId, urlState and loading/notFound/error
  replacements; it no longer requests /products/ without a product ID.
- CatalogRoot and Catalog take pageSize; CatalogRoot takes urlState, and
  CatalogProducts takes empty.
- Action and heading parts take replacement text, or a function of their
  state, while keeping their behavior.
- CommerceProvider takes locale; useMoney and ProductImage are exported.
- onEvent reports cart changes and failures; a throwing host callback no
  longer fails the cart operation.
- Every part has a stable data-commerce-part styling hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sbolinger-godaddy
sbolinger-godaddy requested a review from a team as a code owner October 19, 2026 18:31
@changeset-bot

changeset-bot Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4ea2afa

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

This PR includes changesets to release 1 package
Name Type
@godaddy/gd-commerce-storefront Minor

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

sbolinger-godaddy and others added 4 commits October 19, 2026 11:39
Matches CartEmpty and the compound parts pattern: the empty state is its
own part, placed anywhere in the layout, instead of a content prop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ules

- CartItemRemove names its line with visually hidden text instead of
  aria-label, so a replaced label starts the accessible name (WCAG 2.5.3).
  The default name, "Remove <name>", is unchanged.
- A sample card with a replaced action label drops its aria-label the
  same way.
- README: one rule for replacing content (part text is children, loaded
  states are parts, pre-data root states are root props).
- locale only formats; localized UI text will be a separate opt-in prop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@wesleycole wesleycole left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approved. This is a useful additive API: hosts can customize presentation and embed products without reimplementing the storefront's purchasing and checkout rules. Built the storefront package and verified all 103 existing tests and typecheck pass. Leaving three non-blocking comments covering empty-label fallback, image recovery, and asynchronous callback isolation; the first two were reproduced with targeted tests.

): ReactNode {
if (typeof label === 'function') {
const content: ReactNode = label(state);
return hasChildren(content) ? content : fallback;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: the empty-content fallback does not handle empty fragments. hasChildren() treats a fragment as content without inspecting its children, so a label such as () => <></> (or a fragment containing only null/false children) produces an unnamed add-to-cart button instead of the default label. I reproduced this with a targeted test. Could we recursively inspect fragments, handle blank strings, and add regression coverage for render-function results? The README's guarantee should also be narrowed: an arbitrary child component can render null, which this helper cannot generally detect.

* A product image that fills its container, with a labeled placeholder when there is no URL or it fails to load.
* Use it in custom parts so they handle missing images like the package's own.
*/
export function ProductImage({ url, name, className = '' }: ProductImageProps): ReactElement {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: now that ProductImage is a supported public export, could we make it recover when url changes after an image failure? The existing failed boolean stays true for the lifetime of the component, so a custom SKU image that changes from a broken URL to a valid URL remains a placeholder and never requests the replacement image. I reproduced this by firing an error and rerendering with a new URL. Tracking the failed URL, or resetting failure on URL changes, plus a regression test would address it. This behavior predates this PR internally; the public export makes it worth addressing here.

/** Runs a host callback so that its failure cannot fail the cart operation that triggered it. */
function callHost(callback: () => void): void {
try {
callback();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: callHost isolates synchronous throws, but not rejected promises. An integration such as onEvent={async (event) => { await sendAnalytics(event); }} is accepted by TypeScript and can produce an unhandled rejection if analytics fails. Could we observe the callback's returned promise and log rejections without awaiting it or delaying cart/checkout operations? Please add a rejecting-async-callback test for onEvent/onItemAdded. This would complete the callback-isolation behavior for common async integrations.

…callback rejections

- hasChildren looks inside fragments and treats blank text as empty, so `() => <></>` keeps the
  package label. The README now says a label component that returns null cannot be detected.
- ProductImage keys its failure to the URL that failed, so a new URL is requested.
- callHost logs a rejected promise from onEvent or onItemAdded without awaiting it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sbolinger-godaddy

Copy link
Copy Markdown
Collaborator Author

All review comments addressed in 4ea2afa

@sbolinger-godaddy
sbolinger-godaddy merged commit 1958f0a into godaddy:main Oct 20, 2026
3 checks passed
@sbolinger-godaddy
sbolinger-godaddy deleted the feat/storefront-extensibility branch October 20, 2026 20:35
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