Repository navigation
Add composable product details parts - #1502
Conversation
ProductDetails is now composed from ProductDetailsRoot and flat-exported display and purchase parts, plus useProductDetails and useProductOption hooks. Parts read state from the root, so any layout can only add a server-verified SKU. ProductDetails output is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 0a4780b 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 |
ProductPurchase renders option controls without the ProductOptions wrapper, so the default layout's spacing matches the previous release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wcole1-godaddy
left a comment
There was a problem hiding this comment.
Thanks for splitting ProductDetails into parts. The design is good, and the root keeps the old purchase logic. Typecheck, lint, build and the 47 tests pass locally. I also rendered the old and new ProductDetails in six product states and compared the HTML, and the differences are small. I listed them in the comment on the changeset.
I'm requesting changes for the items below, and the inline comments have the details.
Blocking:
- Parts share one
useId()from the root, so rendering a part twice creates duplicate DOM IDs. - The README says host
classNamecan control margins. In a Tailwind v4 host, margins don't work onProductDetailsTitle,ProductDetailsDescriptionorProductOption, and borders don't work on any part. - Please settle the public names before release, because renaming later is a breaking change.
Should fix:
4. The missing option warning fires for controls that mount after the first render.
5. The README example is labeled as the packaged layout, but it isn't the same.
6. The changeset and the PR description overstate a couple of things.
Follow ups that are fine in a later PR:
useProductDetails()exposesimagesbut not the current image index, so a custom gallery can't reset when the selection changes. The PR says the image resets "whichever part made the change".- There's no setter for a custom quantity control, and no hook for a custom SKU list control.
- Tests to add: a custom layout with a SKU list product, image reset,
ProductPurchaseStatusin the unavailable and variant error states, andselect(null). - The root reads only
:productId, and hosts can't customize its loading, error and not found states.
| } | ||
|
|
||
| /** Quantity for the verified SKU. Renders nothing until a SKU is verified. */ | ||
| export function ProductQuantity({ className }: ProductPartProps): ReactElement | null { |
There was a problem hiding this comment.
All parts build their IDs from the single fieldId that the root creates with useId(), so rendering a part twice produces duplicate IDs. A sticky mobile purchase bar next to <ProductPurchase /> is a common layout that does this.
I wrote a quick test that renders <ProductPurchase /> plus a second <ProductQuantity /> and <ProductAddToCart />. It produced a duplicate …-quantity ID when the SKU was ready, and a duplicate …-purchase-reason ID when it wasn't. With the duplicate quantity ID, the second label points at the first input. SkuOption has the same problem with …-sku-option.
Suggested fix: call useId() inside ProductQuantity, ProductAddToCart and SkuOption, and remove fieldId from the context. Please add a test that renders a part twice and checks that the IDs are unique.
There was a problem hiding this comment.
Fixed in 0a4780b. ProductDetailsQuantity, ProductDetailsAddToCart and the SKU list each call useId(), and fieldId is gone from the context. Two new tests cover this. One renders ProductDetailsPurchase plus a second quantity and add button, and checks that IDs stay unique in both the incomplete and ready states. The other renders ProductDetailsOptions twice for a SKU-list product. The test app now also has a sticky-bar layout and fails any page that has duplicate IDs or dangling for/aria-* references.
|
|
||
| Parts read state from the root and take no product, SKU, price or quantity props. Whatever the layout, only a server-verified SKU can be added, and changing a selection resets the quantity and image. Every part throws if it is rendered outside `ProductDetailsRoot`. If a layout cannot select every attribute a product requires, a console warning names the missing ones. Products without attributes but with several SKUs need `ProductOptions` or `ProductPurchase`. | ||
|
|
||
| Every part accepts `className`, which is added to the part's outer element. The packaged layout sets spacing and grid placement this way, so your classes can control margins, width and position. Package styles are scoped under `.commerce-storefront` and win over unscoped host classes for the properties a part already sets, such as its font size. |
There was a problem hiding this comment.
Host classes can't control margins on some parts. The package CSS ships without cascade layers. Its base reset sets margin: 0 on h1, p and fieldset (styles.css:27) and border-width: 0 on every element. A Tailwind v4 host puts its utilities in @layer utilities, and CSS outside a layer always wins over CSS inside a layer, whatever the specificity.
I loaded the built dist/styles.css in headless Chrome to check, and got these results:
- a layered
mt-4on anh1orpinside.commerce-storefrontgives 0px - a layered
mt-4on adivgives 16px - a layered
borderon adivgives 0px - an unlayered
.mt-4on apgives 16px only when it loads after the package CSS, because the specificity ties with the reset
So in a Tailwind v4 host, className='mt-4' works on ProductDetailsGallery, ProductDetailsPrice, ProductQuantity and ProductAddToCart. It does nothing on ProductDetailsTitle, ProductDetailsDescription and ProductOption, and borders don't work on any part.
Either make a div the outer element that receives className on those three parts, or rewrite the paragraph to state these limits. Either way, please add a test that checks the host class takes effect. The current test only checks that the class name is on the element.
There was a problem hiding this comment.
Thanks for checking this in Chrome. Fixed in 0a4780b. A div outer element alone would fix margins but not borders or color, because the reset sets border-width, color and font-family on every element. So each part now puts className on a commerce-part wrapper, and those three resets skip .commerce-part. A plain div needs neither reset.
I added a test that renders every part with a host class. It matches the shipped dist/styles.css rules against each outer element, and fails if any package rule sets margin, padding, border, color or font-family there. It fails on the previous CSS. The test app also loads a probe class inside @layer utilities and checks computed styles on every part in Chrome; all of them now pass. The README now spells out the limit: elements inside a part keep the package styles, so a host font size on ProductDetailsTitle does not resize the heading.
| ...attributes.map((attribute) => `attribute:${attribute.name}`), | ||
| ...(skuOptions.length ? ['sku'] : []), | ||
| ].join('\n'); | ||
| // Parent effects run after child effects, so every mounted control has registered by now. |
There was a problem hiding this comment.
The check runs once, when the required controls first become known. A control that mounts later, such as one in a closed accordion, a tab or a mobile options sheet, triggers a false warning that says the product "can never be added to the cart".
I confirmed it with a test that renders <ProductOption attribute='size' /> only after a button click. The warning fired once, and the product could still be bought after the section was opened.
Could you change the message to say that no control for "size" is rendered right now, instead of saying the product can never be added? The warning also runs in production builds, so please consider limiting it to development.
There was a problem hiding this comment.
Fixed in 0a4780b. The warning now says No control for "size" is rendered right now, so this product cannot be added to the cart until one is, and adds that it can be ignored if the control mounts later. It is skipped when process.env.NODE_ENV === "production", the same check @tanstack/react-query uses, so hosts already define it. A test covers the production case, and the test app mounts the size option after a click and checks the wording.
|
|
||
| ## Custom product layouts | ||
|
|
||
| `ProductDetails` is the recommended product page. When its layout does not fit, compose the same parts inside `ProductDetailsRoot`. The root loads the route's `:productId` and shows the connection, loading, error and not-found states itself, so parts render only once the product exists. This is the packaged layout, written out: |
There was a problem hiding this comment.
The example below is labeled "the packaged layout, written out", but it leaves out the grid wrapper and the spacing classes that ProductDetails passes in. Margins moved out of the parts, so anyone who copies the example gets a single column with no space between the parts. Please show the real layout from product-details.tsx, or change the label to something like "a minimal layout".
There was a problem hiding this comment.
Fixed in 0a4780b. The README now shows the real layout from product-details.tsx, with the grid and spacing classes. A note says that without them, the parts stack in one column. The test app renders a copy of the example and checks that it is pixel-identical to <ProductDetails />.
| useProductOption, | ||
| } from './product-details-context'; | ||
| export { | ||
| ProductAddToCart, |
There was a problem hiding this comment.
Please settle the names before release, because changing them later is a breaking change.
The display parts use the ProductDetails* prefix, but the purchase parts use Product*, e.g., ProductOption, ProductQuantity and ProductAddToCart, even though they also work only inside ProductDetailsRoot. PR 2 adds ProductCard parts, so the shorter Product* names may collide with those or suggest that they work there too. ProductAddToCart and the existing AddToCartButton would also be two add-to-cart exports with different rules, and an agent can easily mix them up.
One option is to use the ProductDetails* prefix for every part that requires the root.
There was a problem hiding this comment.
Agreed. Renamed in 0a4780b. Every part that needs the root now uses the ProductDetails* prefix: ProductDetailsPurchase, ProductDetailsOptions, ProductDetailsOption, ProductDetailsPurchaseStatus, ProductDetailsAvailability, ProductDetailsQuantity and ProductDetailsAddToCart. The hook and types follow: useProductDetailsOption, ProductDetailsOptionState, ProductDetailsOptionValueState and ProductDetailsPartProps. PurchaseStatus became ProductDetailsStatus, because ProductDetailsPurchaseStatus is the component. The README also points out that AddToCartButton is the standalone button.
| '@godaddy/gd-commerce-storefront': minor | ||
| --- | ||
|
|
||
| Add composable product parts. `ProductDetailsRoot` with display parts (`ProductDetailsGallery`, `ProductDetailsTitle`, `ProductDetailsPrice`, `ProductDetailsDescription`, `ProductDetailsBackLink`) and purchase parts (`ProductPurchase`, `ProductOptions`, `ProductOption`, `ProductPurchaseStatus`, `ProductAvailability`, `ProductQuantity`, `ProductAddToCart`) build custom product layouts. `useProductDetails` reads product state and `useProductOption` builds custom option controls. Only server-verified SKUs can be added in any layout. `ProductDetails` is unchanged and is now composed from these parts. |
There was a problem hiding this comment.
"ProductDetails is unchanged" isn't quite accurate. I rendered the old and new ProductDetails in six states and compared the HTML. Apart from class order, which has no visual effect, the new version has these differences:
- it wraps the add button in a
<div> - it adds a hidden reason
<span>and pointsaria-describedbyat it, instead of at the visible guidance paragraph - it adds
aria-describedbyin the variant error state, where the old version had none
The differences look fine to me, but "visually unchanged" would be accurate.
The PR description has a similar problem. It says the disabled add button always has an accessible description. When the quantity is higher than the stock, the quantity is invalid, or the cart is still loading, AddToCartButton disables itself without giving a reason. The old version behaved the same way, so either fix it in AddToCartButton or soften the claim.
There was a problem hiding this comment.
Fixed in 0a4780b. The changeset now says ProductDetails is visually unchanged and lists the markup differences. The screenshots are still pixel-identical to 0.2.0. The new wrappers briefly added a 24px gap under the status messages, which the pixel diff caught, and I fixed it in the same commit. I softened the PR description: the reason is guaranteed only before a SKU is verified. I left the AddToCartButton reasons for quantity over stock and the cart loading as a follow-up, because 0.2.0 behaves the same way.
- Give each part its own useId() so parts rendered twice, such as a sticky purchase bar, keep unique IDs and labels. - Put className on a commerce-part wrapper that the package CSS reset skips, so layered host utilities (Tailwind v4) control margin, padding, border and color on every part. Cancel the space-y gap on the parts that had none in 0.2.0 so ProductDetails stays pixel-identical. - Rename purchase parts, the option hook and the shared types to the ProductDetails* prefix before release. - Reword the missing-control warning for controls that mount later, and limit it to development builds. - Show the real packaged layout in the README, document the className limits, and correct the changeset wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the thorough review. All six items are addressed in 0a4780b, and each inline comment has a reply with details. I left the follow-ups you listed for a later PR:
Checks: typecheck, lint and build pass, and all 51 tests pass. In the test app, the smoke run passes and its 13 screenshots are pixel-identical to 0.2.0. The 4 new parts checks pass: layered host classes, parts rendered twice, a late-mounting option, and the README layout matching |
wcole1-godaddy
left a comment
There was a problem hiding this comment.
Thanks for working through the feedback. The duplicate IDs, naming, warning, README and changeset items are all fixed, and typecheck, lint, build and all 51 tests pass locally.
One possible follow-up, not blocking: host classes that share a name with a class the package ships, such as mt-4 or flex, still lose to the package's copy, so mt-4 lg:mt-0 or flex lg:hidden won't respond at breakpoints.
Why
This PR makes our components more customizable without making them more difficult for an agent to use. I'm doing this over multiple PRs so it's easier to review.
ProductDetailswas a single component. Changing its layout meant forking it or adding more props, which is the prop-explosion problem described in Soul-Crushing Components. This PR splits it into compound parts. The packaged component stays the default.AAB and other agents should keep using the packaged components, such as
<ProductDetails />. Nothing about that path changes. The parts are an opt-in option for when a layout genuinely needs more customization.The parts are designed so they can't produce impossible states. They read state from
ProductDetailsRootand take no product, SKU, price or quantity props. Whatever the layout, only a server-verified SKU can be added.What
ProductDetailsRootloads the route's:productIdand shows the loading, error and not-found states itself. Parts only render once the product exists.ProductDetailsGallery,ProductDetailsTitle,ProductDetailsPrice,ProductDetailsDescription,ProductDetailsBackLink.ProductDetailsPurchase(the packaged purchase area),ProductDetailsOptions,ProductDetailsOption,ProductDetailsPurchaseStatus,ProductDetailsAvailability,ProductDetailsQuantity,ProductDetailsAddToCart. Every part that needs the root uses theProductDetails*prefix, so none can be confused withProductCard(PR 2) or the standaloneAddToCartButton.useProductDetails()is read-only.useProductDetailsOption(attribute)supports custom controls such as swatches, andselectaccepts only that attribute's real values.className. Parts can be rendered more than once, such as in a sticky mobile purchase bar, and each copy gets its own element IDs.ProductDetailsis now composed from these parts and is visually unchanged: screenshots are pixel-identical to 0.2.0. Its markup differs: each part has a wrapper<div>, and the disabled add button'saria-describedbypoints at a hidden reason element instead of the visible guidance paragraph.Guardrails
AddToCartButtonstill disables itself without a reason when the quantity is over stock or invalid, or the cart is loading. 0.2.0 behaves the same way; that's a follow-up.Notes
classNameon acommerce-partwrapper that the package's CSS reset skips. The package CSS has no cascade layers, so before this change the reset beat a Tailwind v4 host's layered utilities. Host classes now control margin, padding, border, color and grid placement on every part. Inner elements keep the package's own styles, so a host font size onProductDetailsTitledoesn't change the heading's size."use client"directive. That is a follow-up, as areProductCard(PR 2) and the cart drawer (PR 3).Testing
Package checks: typecheck, lint, build and all 51 storefront tests pass. 9 of the tests are new. They cover:
The example app builds.
Release check against 0.2.0: I built a standalone Vite test app outside the monorepo (
commerce-component-test-app) that installs the package the way a host would. It uses only exports that exist in 0.2.0, so the same app code runs on both versions:@godaddy/gd-commerce-storefront@0.2.0from npm and recorded a baseline.pnpm packtarball and ran the same checks.The app runs against a mock
/api/commerceserver with five products that cover every purchase path:Mock contract: a contract script takes the product queries from
gd-commerce-server's source, validates them against the catalog-api-v2storefront.graphqlschema, and runs them with the mock's responses as data. Every response matches the real server's response exactly: no missing, extra, wrongly typed or wrongly null fields. The script fails on a deliberately broken mock.Results on both versions:
The pixel comparison caught two regressions where wrappers picked up a 24px
space-y-6gap that 0.2.0's markup never got. The first was below the option dropdowns, fixed in 1cea7c6. The second was below the status and availability messages, fixed in 0a4780b.Composable parts (PR builds only): a separate
parts.htmlpage in the test app runs 4 more checks, all passing:@layer utilities, checked through computed margin, padding, border and color<ProductDetails />Every smoke and parts check also fails on duplicate IDs, and on
label[for]oraria-*references that point at a missing ID.🤖 Generated with Claude Code