fix(catalog): stabilise hook order in CatalogPurchaseWidgetView

React reported "Rendered more hooks than during the previous render"
when CatalogPurchaseWidgetView transitioned from currentOffer=null to
a real offer: hook count jumped from 22 to 23 because the
useMemo/useEffect block for the builders-club placement state sat
*below* the `if(!currentOffer) return null` early-return on line 140.
On the first render it never ran; on the next render (offer loaded)
it did, and React's hook-call tracker flagged the divergence and
unmounted the component via the error boundary.

Fix: move the three builders-club hooks (useMemo builderPlaceableStatus,
useMemo buildersClubPlaceOneButtonStyle, useEffect interval) above the
early return. They already short-circuit cleanly when
isBuildersClubPlaceable is false — added a defensive `!currentOffer`
guard on the first useMemo and an explicit `!!currentOffer` clause on
the derived isBuildersClubPlaceable so the .product access stays safe
when offer is null. Behavior unchanged for the loaded-offer path; the
early-render path now runs the hooks but their bodies no-op.

Verification: yarn typecheck clean, yarn test 209/209.
This commit is contained in:
simoleo89
2026-05-19 17:43:20 +02:00
parent d28819db89
commit 6bf3366af7
@@ -137,15 +137,19 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
};
}, [ purchaseState ]);
if(!currentOffer) return null;
// Builders-club state — derived + hooks MUST run unconditionally on
// every render so the hook order stays stable even when currentOffer
// is null (the `if(!currentOffer) return null` below would otherwise
// hide the useMemo/useEffect block from the first render and React
// would flag "Rendered more hooks than during the previous render").
const isBuildersClubOffer = (currentType === CatalogType.BUILDER);
const isBuildersClubPlaceable = isBuildersClubOffer
&& !!currentOffer
&& !!currentOffer.product
&& ((currentOffer.product.productType === ProductTypeEnum.FLOOR) || (currentOffer.product.productType === ProductTypeEnum.WALL));
const builderPlaceableStatus = useMemo(() =>
{
if(!isBuildersClubPlaceable || !getBuilderFurniPlaceableStatus) return BuilderFurniPlaceableStatus.OKAY;
if(!isBuildersClubPlaceable || !getBuilderFurniPlaceableStatus || !currentOffer) return BuilderFurniPlaceableStatus.OKAY;
return getBuilderFurniPlaceableStatus(currentOffer);
}, [ currentOffer, getBuilderFurniPlaceableStatus, isBuildersClubPlaceable, builderPlaceableRefreshTick ]);
@@ -164,6 +168,8 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
return () => clearInterval(interval);
}, [ isBuildersClubPlaceable ]);
if(!currentOffer) return null;
const PurchaseButton = () =>
{
if(isBuildersClubPlaceable)