@clicktap/ui 0.40.8 → 0.40.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/docs/migrations/2026/01/2026-01-14-external-link-detection.md +290 -0
  2. package/docs/migrations/2026/01/2026-01-14-use-route-hook.md +142 -0
  3. package/docs/migrations/2026/01/2026-01-15-input-disable-skeleton.md +82 -0
  4. package/docs/migrations/2026/02/2026-02-16-env-utils-from-clicktap-ui.md +94 -0
  5. package/docs/migrations/2026/02/2026-02-17-select-popover-portal-container-restored.md +84 -0
  6. package/docs/migrations/2026/03/2026-03-24-image-dimmed-hidden-onready.md +108 -0
  7. package/docs/migrations/2026/03/2026-03-24-modal-overlay-backdrop-dismiss.md +81 -0
  8. package/docs/migrations/2026/04/2026-04-17-select-address-input-onchange-key-or-item.md +182 -0
  9. package/docs/migrations/2026/04/2026-04-24-image-placeholder-frontend-fallback.md +85 -0
  10. package/docs/migrations/2026/04/2026-04-30-prune-invalid-pdp-selections.md +166 -0
  11. package/docs/migrations/2026/04/2026-04-30-top-down-pdp-option-dependency.md +286 -0
  12. package/docs/migrations/2026/05/2026-05-01-build-pdp-url-util.md +128 -0
  13. package/docs/migrations/2026/05/2026-05-03-auth-aware-graphql-client.md +278 -0
  14. package/docs/migrations/2026/05/2026-05-20-framer-motion-11-idioms.md +391 -0
  15. package/docs/migrations/2026/05/2026-05-21-button-loading-content.md +265 -0
  16. package/docs/migrations/2026/05/2026-05-21-password-check-hide-on-empty.md +50 -0
  17. package/docs/migrations/2026/05/2026-05-29-password-check-requirements-variant.md +63 -0
  18. package/docs/migrations/2026/06/2026-06-17-select-standardization.md +286 -0
  19. package/docs/migrations/2026/06/2026-06-18-select-hiddenselect-setvalue-crash.md +75 -0
  20. package/docs/migrations/2026/07/2026-07-31-ui-exports-bundler-resolution.md +50 -0
  21. package/docs/migrations/README.md +31 -0
  22. package/package.json +1 -1
@@ -0,0 +1,108 @@
1
+ # Image: dimmed, hidden, and onReady Props
2
+
3
+ **Date:** 2026-03-24
4
+ **Affects:** `@clicktap/ui` — `Image` component
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No
7
+ **Group:** plp-image-transitions
8
+
9
+ ## Dependencies
10
+
11
+ *None*
12
+
13
+ See also: [Collection Image Filter Transitions](../../../../../../apps/frontend/docs/migrations/2026/03/2026-03-24-collection-image-filter-transitions.md) ↔ — frontend implementation using these props
14
+
15
+ ## Summary
16
+
17
+ The `Image` component gains three optional props for opacity-based loading transitions: `dimmed` (50% opacity), `hidden` (0% opacity), and `onReady` (callback when a new image loads after src change). When none of these props are passed, the component behaves exactly as before (blur-based loading). This also fixes a flicker bug caused by `onError` firing on valid images.
18
+
19
+ ## Problem
20
+
21
+ Two issues existed:
22
+
23
+ 1. **No way to coordinate image transitions** — collection grids needed to dim/hide images during filter changes to prevent stale variant images from flashing. The `Image` component had no props for external opacity control.
24
+
25
+ 2. **onError flicker** — Next.js image optimization can fire `onError` on valid images when one `srcSet` variant fails while another succeeds. The immediate `setErrorSrc` call caused a re-render that produced a visible flicker. Additionally, `key={srcKey}` on the error boundary caused a full component remount on every src change.
26
+
27
+ ## Changes
28
+
29
+ ### New props on `ExtendedImageProps`
30
+
31
+ ```typescript
32
+ export type ExtendedImageProps = ImageProps & {
33
+ /** Reduce opacity to 50% (e.g., while waiting for data) */
34
+ dimmed?: boolean;
35
+ /** Hide image completely — opacity 0 (e.g., before grid rearrange) */
36
+ hidden?: boolean;
37
+ /** Fires when a new image has loaded after a src change */
38
+ onReady?: () => void;
39
+ };
40
+ ```
41
+
42
+ **Source**: `libs/ui/src/components/Image/Image.tsx:62-69`
43
+
44
+ ### Managed vs unmanaged mode
45
+
46
+ When `dimmed`, `hidden`, or `onReady` are passed ("managed" mode):
47
+ - Opacity transitions replace the blur loading effect
48
+ - `opacity: 0` when hidden, `0.5` when loading or dimmed, `1` when loaded
49
+ - Src changes are detected during render (not `useEffect`) to prevent stale frame flash
50
+ - `onReady` fires when a new image finishes loading
51
+
52
+ When none are passed ("unmanaged" mode):
53
+ - Existing blur-based loading (`blur-md`) is preserved
54
+ - No opacity management, no src change detection
55
+ - Fully backward compatible
56
+
57
+ ### onError debounce (bugfix)
58
+
59
+ `onError` now uses a 2-second debounced timeout. If `onLoad` fires within that window (which it does for valid images), the timeout is cleared and no error fallback occurs.
60
+
61
+ **Before:**
62
+ ```tsx
63
+ onError={() => setErrorSrc(src)}
64
+ ```
65
+
66
+ **After:**
67
+ ```tsx
68
+ onError={() => {
69
+ errorTimer.current = setTimeout(() => setErrorSrc(src), 2000);
70
+ }}
71
+ onLoad={(e) => {
72
+ clearTimeout(errorTimer.current);
73
+ setLoadingImg(false);
74
+ // ...
75
+ }}
76
+ ```
77
+
78
+ ### Error boundary key removal (bugfix)
79
+
80
+ The `key={srcKey}` on `ImageErrorBoundary` has been removed. It was causing a full component remount on every src change, which destroyed state and produced flicker. The error boundary's `errorSrc` state check already handles src changes correctly.
81
+
82
+ ## Migration Steps
83
+
84
+ No action required — these are additive, non-breaking changes. Existing `Image` usage is unaffected.
85
+
86
+ To use the new props:
87
+
88
+ ```tsx
89
+ import { Image } from '@clicktap/ui/components/Image';
90
+
91
+ <Image
92
+ alt="Product"
93
+ src={imageUrl}
94
+ width={500}
95
+ height={625}
96
+ dimmed={isLoading} // optional: 50% opacity
97
+ hidden={isHidden} // optional: 0% opacity
98
+ onReady={() => onLoaded()} // optional: callback when loaded
99
+ />
100
+ ```
101
+
102
+ ## Migration Checklist
103
+
104
+ - [ ] Verify existing `Image` usage still works (blur loading, error fallback)
105
+ - [ ] Verify `dimmed` prop reduces opacity to 50%
106
+ - [ ] Verify `hidden` prop hides image completely (opacity 0)
107
+ - [ ] Verify `onReady` fires after src change + image load
108
+ - [ ] Verify no flicker on images that previously flickered with `onError`
@@ -0,0 +1,81 @@
1
+ # ModalOverlay: Fix Dismiss on Backdrop Click
2
+
3
+ **Date:** 2026-03-24
4
+ **Affects:** `@clicktap/ui` — `ModalOverlay` component
5
+ **Classification:** BUGFIX
6
+ **Breaking:** No
7
+
8
+ ## Dependencies
9
+
10
+ *None*
11
+
12
+ ## Summary
13
+
14
+ Clicking the backdrop overlay behind a drawer or modal did not dismiss it, even with `isDismissable` set. Pressing Escape and the close button worked correctly. The fix adds an explicit `onPointerDown` handler to the `ModalOverlay` that closes when the user clicks directly on the backdrop.
15
+
16
+ ## Problem
17
+
18
+ react-aria's `useInteractOutside` hook detects clicks **outside** a ref element to trigger dismiss. However, the `ModalOverlay` component covers the entire viewport — so every click, including on the backdrop, is considered "inside" the overlay ref. The dismiss never fires for backdrop clicks.
19
+
20
+ The `Modal` component already had `pointer-events-none [&>*]:pointer-events-auto` to let clicks pass through to the overlay, but react-aria's event detection still could not distinguish backdrop clicks from content clicks.
21
+
22
+ ## Changes
23
+
24
+ **File**: `libs/ui/src/components/ModalOverlay/ModalOverlay.tsx`
25
+
26
+ The `InnerModalOverlay` component now:
27
+
28
+ 1. Extracts `onOpenChange` from props (previously spread via `...restProps`)
29
+ 2. Adds a `handlePointerDown` callback that calls `onOpenChange(false)` when the click target is the overlay element itself (`e.target === e.currentTarget`)
30
+ 3. Passes both `onOpenChange` and `onPointerDown` to the overlay element
31
+
32
+ ```tsx
33
+ const {
34
+ key,
35
+ shouldCloseOnInteractOutside: propShouldClose,
36
+ onOpenChange,
37
+ ...restProps
38
+ } = props;
39
+
40
+ // react-aria's useInteractOutside checks clicks outside the overlay ref,
41
+ // but since the overlay covers the viewport, backdrop clicks are "inside"
42
+ // and never trigger dismiss. Handle backdrop clicks explicitly.
43
+ const handlePointerDown = useCallback(
44
+ (e: PointerEvent<HTMLDivElement>) => {
45
+ if (isDismissable && e.target === e.currentTarget && onOpenChange) {
46
+ onOpenChange(false);
47
+ }
48
+ },
49
+ [isDismissable, onOpenChange],
50
+ );
51
+
52
+ const commonProps = {
53
+ isDismissable,
54
+ shouldCloseOnInteractOutside,
55
+ onOpenChange,
56
+ onPointerDown: handlePointerDown,
57
+ // ...rest
58
+ };
59
+ ```
60
+
61
+ **Source**: `libs/ui/src/components/ModalOverlay/ModalOverlay.tsx:139-183`
62
+
63
+ The `e.target === e.currentTarget` check ensures that only clicks directly on the backdrop trigger dismiss — clicks on child elements (the drawer/dialog content) are ignored because `e.target` would be a descendant, not the overlay itself.
64
+
65
+ ## Migration Steps
66
+
67
+ 1. Update `libs/ui/src/components/ModalOverlay/ModalOverlay.tsx`:
68
+ - Add `PointerEvent` to the React type imports
69
+ - Destructure `onOpenChange` from props alongside `key` and `shouldCloseOnInteractOutside`
70
+ - Add the `handlePointerDown` callback
71
+ - Add `onOpenChange` and `onPointerDown: handlePointerDown` to `commonProps`
72
+
73
+ 2. Verify by opening any drawer (e.g., cart drawer) and clicking the dark backdrop area — it should now close.
74
+
75
+ ## Migration Checklist
76
+
77
+ - [ ] Update `ModalOverlay.tsx` with the `handlePointerDown` handler
78
+ - [ ] Verify drawers dismiss on backdrop click
79
+ - [ ] Verify clicking inside a drawer does **not** dismiss it
80
+ - [ ] Verify Escape key still dismisses
81
+ - [ ] Verify close button still works
@@ -0,0 +1,182 @@
1
+ # Select / AddressInput: `onSelectionChange` / `onChange` callback widened to `Key | T`
2
+
3
+ **Date:** 2026-04-17 *(documentation date; underlying react-aria upgrade landed 2026-01-19 via commit `7c27599b2a`)*
4
+ **Affects:** Frontend apps consuming `@clicktap/ui`'s `Select` and components that re-export its types (`AddressInput`, etc.). Any handler typed as `(value: Key) => void` breaks after the update.
5
+ **Classification:** COMPATIBILITY
6
+ **Breaking:** Yes — TypeScript-only. No runtime change; existing handlers still receive the same value at runtime, but strict TS setups fail to compile.
7
+
8
+ ## Dependencies
9
+
10
+ | Package | Minimum version |
11
+ |---|---|
12
+ | `@clicktap/ui` | The release that bundles `react-aria-components ^1.12.x` (commit `7c27599b2a`). Confirm with `pnpm why react-aria-components` that the resolved version is `>= 1.12.0`. |
13
+
14
+ ## Summary
15
+
16
+ `@clicktap/ui`'s `Select<T>` extends `AriaComboBoxProps<T>`. After the react-aria upgrade, the `onSelectionChange` argument type widened from `Key | null` to effectively `Key | null | T` — callers can now receive either the selected item's key **or**, in certain configurations, the full item object, depending on how the consumer sets up `items` and `selectedKey`.
17
+
18
+ For consumer handlers that were strictly typed `(key: Key) => void`, TypeScript now complains that `Key | T` is not assignable to `Key`. The fix is to narrow at the callback entry point.
19
+
20
+ ## Consumer Impact
21
+
22
+ Types changed:
23
+
24
+ | Type | Before | After |
25
+ |---|---|---|
26
+ | `SelectProps<T>['onSelectionChange']` | `(key: Key \| null) => void` | `(key: Key \| null \| T) => void` (widened union) |
27
+ | `AddressInputProps['onChange']` (in `apps/frontend/components/AddressForm/AddressInput`) | Re-typed to `(address?: AddressSuggestion) => void` — consumer-owned. Still works after upgrade if the inner `onSelectionChange` handler narrows correctly. |
28
+
29
+ Find affected handlers:
30
+
31
+ ```bash
32
+ # Handlers passed to Select or wrappers that assume `Key` only
33
+ grep -rn 'onSelectionChange\|onChange=' apps/frontend/ | \
34
+ grep -E 'Select|AddressInput|Combobox|Dropdown' | \
35
+ head -40
36
+ ```
37
+
38
+ ## Preconditions
39
+
40
+ - [ ] `@clicktap/ui` is pinned to the release containing commit `7c27599b2a` or later. Check with:
41
+ ```bash
42
+ jq '.dependencies["@clicktap/ui"]' apps/frontend/package.json
43
+ ```
44
+ - [ ] `apps/frontend/package.json` is tracked in git. Some legacy project layouts gitignore it — de-ignore before applying changes so CI/typecheck runs see the fix.
45
+
46
+ ## Migration Steps
47
+
48
+ ### 1. Narrow the argument in your handler
49
+
50
+ The cleanest fix is a type guard at the callback boundary:
51
+
52
+ ```tsx
53
+ // Before — TS error after upgrade
54
+ <Select
55
+ items={items}
56
+ onSelectionChange={(key: Key) => {
57
+ selectItemByKey(key);
58
+ }}
59
+ />
60
+
61
+ // After — narrow the widened union
62
+ <Select
63
+ items={items}
64
+ onSelectionChange={(keyOrItem) => {
65
+ const key = typeof keyOrItem === 'string' || typeof keyOrItem === 'number'
66
+ ? keyOrItem
67
+ : keyOrItem?.id; // or whatever your T's key field is
68
+ if (key === null || key === undefined) return;
69
+ selectItemByKey(key);
70
+ }}
71
+ />
72
+ ```
73
+
74
+ > **For consumers with 10+ call sites:** the per-site narrow above gets repetitive. Consider extracting a local helper to your project's `utils/` module:
75
+ >
76
+ > ```ts
77
+ > import type { Key } from 'react-aria-components';
78
+ >
79
+ > export function narrowKey<T extends { id: Key }>(
80
+ > value: Key | T | null,
81
+ > ): Key | null {
82
+ > if (value === null) return null;
83
+ > if (typeof value === 'string' || typeof value === 'number') return value;
84
+ > return value.id;
85
+ > }
86
+ > ```
87
+ >
88
+ > Then each call site becomes `onSelectionChange={(v) => { const key = narrowKey(v); if (key !== null) selectItemByKey(key); }}`. We may upstream this helper to `@clicktap/ui/utils` in a future release; for now, ship it locally if your codebase has enough call sites to justify the abstraction.
89
+
90
+ ### 2. If you need the full item, accept it directly
91
+
92
+ When a handler wants the full `T`, skip the narrow and type against it:
93
+
94
+ ```tsx
95
+ type Option = { id: Key; label: string };
96
+
97
+ <Select<Option>
98
+ items={options}
99
+ onSelectionChange={(value) => {
100
+ // value is Key | null | Option — narrow by shape
101
+ const option = typeof value === 'object' && value !== null ? value : null;
102
+ if (option) onOptionSelected(option);
103
+ }}
104
+ />
105
+ ```
106
+
107
+ ### 3. For wrappers like `AddressInput`, narrow inside the wrapper once
108
+
109
+ If your app wraps `Select` (the clicktap `AddressInput` does this), narrow in one place so every consumer of the wrapper keeps its old `(address?: T) => void` contract:
110
+
111
+ ```tsx
112
+ // apps/frontend/components/AddressForm/AddressInput/AddressInput.tsx
113
+ <Select<AddressSuggestion>
114
+ items={suggestions}
115
+ onSelectionChange={(keyOrItem) => {
116
+ const selection = typeof keyOrItem === 'object' && keyOrItem !== null
117
+ ? keyOrItem
118
+ : suggestions.find((s) => s.key === keyOrItem);
119
+ props.onChange?.(selection);
120
+ }}
121
+ />
122
+ ```
123
+
124
+ ### Verification
125
+
126
+ ```bash
127
+ # Typecheck should pass with zero errors mentioning onSelectionChange
128
+ nx run frontend:typecheck
129
+ ```
130
+
131
+ ## Consumer-Project Workaround
132
+
133
+ If for any reason the consumer project can't yet take the new `@clicktap/ui` release but must compile:
134
+
135
+ 1. **Pin the previous minor** in `apps/frontend/package.json`:
136
+ ```json
137
+ "@clicktap/ui": "~X.Y.Z"
138
+ ```
139
+ Replace `X.Y.Z` with the last minor before the react-aria 3.43.x bump.
140
+
141
+ 2. **Or wrap locally** until ready — shadow `Select` in a local component that narrows the callback before forwarding:
142
+ ```tsx
143
+ // apps/frontend/components/LegacySelect.tsx
144
+ import { Select as UpstreamSelect, type SelectProps } from '@clicktap/ui/components/Select';
145
+ import type { Key } from 'react-aria-components';
146
+
147
+ type LegacySelectProps<T extends { id: Key }> = Omit<SelectProps<T>, 'onSelectionChange'> & {
148
+ onSelectionChange?: (key: Key | null) => void;
149
+ };
150
+
151
+ export function LegacySelect<T extends { id: Key }>(props: LegacySelectProps<T>) {
152
+ return (
153
+ <UpstreamSelect<T>
154
+ {...props}
155
+ onSelectionChange={(v) => {
156
+ const key = typeof v === 'object' && v !== null ? v.id : v;
157
+ props.onSelectionChange?.(key ?? null);
158
+ }}
159
+ />
160
+ );
161
+ }
162
+ ```
163
+
164
+ Prefer the narrow-in-handler fix over the wrapper — the wrapper is only for bulk migrations where touching every call site at once isn't feasible.
165
+
166
+ ## Rollback
167
+
168
+ Revert `@clicktap/ui` in `apps/frontend/package.json`:
169
+
170
+ ```bash
171
+ docker compose exec -u app node-app pnpm --filter frontend add '@clicktap/ui@<previous-minor>'
172
+ ```
173
+
174
+ Safe — TypeScript-only change. No persistent state involved.
175
+
176
+ ## References
177
+
178
+ - Upstream commit: `7c27599b2a` — `refactor(ui): upgrade react-aria and refactor Select component`
179
+ - react-aria upgrade: `react-aria ^3.43.1`, `react-aria-components ^1.12.2`
180
+ - Existing related guide: `docs/migrations/2026-01-14-checkout-fixes.md` (partially documents this upgrade; expand with the onSelectionChange narrowing pattern above)
181
+ - Source: `libs/ui/src/components/Select/Select.types.ts`
182
+ - Wrapper reference: `apps/frontend/components/AddressForm/AddressInput/AddressInput.types.ts`
@@ -0,0 +1,85 @@
1
+ # `PLACEHOLDER_IMAGE` Export from `@clicktap/ui/utils/placeholder`
2
+
3
+ **Date:** 2026-04-24
4
+ **Affects:** `@clicktap/ui` consumers that render product/category images
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No
7
+
8
+ ## Dependencies
9
+
10
+ - See [frontend counterpart](2026-04-24-image-placeholder-frontend-fallback.md) ↔ for the consuming-app changes.
11
+
12
+ ## Summary
13
+
14
+ Adds `PLACEHOLDER_IMAGE` — a self-contained data URL — to `@clicktap/ui/utils/placeholder`. Frontend apps render this whenever they have a null image to display, replacing the old "backend returns a placeholder URL" pattern.
15
+
16
+ The value is an inline `data:image/svg+xml;utf8,...` string containing a neutral grayscale "no image available" SVG (camera icon with diagonal slash). Works in every bundler (Next.js, Storybook, Jest, vanilla node) without consumer-side asset pipeline configuration — no `public/` copy, no rewrite, no build script.
17
+
18
+ ## Changes
19
+
20
+ ### New export
21
+
22
+ ```ts
23
+ // libs/ui/src/utils/placeholder.ts
24
+ export const PLACEHOLDER_IMAGE: string;
25
+ ```
26
+
27
+ Import path:
28
+
29
+ ```ts
30
+ import { PLACEHOLDER_IMAGE } from '@clicktap/ui/utils/placeholder';
31
+ ```
32
+
33
+ ### What it looks like
34
+
35
+ The SVG is 200×200, viewBox `0 0 200 200`, with:
36
+ - Light gray background (`#f3f4f6`)
37
+ - Camera icon (rect + viewfinder bump + concentric lens circles), stroke `#9ca3af` weight 4
38
+ - Diagonal slash from upper-right to lower-left at 45° through the camera
39
+
40
+ The body is centered on the canvas (lens at canvas center 100,100); slash passes through canvas center. Symmetric.
41
+
42
+ ## Pattern for Consumers
43
+
44
+ Use directly as the `src` for any image renderer:
45
+
46
+ ```tsx
47
+ import { Image } from '@clicktap/ui/components/Image';
48
+ import { PLACEHOLDER_IMAGE } from '@clicktap/ui/utils/placeholder';
49
+
50
+ <Image
51
+ src={product.image ?? PLACEHOLDER_IMAGE}
52
+ alt={product.name}
53
+ // ...
54
+ />
55
+ ```
56
+
57
+ Works equally well with raw `<img>`:
58
+
59
+ ```tsx
60
+ <img src={product.image ?? PLACEHOLDER_IMAGE} alt="" />
61
+ ```
62
+
63
+ For the PDP/PLP migration, see the [frontend counterpart guide](2026-04-24-image-placeholder-frontend-fallback.md) — it covers the full cascade decision (variant → placeholder, no parent fallback) and the dead `no_selection` checks to remove.
64
+
65
+ ## Files Changed
66
+
67
+ | File | What changed |
68
+ |---|---|
69
+ | `libs/ui/src/utils/placeholder.ts` | New file. Inlines the SVG and exports a data-URL constant. |
70
+ | `libs/ui/src/assets/clicktap-placeholder.svg` | New file. Source of truth for the SVG; the data URL above is a byte-for-byte copy. Keep in sync if the icon changes. |
71
+ | `libs/ui/package.json` | Adds `./assets/*` to the `exports` map (already in place from earlier work; mentioned here for completeness). |
72
+
73
+ ## Migration Checklist
74
+
75
+ - [ ] `@clicktap/ui >= 0.27.0` installed in consumers.
76
+ - [ ] No need to copy any SVG into the consuming app's `public/` folder.
77
+ - [ ] Apply the [frontend counterpart guide](2026-04-24-image-placeholder-frontend-fallback.md) for the consuming-app changes.
78
+
79
+ ## Why a data URL and not a bundled file?
80
+
81
+ Three reasons:
82
+
83
+ 1. **Zero consumer setup.** A bundled file requires consumers to either copy it into `public/` or configure a static-file plugin. A data URL works on import.
84
+ 2. **Bundler-agnostic.** The `@clicktap/ui` package ships to apps using Next.js, Storybook, and Jest test runners. SVG-as-static-asset semantics differ across them; a string constant is identical everywhere.
85
+ 3. **Tiny payload.** The SVG is ~700 bytes; URL-encoded it's ~1KB. Network overhead vs an HTTP-fetched SVG is negligible and we avoid a second request.
@@ -0,0 +1,166 @@
1
+ # PDP Option Selection — Conservative Prune Helper (`pruneInvalidSelections`)
2
+
3
+ **Date:** 2026-04-30
4
+ **Affects:** `@clicktap/ui/utils/variantOptions` — adds `pruneInvalidSelections`
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No (additive)
7
+
8
+ ## Dependencies
9
+
10
+ - Builds on [`2026-04-30-top-down-pdp-option-dependency.md`](./2026-04-30-top-down-pdp-option-dependency.md). That migration introduced top-down option filtering with a blanket "clear every downstream selection on any upstream change". This migration replaces the recommended consumer pattern with a more conservative prune that preserves still-valid downstream picks.
11
+ - Frontend counterpart: [`apps/frontend/.../2026-04-30-prune-invalid-pdp-selections.md`](2026-04-30-prune-invalid-pdp-selections.md) ↔.
12
+
13
+ ## Summary
14
+
15
+ Adds `pruneInvalidSelections(variants, selections, axes)` to `@clicktap/ui/utils/variantOptions`. Walks the selection map top-to-bottom and drops only entries whose value is no longer reachable under the (possibly already-pruned) upstream context. Preserves user intent: a downstream pick is dropped only when actually invalid, never just because an upstream sibling changed.
16
+
17
+ `getDownstreamAxisCodes` is kept exported for convenience but the recommended `setUserSelection` pattern now uses `pruneInvalidSelections` instead.
18
+
19
+ ## Why
20
+
21
+ The previous `setUserSelection` pattern (from the earlier 2026-04-30 migration) blanket-cleared every axis downstream of the changed one. That's correct, but it discards user intent in a common case:
22
+
23
+ - User has `Color=Red, Size=Large, Material=Cotton`.
24
+ - User changes `Color` to `Blue`.
25
+ - If `Blue+Large+Cotton` is a real variant, blanket-clear still drops both `Size` and `Material` and forces the user to repick.
26
+
27
+ `pruneInvalidSelections` keeps still-valid downstream picks. Three behaviors:
28
+
29
+ | Scenario | Blanket clear (old) | Prune (new) |
30
+ |---|---|---|
31
+ | `Blue+Large+Cotton` is a real variant | Drops Size and Material | Keeps both |
32
+ | `Blue+Large` exists but `Blue+Large+Cotton` doesn't | Drops Size and Material | Keeps Size, drops Material |
33
+ | `Blue+Large` doesn't exist | Drops Size and Material | Drops Size and Material |
34
+
35
+ ## Public API
36
+
37
+ ```ts
38
+ import {
39
+ pruneInvalidSelections,
40
+ filterAvailableOptions,
41
+ findVariant,
42
+ getDownstreamAxisCodes,
43
+ } from '@clicktap/ui/utils/variantOptions';
44
+ ```
45
+
46
+ ```ts
47
+ /**
48
+ * Walks `selections` top-to-bottom along `axes` and drops any whose value
49
+ * is no longer reachable under the (possibly already-pruned) upstream
50
+ * context.
51
+ *
52
+ * Validates the entire selection map, not just downstream of one axis —
53
+ * works for both selection mutation and URL/state rehydration.
54
+ *
55
+ * Returns the same object reference when nothing was dropped, so callers
56
+ * can use `===` to skip downstream state updates.
57
+ */
58
+ function pruneInvalidSelections<V extends VariantLike>(
59
+ variants: V[],
60
+ selections: Record<string, string>,
61
+ axes: string[],
62
+ ): Record<string, string>;
63
+ ```
64
+
65
+ Implementation properties worth knowing:
66
+
67
+ - **Top-to-bottom iteration**: each axis evaluates against axes earlier in `axes` that have already been validated this pass. Cascades through naturally — if an upstream pick gets dropped, downstream is re-checked against the now-empty upstream slot.
68
+ - **Object identity preserved**: returns `selections` unchanged when nothing is dropped. Lets `useState` setters skip downstream re-renders via referential equality.
69
+ - **Conservative**: a downstream value is dropped only when its reachable set (under current upstream) doesn't contain it. Independence between sibling axes is preserved. E.g. switching `Color: Red→Blue` keeps `Material: Cotton` even when `Size` was dropped, as long as `Blue+(any size)+Cotton` exists.
70
+
71
+ ## Pattern for Consumers
72
+
73
+ Replace `getDownstreamAxisCodes` + manual delete in your selection handler with `pruneInvalidSelections` over the full candidate map. **Read the previous selections from the closure and run all side effects in event-handler scope — do NOT use the functional-updater form of setState here**:
74
+
75
+ ```tsx
76
+ import { pruneInvalidSelections } from '@clicktap/ui/utils/variantOptions';
77
+
78
+ const setSelection = useCallback(
79
+ (axisCode: string, optionCode: string) => {
80
+ if (selections[axisCode] === optionCode) {
81
+ return;
82
+ }
83
+ const candidate = { ...selections, [axisCode]: optionCode };
84
+ // Prune cascades: top-to-bottom, each axis reads pruned upstream.
85
+ const next = pruneInvalidSelections(variants, candidate, axisCodes);
86
+
87
+ setSelections(next);
88
+ writeSelectionsToUrl(next); // or any other side effect
89
+ },
90
+ [axisCodes, selections, variants],
91
+ );
92
+ ```
93
+
94
+ > ⚠ **Don't put side effects inside a `setSelections((prev) => ...)` updater.**
95
+ > React requires functional updaters to be pure — no calls that schedule
96
+ > updates on other components, no mutations. Calling `router.replace()` (or
97
+ > any side effect that dispatches into another component's render cycle)
98
+ > from inside an updater triggers `"Cannot update a component while
99
+ > rendering a different component"` and a recoverable crash on
100
+ > first-select / deselect interactions. React 19 enforces this harder.
101
+ > Read the previous state from the closure and put side effects after the
102
+ > setter, in the event-handler scope. Trade-off: closure-read loses
103
+ > stale-closure protection if multiple selections fire in the same
104
+ > event-loop tick — fine for swatch clicks paced by humans, but if you
105
+ > wire a programmatic batch-set-multiple-axes flow on top, defer the side
106
+ > effects to a `useEffect` keyed on `selections` and bring back the
107
+ > functional updater for the state write.
108
+
109
+ If your component tracks per-axis manual-clear flags (so auto-collapse doesn't re-fill an axis the user explicitly cleared), only unflag the axes that the prune actually dropped:
110
+
111
+ ```tsx
112
+ const candidate = { ...selections, [axisCode]: optionCode };
113
+ const next = pruneInvalidSelections(variants, candidate, axisCodes);
114
+
115
+ for (const code of axisCodes) {
116
+ if (candidate[code] !== undefined && next[code] === undefined) {
117
+ manuallyClearedAxes.current.delete(code);
118
+ }
119
+ }
120
+
121
+ setSelections(next);
122
+ writeSelectionsToUrl(next);
123
+ ```
124
+
125
+ Otherwise, a kept (still-valid) selection could later get auto-collapsed back to a value the user previously cleared.
126
+
127
+ ## Migration Steps
128
+
129
+ This is purely additive — existing code keeps compiling. To adopt:
130
+
131
+ ### 1. Bump `@clicktap/ui`
132
+
133
+ ```bash
134
+ pnpm up @clicktap/ui
135
+ ```
136
+
137
+ Minimum version: **next minor after the release that ships this migration**.
138
+
139
+ ### 2. Replace blanket clear with prune in selection handlers
140
+
141
+ Find every call site that uses `getDownstreamAxisCodes` to clear downstream selections:
142
+
143
+ ```bash
144
+ grep -rn 'getDownstreamAxisCodes' apps/ libs/ --include='*.ts' --include='*.tsx'
145
+ ```
146
+
147
+ Replace the manual delete loop with a single `pruneInvalidSelections` call (see "Pattern for Consumers" above). `getDownstreamAxisCodes` itself stays exported for any caller that genuinely needs the codes for non-prune purposes.
148
+
149
+ ### Verification
150
+
151
+ - **Behavior**: change an upstream axis on a configurable PDP. Confirm a downstream selection that's still reachable (i.e. the new combination is a real variant) stays selected. Confirm a downstream selection that's now unreachable gets dropped.
152
+ - **Lib tests**: `nx test ui --testPathPattern variantOptions` → 26 tests pass (was 15).
153
+ - **Typecheck**: `nx run frontend:typecheck` (or your app equivalent) → no errors.
154
+
155
+ ## Files Changed
156
+
157
+ | File | Change |
158
+ |---|---|
159
+ | `libs/ui/src/utils/variantOptions/pruneInvalidSelections.ts` | New — top-to-bottom prune helper |
160
+ | `libs/ui/src/utils/variantOptions/pruneInvalidSelections.test.ts` | New — Jest coverage (11 tests) |
161
+ | `libs/ui/src/utils/variantOptions/index.ts` | Export `pruneInvalidSelections` |
162
+
163
+ ## References
164
+
165
+ - Frontend counterpart migration: [`apps/frontend/.../2026-04-30-prune-invalid-pdp-selections.md`](2026-04-30-prune-invalid-pdp-selections.md) ↔
166
+ - Previous top-down migration this builds on: [`2026-04-30-top-down-pdp-option-dependency.md`](./2026-04-30-top-down-pdp-option-dependency.md)