@clicktap/ui 0.40.8 → 0.40.10
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.
- package/components/Accordion/Accordion.d.ts +1 -1
- package/components/Accordion/Accordion.js +1 -1
- package/components/Accordion/index.d.ts +2 -2
- package/components/Accordion/index.js +1 -1
- package/components/Badge/Badge.js +1 -1
- package/components/Badge/Badge.types.d.ts +1 -1
- package/components/Badge/index.js +1 -1
- package/components/Collection/CollectionFilters/CollectionFilters.js +1 -1
- package/components/Collection/CollectionFilters/index.js +1 -1
- package/components/Collection/index.js +1 -1
- package/components/Pagination/Pagination.d.ts +1 -1
- package/components/Pagination/Pagination.js +1 -1
- package/components/Pagination/index.js +1 -1
- package/components/Tooltip/Tooltip.d.ts +1 -1
- package/components/Tooltip/Tooltip.js +1 -1
- package/components/Tooltip/Tooltip.types.d.ts +1 -1
- package/components/Tooltip/index.js +1 -1
- package/docs/migrations/2026/01/2026-01-14-external-link-detection.md +290 -0
- package/docs/migrations/2026/01/2026-01-14-use-route-hook.md +142 -0
- package/docs/migrations/2026/01/2026-01-15-input-disable-skeleton.md +82 -0
- package/docs/migrations/2026/02/2026-02-16-env-utils-from-clicktap-ui.md +94 -0
- package/docs/migrations/2026/02/2026-02-17-select-popover-portal-container-restored.md +84 -0
- package/docs/migrations/2026/03/2026-03-24-image-dimmed-hidden-onready.md +108 -0
- package/docs/migrations/2026/03/2026-03-24-modal-overlay-backdrop-dismiss.md +81 -0
- package/docs/migrations/2026/04/2026-04-17-select-address-input-onchange-key-or-item.md +182 -0
- package/docs/migrations/2026/04/2026-04-24-image-placeholder-frontend-fallback.md +85 -0
- package/docs/migrations/2026/04/2026-04-30-prune-invalid-pdp-selections.md +166 -0
- package/docs/migrations/2026/04/2026-04-30-top-down-pdp-option-dependency.md +286 -0
- package/docs/migrations/2026/05/2026-05-01-build-pdp-url-util.md +141 -0
- package/docs/migrations/2026/05/2026-05-03-auth-aware-graphql-client.md +278 -0
- package/docs/migrations/2026/05/2026-05-20-framer-motion-11-idioms.md +391 -0
- package/docs/migrations/2026/05/2026-05-21-button-loading-content.md +265 -0
- package/docs/migrations/2026/05/2026-05-21-password-check-hide-on-empty.md +50 -0
- package/docs/migrations/2026/05/2026-05-29-password-check-requirements-variant.md +63 -0
- package/docs/migrations/2026/06/2026-06-17-select-standardization.md +286 -0
- package/docs/migrations/2026/06/2026-06-18-select-hiddenselect-setvalue-crash.md +75 -0
- package/docs/migrations/2026/07/2026-07-31-ui-exports-bundler-resolution.md +50 -0
- package/docs/migrations/2026/08/2026-08-02-nextui-to-heroui.md +65 -0
- package/docs/migrations/README.md +31 -0
- package/package.json +7 -5
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# Auth-aware GraphQL client — `@clicktap/ui/utils/createAuthAwareGraphqlClient`
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-05-03
|
|
4
|
+
**Affects:** `@clicktap/ui/utils/createAuthAwareGraphqlClient` — new export
|
|
5
|
+
**Classification:** IMPROVEMENT
|
|
6
|
+
**Breaking:** No (additive)
|
|
7
|
+
|
|
8
|
+
## Dependencies
|
|
9
|
+
|
|
10
|
+
*None.*
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
Adds two factories and an event bus for handling `graphql-request` responses uniformly across consumer apps:
|
|
15
|
+
|
|
16
|
+
- `createAuthAwareGraphqlClient()` — returns a `request(url, doc, vars, headers)` callable plus a `.withOptions({ … signal })` overload. Drop-in for any caller that previously used bare `request()` from `graphql-request`.
|
|
17
|
+
- `createAuthAwareFetch()` — returns a `fetch` function suitable for `new GraphQLClient(url, { fetch: … })` when the caller manages their own client config (cors, credentials, custom headers).
|
|
18
|
+
- `authAwareEvents` — a small, app-runtime-agnostic event bus. Both factories dispatch `unauthenticated` (HTTP 401), `permissionDenied` (403), and `serverError` (5xx) events. Consumer apps subscribe from a Provider component (where xstate actors and routers are in scope) to wire logout / toast / redirect.
|
|
19
|
+
|
|
20
|
+
The `request()` form additionally returns the partial `data` from a HTTP-200-with-errors response — single-field GraphQL errors don't tear down the whole UI. (Same behavior the prior project-level `apps/frontend/utils/graphqlRequest.ts` had — moved here so every consumer gets it for free.)
|
|
21
|
+
|
|
22
|
+
## Why
|
|
23
|
+
|
|
24
|
+
Every consumer app was reinventing the same four pieces in its own actors / mutation handlers:
|
|
25
|
+
|
|
26
|
+
1. 401 → log out + toast + redirect to login
|
|
27
|
+
2. 403 → permission-denied toast
|
|
28
|
+
3. 5xx → service-unavailable toast
|
|
29
|
+
4. Partial responses → return `data`, don't throw
|
|
30
|
+
|
|
31
|
+
The 401 path in particular was load-bearing for the upcoming "no-guest storefront" pattern (see [`2026-05-03-guest-checkout-disabled-storefront.md`](#) cross-app guides), but its scope is broader: any project that wants its session-expired UX coherent benefits.
|
|
32
|
+
|
|
33
|
+
The factory pattern decouples the lib from app runtime context. The lib emits typed events; the app subscribes from a Provider where it has the runtime concerns (auth machine, toast actor, router). Lib stays pure; consumers stay flexible.
|
|
34
|
+
|
|
35
|
+
## Public API
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import {
|
|
39
|
+
createAuthAwareGraphqlClient,
|
|
40
|
+
createAuthAwareFetch,
|
|
41
|
+
authAwareEvents,
|
|
42
|
+
ClientError, // re-exported — see "Cross-package error handling" below
|
|
43
|
+
GraphQLClient, // re-exported
|
|
44
|
+
type AuthAwareEventName,
|
|
45
|
+
type AuthAwareEventDetail,
|
|
46
|
+
} from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
|
|
47
|
+
|
|
48
|
+
import { getRequestErrorStatus } from '@clicktap/ui/utils/error';
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
/**
|
|
53
|
+
* Returns a callable: `(url, document, variables?, headers?) => Promise<TData>`.
|
|
54
|
+
* Plus a `.withOptions({ url, document, variables?, headers?, signal? })` form
|
|
55
|
+
* for actors needing AbortSignal support.
|
|
56
|
+
*
|
|
57
|
+
* Internally creates a fresh GraphQLClient per call. On error: dispatches
|
|
58
|
+
* the appropriate event (401/403/5xx) and either rethrows or returns the
|
|
59
|
+
* partial `data` from a HTTP-200-with-errors response.
|
|
60
|
+
*/
|
|
61
|
+
function createAuthAwareGraphqlClient(): AuthAwareGraphqlClient;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns a `fetch` function. Pass to:
|
|
65
|
+
* new GraphQLClient(url, { fetch: createAuthAwareFetch(), credentials, mode, … })
|
|
66
|
+
*
|
|
67
|
+
* Wraps the global `fetch`. On 401/403/5xx response, dispatches the
|
|
68
|
+
* matching event with `{ status, response }`. The original Response is
|
|
69
|
+
* returned untouched — graphql-request still parses, throws, etc. exactly
|
|
70
|
+
* as it would have. The event dispatch is a side channel.
|
|
71
|
+
*/
|
|
72
|
+
function createAuthAwareFetch(): typeof fetch;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Module-level event bus. Subscribe with a callback; returns an unsubscribe
|
|
76
|
+
* function.
|
|
77
|
+
*
|
|
78
|
+
* authAwareEvents.subscribe('unauthenticated', ({ status, error, response }) => { … });
|
|
79
|
+
*/
|
|
80
|
+
export const authAwareEvents: {
|
|
81
|
+
subscribe(name: AuthAwareEventName, listener: (detail: AuthAwareEventDetail) => void): () => void;
|
|
82
|
+
dispatch(name: AuthAwareEventName, detail: AuthAwareEventDetail): void;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type AuthAwareEventName = 'unauthenticated' | 'permissionDenied' | 'serverError';
|
|
86
|
+
interface AuthAwareEventDetail {
|
|
87
|
+
status: number;
|
|
88
|
+
/** Set when triggered from a `graphql-request` ClientError. */
|
|
89
|
+
error?: ClientError;
|
|
90
|
+
/** Set when triggered from the fetch wrapper. */
|
|
91
|
+
response?: Response;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Pattern for consumers
|
|
96
|
+
|
|
97
|
+
Inside a Provider component (where actors + router + toast are in scope):
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
import { authAwareEvents } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
|
|
101
|
+
import { useAuth } from '@/state/auth/AuthProvider';
|
|
102
|
+
import { useToast } from '@/state/toast/ToastProvider';
|
|
103
|
+
|
|
104
|
+
function AuthAwareEventBridge({ children }: { children: ReactNode }) {
|
|
105
|
+
const { authActor } = useAuth();
|
|
106
|
+
const { toastActor } = useToast();
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const unsubUnauth = authAwareEvents.subscribe('unauthenticated', () => {
|
|
110
|
+
authActor.send({ type: 'LOGOUT' });
|
|
111
|
+
toastActor.send({
|
|
112
|
+
type: 'ADD_ITEM',
|
|
113
|
+
item: <Alert type="error" title="Session expired" message="Please log in again." />,
|
|
114
|
+
duration: 5000,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
const unsubPerm = authAwareEvents.subscribe('permissionDenied', () => {
|
|
118
|
+
toastActor.send({
|
|
119
|
+
type: 'ADD_ITEM',
|
|
120
|
+
item: <Alert type="error" title="Permission denied" />,
|
|
121
|
+
duration: 5000,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
const unsub5xx = authAwareEvents.subscribe('serverError', () => {
|
|
125
|
+
toastActor.send({
|
|
126
|
+
type: 'ADD_ITEM',
|
|
127
|
+
item: <Alert type="error" title="Service unavailable" />,
|
|
128
|
+
duration: 5000,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
return () => { unsubUnauth(); unsubPerm(); unsub5xx(); };
|
|
132
|
+
}, [authActor, toastActor]);
|
|
133
|
+
|
|
134
|
+
return <>{children}</>;
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
For raw callers, two flavors:
|
|
139
|
+
|
|
140
|
+
**Caller that previously used `request()` from `graphql-request`:**
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// Before
|
|
144
|
+
import { request } from 'graphql-request';
|
|
145
|
+
const data = await request(url, document, vars, headers);
|
|
146
|
+
|
|
147
|
+
// After
|
|
148
|
+
import { graphqlRequest } from '@/utils/graphqlRequest';
|
|
149
|
+
// where graphqlRequest is `createAuthAwareGraphqlClient()` instantiated once at module scope
|
|
150
|
+
const data = await graphqlRequest(url, document, vars, headers);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Caller that constructs its own `GraphQLClient`:**
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
// Before
|
|
157
|
+
const client = new GraphQLClient(url, { credentials: 'include', mode: 'cors' });
|
|
158
|
+
|
|
159
|
+
// After
|
|
160
|
+
import { createAuthAwareFetch } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
|
|
161
|
+
const client = new GraphQLClient(url, {
|
|
162
|
+
credentials: 'include',
|
|
163
|
+
mode: 'cors',
|
|
164
|
+
fetch: createAuthAwareFetch(),
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Cross-package error handling — `ClientError` re-export + `getRequestErrorStatus`
|
|
169
|
+
|
|
170
|
+
**Added 2026-06-15.**
|
|
171
|
+
|
|
172
|
+
`graphql-request` is bundled into every consuming package, so its `ClientError`
|
|
173
|
+
class is a *different object* in each bundle. A consumer that catches an error
|
|
174
|
+
thrown by this client and checks `error instanceof ClientError` using its **own**
|
|
175
|
+
`import { ClientError } from 'graphql-request'` always gets `false` — the class
|
|
176
|
+
identities don't match across the package boundary. In practice this silently
|
|
177
|
+
broke the 401→login redirect on SSR route fetches: the `instanceof` guard
|
|
178
|
+
failed, the 401 fell through to a rethrow, and the page **500'd instead of
|
|
179
|
+
redirecting**.
|
|
180
|
+
|
|
181
|
+
Two fixes, both additive:
|
|
182
|
+
|
|
183
|
+
1. **`ClientError` (and `GraphQLClient`) are re-exported from the client
|
|
184
|
+
module.** Import them from `@clicktap/ui` so the class identity matches the
|
|
185
|
+
one this client throws:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { ClientError } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
return await fetchRoute(path);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof ClientError && error.response?.status === 401) {
|
|
194
|
+
redirect('/account/authenticate?…');
|
|
195
|
+
}
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
2. **`getRequestErrorStatus(error)` — identity-agnostic status extraction.**
|
|
201
|
+
Prefer this at cross-package boundaries: it duck-types `response.status`, so
|
|
202
|
+
it works regardless of which bundle threw, with no `instanceof` at all.
|
|
203
|
+
Returns `undefined` when no numeric status is present.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import { getRequestErrorStatus } from '@clicktap/ui/utils/error';
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
return await fetchRoute(path);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (getRequestErrorStatus(error) === 401) {
|
|
212
|
+
redirect(`/account/authenticate?redirect=${path}&reason=authentication_required`);
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### SSR route fetches must forward the auth token
|
|
219
|
+
|
|
220
|
+
Now that catalog/entity routes can be auth-gated on the backend (the router's
|
|
221
|
+
`EntityAuthenticationGate` 401s a guest request whose route resolves to a gated
|
|
222
|
+
entity type), an SSR route/data fetch that omits the caller's bearer token makes
|
|
223
|
+
a **logged-in user look unauthenticated to the gate** — they get bounced like a
|
|
224
|
+
guest. SSR fetches for gated routes must forward `Authorization: Bearer <token>`
|
|
225
|
+
from the request's session:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
const token = await getAccessToken(); // request-memoized; no extra round-trip
|
|
229
|
+
const data = await graphqlRequest(url, GetRoute, { path }, {
|
|
230
|
+
...scopeHeaders,
|
|
231
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The storefront-auth-gating guide documents where route protection is configured.
|
|
236
|
+
|
|
237
|
+
## Migration Steps
|
|
238
|
+
|
|
239
|
+
This is purely additive — existing code keeps compiling. To adopt:
|
|
240
|
+
|
|
241
|
+
### 1. Bump `@clicktap/ui`
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
pnpm up @clicktap/ui
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Minimum version: **next minor after the release that ships this migration**.
|
|
248
|
+
|
|
249
|
+
### 2. Build a Provider-level event bridge
|
|
250
|
+
|
|
251
|
+
Mount it once high in the tree (alongside `AuthProvider` / `ToastProvider`). Subscribe to the three events, dispatch into your project's runtime. See "Pattern for consumers" above.
|
|
252
|
+
|
|
253
|
+
### 3. (Incremental) Migrate actors that hit gated GraphQL operations
|
|
254
|
+
|
|
255
|
+
For any actor that performs a mutation the backend has gated behind auth (e.g. cart mutations on a no-guest storefront), switch its GraphQL call to one of the two forms above. The wrapping is what fires the events.
|
|
256
|
+
|
|
257
|
+
Actors that hit non-gated operations don't need migration unless you want their 5xx / 403 to surface a toast too — adopt incrementally.
|
|
258
|
+
|
|
259
|
+
## Verification
|
|
260
|
+
|
|
261
|
+
- **Typecheck** clean.
|
|
262
|
+
- **Browser smoke test**:
|
|
263
|
+
1. Mount a page that performs a wrapped GraphQL request.
|
|
264
|
+
2. Force the response to 401 (devtools → request blocking → respond with 401, or expire the JWT).
|
|
265
|
+
3. Confirm: subscriber callback fires; auth/toast/router behave as wired.
|
|
266
|
+
4. Repeat for 403 and 503.
|
|
267
|
+
|
|
268
|
+
## Files Changed
|
|
269
|
+
|
|
270
|
+
| File | Change |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `libs/ui/src/utils/createAuthAwareGraphqlClient.ts` | NEW — factories + event bus; re-exports `ClientError`/`GraphQLClient` (2026-06-15) |
|
|
273
|
+
| `libs/ui/src/utils/error.ts` | `getRequestErrorStatus()` — identity-agnostic HTTP-status extraction (2026-06-15) |
|
|
274
|
+
|
|
275
|
+
## References
|
|
276
|
+
|
|
277
|
+
- Frontend wiring (consumes this): [`apps/frontend/.../2026-05-03-guest-checkout-disabled-storefront.md`](../../../../../apps/frontend/docs/migrations/2026/05/2026-05-03-guest-checkout-disabled-storefront.md)
|
|
278
|
+
- Backend that 401s the gated operations: [`apps/middleware/.../2026-05-03-guest-checkout-disabled-storefront.md`](../../../../../apps/middleware/docs/migrations/2026/05/2026-05-03-guest-checkout-disabled-storefront.md)
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# framer-motion 11.x idioms — opacity-only for modal entry/exit, primitives over transform strings, matched keyframe shapes
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-05-20
|
|
4
|
+
**Affects:** `@clicktap/ui/components/Dialog`, `@clicktap/ui/components/ModalOverlay`, `@clicktap/ui/components/Badge`, `@clicktap/ui/components/Loader/CircularEasing`, `@clicktap/ui/components/Loader/Pulse`, and any consumer-side `motion.*` usage on framer-motion 11.x.
|
|
5
|
+
**Classification:** COMPATIBILITY
|
|
6
|
+
**Breaking:** Yes (for consumers passing custom `animationVariants` containing multi-function `transform` strings, transform/scale primitives in modal variants, calc-with-mixed-units in animate targets, or who copy-paste the affected motion patterns into their own components — see Consumer Impact)
|
|
7
|
+
|
|
8
|
+
## Dependencies
|
|
9
|
+
|
|
10
|
+
*None.* Targets framer-motion 11.18.
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
framer-motion 11.x's keyframe resolver crashes inside `mixObject` (`complex.mjs:48`) with `Cannot read properties of null (reading '0')` more often than the docs suggest. The root cause is `getMixer(null)`: `typeof null === 'object'` in JavaScript, so when the resolver hands a null keyframe value into the mixer factory it returns `mixObject`, and `mixObject` then dereferences a key on null and throws. Any time the resolver leaves a null in the keyframes array — unresolved DOM-dependent value, a key animated in one phase but missing in another, a complex/calc value that fails to parse, an un-interpolatable CSS property batched with other unresolved keyframes — it crashes here.
|
|
15
|
+
|
|
16
|
+
We hit this bug repeatedly across the UI library and the frontend app. After iterating on several "use primitives instead of transform strings" attempts that *still* crashed under realistic ancestor-AnimatePresence load, we landed on a small set of rules that hold:
|
|
17
|
+
|
|
18
|
+
1. **Modal entry/exit animations: animate `opacity` only.** Centering, scale, and any other transform-related styling stays in static CSS (Tailwind). The Dialog and ModalOverlay default variants now look like `{ opacity: 0 } ↔ { opacity: 1 }`, full stop.
|
|
19
|
+
2. **For non-modal motion (toasts, badges, page-level enter/exit), prefer framer-motion primitives (`x`, `y`, `rotate`, `scale`, `opacity`) over multi-function CSS `transform` strings.** Primitives compose into a single transform that framer-motion owns; transform strings go through the complex-value parser, which is the buggy code path.
|
|
20
|
+
3. **Every key that `animate` touches must also be present in `initial` and `exit`.** When you have a no-animation branch (`shouldAnimate ? animatedShape : skippedShape`), the skipped shape still has to include the same keys with sensible base values (usually `0`).
|
|
21
|
+
4. **Resolve unit math to pixels in JS before handing it to framer-motion.** Strings like `calc(-100% + Npx + Yrem)` are complex values; the parser can't reliably mix them across remeasure cycles.
|
|
22
|
+
5. **Anything WAAPI can't interpolate goes on a static CSS class, not in `variants`.** `backdrop-filter`, complex `clip-path`, gradient `background`, `pointer-events`, etc.
|
|
23
|
+
|
|
24
|
+
Until framer-motion 12.x lands (where this resolver is rewritten), treat these rules as the long-term convention, not a workaround.
|
|
25
|
+
|
|
26
|
+
## Problem
|
|
27
|
+
|
|
28
|
+
A timeline of what failed before we got to the right rule set:
|
|
29
|
+
|
|
30
|
+
1. **Dialog rendered off-center** — framer-motion was writing its own inline `transform` for `scale`, overwriting Tailwind `-translate-x-1/2 -translate-y-1/2` centering classes.
|
|
31
|
+
2. **`mixObject` crash on the Cybersource / Auth.Net checkout step** — saved-cards ↔ new-card-form toggle exposed two contributing causes: Dialog variants used multi-function `transform` strings, AND the `motion.div` toggling between forms had mismatched `initial` / `animate` / `exit` keys.
|
|
32
|
+
3. **`mixObject` crash on the Add Address modal** — Dialog had been "fixed" with primitives at this point, but the Badge cart-count component still animated `transform: 'scale(0/1)'` strings. Ancestor remeasure from the modal open triggered Badge's variants through the complex parser.
|
|
33
|
+
4. **Loader spinners crash similarly** — `CircularEasing` animated `transform: 'rotate(360deg)'` and `Pulse` animated `transform: ['scale(...)', ...]` array. Same complex-parser path.
|
|
34
|
+
5. **Toast position crash** — `Toast.tsx` animated `translateY: 'calc(-100% + Npx + Yrem)'` — a calc with mixed units (`%`, `px`, `rem`). The parser couldn't keep up across re-runs on layout shifts.
|
|
35
|
+
6. **Add Address modal *still* crashed** even with all the above fixed and Dialog variants on `x`/`y`/`scale` primitives. The final cause turned out to be the modal's own variants — `x: '-50%'` primitives still routed through the complex-value path under enough load. Once we dropped to opacity-only, the crash stopped.
|
|
36
|
+
|
|
37
|
+
The pattern is consistent: framer-motion 11.x's keyframe resolver gets confused when batched keyframe resolution sees any kind of non-trivial value (string with units, calc, multi-function transform, pointer-events, etc.). Opacity is reliably safe.
|
|
38
|
+
|
|
39
|
+
## Changes
|
|
40
|
+
|
|
41
|
+
| File | Change |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `libs/ui/src/components/Dialog/Dialog.tsx` | Default `animationVariants` now `{ opacity: 0 } ↔ { opacity: 1 }` — no scale, no x/y. Centering restored to static Tailwind classes (`-translate-x-1/2 -translate-y-1/2`). |
|
|
44
|
+
| `libs/ui/src/components/ModalOverlay/ModalOverlay.tsx` | Default variants reduced to opacity-only; `pointerEvents` removed from variants. Backdrop blur stays on the static CSS class (already was). |
|
|
45
|
+
| `libs/ui/src/components/Badge/Badge.tsx` | `animationVariants` switched from `transform: 'scale(0/1)'` strings to `scale: 0 / 1` primitives. |
|
|
46
|
+
| `libs/ui/src/components/Loader/CircularEasing.tsx` | Spinner `animate` switched from `transform: 'rotate(360deg)'` to `rotate: 360` primitive. |
|
|
47
|
+
| `libs/ui/src/components/Loader/Pulse.tsx` | Pulse keyframes switched from `transform: ['scale(1)', 'scale(0.1)', ...]` to `scale: [1, 0.1, ...]` numeric keyframes. |
|
|
48
|
+
| `apps/frontend/components/Toast/Toast.tsx` | Container `translateY` calc string replaced with a numeric pixel `y` value. Container-height and rem-to-pixel resolved in JS before handing to framer-motion. |
|
|
49
|
+
| `apps/frontend/components/Toast/Notification.tsx` | `initial` / `animate` / `exit` shapes aligned (all three include `opacity`, `x`, `y`). `translateX`/`translateY` aliases dropped in favor of `x`/`y` primitives. |
|
|
50
|
+
| `apps/frontend/components/Checkout/Step/Payment/Cybersource/CybersourcePaymentMethod.tsx` | The no-animate branch of `initial` previously returned `{ opacity: 1 }` without `x`. Both branches now include `x: 0`, and `exit` includes `x: 0`. |
|
|
51
|
+
| `apps/frontend/components/Checkout/Step/Payment/Authorizenet/AuthorizenetPaymentMethod.tsx` | Same shape-consistency fix as Cybersource. |
|
|
52
|
+
|
|
53
|
+
### Dialog variants — final state
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
// before — multi-function transform string; off-center; crashes under load
|
|
57
|
+
variants={
|
|
58
|
+
animationVariants || {
|
|
59
|
+
hidden: { transform: 'translate(-50%, -50%) scale(0.8)', opacity: 0 },
|
|
60
|
+
visible: { transform: 'translate(-50%, -50%) scale(1)', opacity: 1 },
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
// after — opacity only; centering moved to static Tailwind
|
|
67
|
+
className={cn(
|
|
68
|
+
'p-8 outline-0 max-w-max w-screen absolute top-2/4 left-2/4',
|
|
69
|
+
'-translate-x-1/2 -translate-y-1/2', // ← centering back in CSS
|
|
70
|
+
'shadow-... rounded-lg bg-white border ...',
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
// ...
|
|
74
|
+
variants={
|
|
75
|
+
animationVariants || {
|
|
76
|
+
hidden: { opacity: 0 },
|
|
77
|
+
visible: { opacity: 1 },
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### ModalOverlay variants — final state
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
// before
|
|
86
|
+
variants={
|
|
87
|
+
animationVariants || {
|
|
88
|
+
hidden: {
|
|
89
|
+
opacity: 0,
|
|
90
|
+
pointerEvents: 'none', // ← non-interpolatable; risky
|
|
91
|
+
transition: { delay: 0.08 },
|
|
92
|
+
},
|
|
93
|
+
visible: { opacity: 1, pointerEvents: 'auto' },
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
// after — opacity only; pointerEvents handled via static class on the overlay
|
|
100
|
+
variants={
|
|
101
|
+
animationVariants || {
|
|
102
|
+
hidden: { opacity: 0, transition: { delay: 0.08 } },
|
|
103
|
+
visible: { opacity: 1 },
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Badge variants — primitive swap
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
// before
|
|
112
|
+
const animationVariants = {
|
|
113
|
+
hidden: { opacity: 0, transform: 'scale(0)' },
|
|
114
|
+
show: { opacity: 1, transform: 'scale(1)' },
|
|
115
|
+
};
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
// after
|
|
120
|
+
const animationVariants = {
|
|
121
|
+
hidden: { opacity: 0, scale: 0 },
|
|
122
|
+
show: { opacity: 1, scale: 1 },
|
|
123
|
+
};
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Loader spinners — primitive swap
|
|
127
|
+
|
|
128
|
+
```tsx
|
|
129
|
+
// CircularEasing — before
|
|
130
|
+
<motion.svg animate={{ transform: 'rotate(360deg)', transition: {...} }} />
|
|
131
|
+
|
|
132
|
+
// CircularEasing — after
|
|
133
|
+
<motion.svg animate={{ rotate: 360, transition: {...} }} />
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
// Pulse — before
|
|
138
|
+
<motion.div animate={{
|
|
139
|
+
opacity: [1, 1, 0.7, 1, 1],
|
|
140
|
+
transform: ['scale(1)', 'scale(1)', 'scale(0.1)', 'scale(1)', 'scale(1)'],
|
|
141
|
+
transition: {...},
|
|
142
|
+
}} />
|
|
143
|
+
|
|
144
|
+
// Pulse — after
|
|
145
|
+
<motion.div animate={{
|
|
146
|
+
opacity: [1, 1, 0.7, 1, 1],
|
|
147
|
+
scale: [1, 1, 0.1, 1, 1],
|
|
148
|
+
transition: {...},
|
|
149
|
+
}} />
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Toast container — calc → numeric pixels
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
// before — calc with mixed units; complex parser stumbles on remeasure
|
|
156
|
+
const h = toastRef.current?.children?.item(y === 'top' ? notifications.length - 1 : 0)
|
|
157
|
+
?.getBoundingClientRect().height;
|
|
158
|
+
const translateY = isMobile
|
|
159
|
+
? { translateY: mobileY === 'top'
|
|
160
|
+
? `calc(-100% + ${h ?? 0}px + 1rem)`
|
|
161
|
+
: `calc(100% - ${h ?? 0}px - 1rem)` }
|
|
162
|
+
: { translateY: y === 'top'
|
|
163
|
+
? `calc(-100% + ${h ?? 0}px + 8rem)`
|
|
164
|
+
: `calc(100% - ${h ?? 0}px - 3.5rem)` };
|
|
165
|
+
toastControls.set(translateY);
|
|
166
|
+
toastControls.start(translateY);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
// after — resolve to a single number, hand framer-motion a primitive
|
|
171
|
+
const h = toastRef.current?.children?.item(y === 'top' ? notifications.length - 1 : 0)
|
|
172
|
+
?.getBoundingClientRect().height ?? 0;
|
|
173
|
+
const containerHeight =
|
|
174
|
+
toastRef.current?.getBoundingClientRect().height ?? 0;
|
|
175
|
+
const remPx =
|
|
176
|
+
typeof window !== 'undefined'
|
|
177
|
+
? parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
|
178
|
+
: 16;
|
|
179
|
+
const translateYValue = isMobile
|
|
180
|
+
? mobileY === 'top'
|
|
181
|
+
? -containerHeight + h + 1 * remPx
|
|
182
|
+
: containerHeight - h - 1 * remPx
|
|
183
|
+
: y === 'top'
|
|
184
|
+
? -containerHeight + h + 8 * remPx
|
|
185
|
+
: containerHeight - h - 3.5 * remPx;
|
|
186
|
+
const translateY = { y: translateYValue };
|
|
187
|
+
toastControls.set(translateY);
|
|
188
|
+
toastControls.start(translateY);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Notification — shape consistency across initial/animate/exit
|
|
192
|
+
|
|
193
|
+
```tsx
|
|
194
|
+
// before — translateY/translateX aliases, exit drops the transform keys entirely
|
|
195
|
+
const initial = isMobile
|
|
196
|
+
? { opacity: 0, translateY: '200px' }
|
|
197
|
+
: { opacity: 0, translateX: '200px' };
|
|
198
|
+
const animate = isMobile
|
|
199
|
+
? { opacity: 1, translateY: 0 }
|
|
200
|
+
: { opacity: 1, translateX: 0 };
|
|
201
|
+
// ...
|
|
202
|
+
<motion.div initial={initial} animate={animate} exit={{ opacity: 0 }}>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
// after — primitives, same keys in all three phases
|
|
207
|
+
const initial = isMobile
|
|
208
|
+
? { opacity: 0, x: 0, y: 200 }
|
|
209
|
+
: { opacity: 0, x: 200, y: 0 };
|
|
210
|
+
const animate = { opacity: 1, x: 0, y: 0 };
|
|
211
|
+
const exit = { opacity: 0, x: 0, y: 0 };
|
|
212
|
+
// ...
|
|
213
|
+
<motion.div initial={initial} animate={animate} exit={exit}>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### `initial` / `animate` / `exit` shape consistency in app code
|
|
217
|
+
|
|
218
|
+
Same fix applies to consumer-side `motion.div` usage. From `CybersourcePaymentMethod`:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
// before — shouldAnimate=false branch omits x, but animate sets x:0; exit drops x too
|
|
222
|
+
<motion.div
|
|
223
|
+
key="newCardForm"
|
|
224
|
+
initial={shouldAnimate ? { opacity: 0, x: 32 } : { opacity: 1 }}
|
|
225
|
+
animate={{ opacity: 1, x: 0 }}
|
|
226
|
+
exit={{ opacity: 0 }}
|
|
227
|
+
/>
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
// after — every key in animate is also in both initial branches and exit
|
|
232
|
+
<motion.div
|
|
233
|
+
key="newCardForm"
|
|
234
|
+
initial={shouldAnimate ? { opacity: 0, x: 32 } : { opacity: 1, x: 0 }}
|
|
235
|
+
animate={{ opacity: 1, x: 0 }}
|
|
236
|
+
exit={{ opacity: 0, x: 0 }}
|
|
237
|
+
/>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Consumer Impact
|
|
241
|
+
|
|
242
|
+
Five categories of code in consuming projects to audit:
|
|
243
|
+
|
|
244
|
+
| Pattern | Required change |
|
|
245
|
+
|---|---|
|
|
246
|
+
| Custom `animationVariants` on `Dialog` / `ModalOverlay` containing **any** non-opacity property (scale, x/y primitives, transform strings, pointer-events, etc.) | Strip to `{ opacity: 0/1 }` and move all other styling to static CSS classes. |
|
|
247
|
+
| Custom `animationVariants` on `Badge`, loaders, or other non-modal components using multi-function `transform` strings | Split into framer-motion primitives (`x`, `y`, `scale`, `rotate`). |
|
|
248
|
+
| Local `motion.div` / `motion.section` / etc. where one of `initial` / `animate` / `exit` includes a key the others don't | Add the missing key with a sensible base value (usually `0`) to the other branches. |
|
|
249
|
+
| `motion.*` variants animating `calc(...)` strings with mixed units, or other complex CSS-value strings | Resolve to numeric pixels in JS first; hand framer-motion a number. |
|
|
250
|
+
| `motion.*` variants animating `backdrop-filter`, `clip-path`, `filter`, gradients, `pointer-events`, or any WAAPI-incompatible property | Move the value to a static CSS class; only animate `opacity` (or a primitive) to fade them on/off. |
|
|
251
|
+
|
|
252
|
+
Search commands:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
# Multi-function transform strings in motion code
|
|
256
|
+
grep -rn "transform:.*translate\|transform:.*scale\|transform:.*rotate" apps/ libs/ \
|
|
257
|
+
| grep -v vendor | grep -v node_modules
|
|
258
|
+
|
|
259
|
+
# Possibly-mismatched initial/animate shapes — look for `initial=` lines that
|
|
260
|
+
# differ in length from neighboring animate/exit lines
|
|
261
|
+
grep -rnA2 "initial=" apps/ libs/ | grep -v vendor | grep -v node_modules
|
|
262
|
+
|
|
263
|
+
# Custom variants for @clicktap/ui Dialog/Modal — any non-opacity prop here is suspect
|
|
264
|
+
grep -rn "animationVariants" apps/ libs/ | grep -v vendor
|
|
265
|
+
|
|
266
|
+
# calc() strings inside motion code
|
|
267
|
+
grep -rn "calc(" apps/ libs/ | grep -v node_modules | grep -v vendor | grep -i 'motion\|controls\|animate\|translate'
|
|
268
|
+
|
|
269
|
+
# WAAPI-incompatible CSS properties in motion variants
|
|
270
|
+
grep -rn "backdropFilter\|backdrop-filter\|clipPath\|clip-path\|pointerEvents:\s*'" apps/ libs/ | grep -v vendor
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Pattern for Consumers
|
|
274
|
+
|
|
275
|
+
The five rules below cover every framer-motion 11.x crash pattern we found. Apply them to any `motion.*` element or component receiving `variants` / `animate`.
|
|
276
|
+
|
|
277
|
+
**Rule 1 — Modal entry/exit: opacity only.** For `Dialog`, `ModalOverlay`, `Drawer`, and any other modal-like component, the entry/exit animation should be a fade. Everything else (centering, slide direction, scale) lives in static CSS. The default `@clicktap/ui` variants now follow this.
|
|
278
|
+
|
|
279
|
+
**Rule 2 — Primitives over transform strings.** For non-modal `motion.*` elements, prefer framer-motion primitives over `transform: '...'` strings.
|
|
280
|
+
|
|
281
|
+
| Avoid | Use |
|
|
282
|
+
|---|---|
|
|
283
|
+
| `transform: 'translateX(-50%)'` | `x: '-50%'` |
|
|
284
|
+
| `transform: 'translateY(10px)'` | `y: 10` |
|
|
285
|
+
| `transform: 'scale(0.9)'` | `scale: 0.9` |
|
|
286
|
+
| `transform: 'rotate(45deg)'` | `rotate: 45` |
|
|
287
|
+
| `transform: 'translate(-50%, -50%) scale(0.8)'` | `x: '-50%', y: '-50%', scale: 0.8` |
|
|
288
|
+
| `translateY: '200px'` (alias) | `y: 200` |
|
|
289
|
+
|
|
290
|
+
**Rule 3 — Keyframe shapes must match.** If `animate` sets `{ opacity, x, scale }`, then `initial` and `exit` must each include all three keys. The framer-motion docs allow omission in theory; in practice the 11.x keyframe resolver stores nulls and crashes on remeasure.
|
|
291
|
+
|
|
292
|
+
```tsx
|
|
293
|
+
// REFERENCE — same shape across initial/animate/exit
|
|
294
|
+
<motion.div
|
|
295
|
+
initial={{ opacity: 0, x: 32 }}
|
|
296
|
+
animate={{ opacity: 1, x: 0 }}
|
|
297
|
+
exit={{ opacity: 0, x: 0 }}
|
|
298
|
+
/>
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
If you need a "no animation" branch, the skipped shape still has to include the same keys:
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
// REFERENCE — branched initial that still matches the target shape
|
|
305
|
+
<motion.div
|
|
306
|
+
initial={shouldAnimate ? { opacity: 0, x: 32 } : { opacity: 1, x: 0 }}
|
|
307
|
+
animate={{ opacity: 1, x: 0 }}
|
|
308
|
+
exit={{ opacity: 0, x: 0 }}
|
|
309
|
+
/>
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Rule 4 — Resolve unit math in JS, not in motion values.** Don't hand framer-motion calc strings with mixed units. Compute pixels in the consumer and pass a number.
|
|
313
|
+
|
|
314
|
+
```tsx
|
|
315
|
+
// AVOID
|
|
316
|
+
toastControls.start({ translateY: `calc(-100% + ${h}px + 8rem)` });
|
|
317
|
+
|
|
318
|
+
// USE
|
|
319
|
+
const remPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
320
|
+
const containerHeight = ref.current?.getBoundingClientRect().height ?? 0;
|
|
321
|
+
toastControls.start({ y: -containerHeight + h + 8 * remPx });
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
**Rule 5 — Un-interpolatable CSS goes on a static class.** `backdrop-filter`, complex `clip-path`, gradient `background`, `pointer-events`, etc. Don't put these in variants. Put them in a Tailwind class (or a `style` literal) and animate only `opacity` to fade them in/out. The `@clicktap/ui` `ModalOverlay` does this for `backdrop-blur-sm` — copy that pattern.
|
|
325
|
+
|
|
326
|
+
## Migration Steps
|
|
327
|
+
|
|
328
|
+
1. **Update `@clicktap/ui`** to a version that includes all of: Dialog opacity-only, ModalOverlay opacity-only, Badge primitive, Loader primitives (≥ this change).
|
|
329
|
+
|
|
330
|
+
2. **If your project overrides `animationVariants` on Dialog or ModalOverlay** with anything beyond opacity, simplify:
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
// before — risky: x/y primitives still route through the complex parser
|
|
334
|
+
// under enough ancestor-AnimatePresence load
|
|
335
|
+
<Dialog
|
|
336
|
+
animationVariants={{
|
|
337
|
+
hidden: { x: '-50%', y: '-50%', scale: 0.9, opacity: 0 },
|
|
338
|
+
visible: { x: '-50%', y: '-50%', scale: 1, opacity: 1 },
|
|
339
|
+
}}
|
|
340
|
+
/>
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
```tsx
|
|
344
|
+
// after — strip to opacity; centering moves to a wrapper className
|
|
345
|
+
<Dialog
|
|
346
|
+
animationVariants={{
|
|
347
|
+
hidden: { opacity: 0 },
|
|
348
|
+
visible: { opacity: 1 },
|
|
349
|
+
}}
|
|
350
|
+
className="-translate-x-1/2 -translate-y-1/2 ..."
|
|
351
|
+
/>
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
3. **Audit local `motion.*` usage in your app** for the five rules. For each `motion.div` (or `motion.section` etc.):
|
|
355
|
+
- Strip any non-opacity values out of the modal variants if it's a modal-shaped component.
|
|
356
|
+
- Replace `transform: '...'` strings with primitives.
|
|
357
|
+
- Match `initial` / `animate` / `exit` shapes — every key in `animate` must be in `initial` and `exit`.
|
|
358
|
+
- Resolve calc-with-units to pixel numbers in JS.
|
|
359
|
+
- Move backdrop-filter / pointer-events / clip-path / gradients to static CSS classes.
|
|
360
|
+
|
|
361
|
+
4. **Verify.**
|
|
362
|
+
- Run the dev server and open every modal flow your project supports (cart drawer, address book Add/Edit, checkout payment-method toggle, custom dialogs). No console errors referencing `mixObject` or `complex.mjs`.
|
|
363
|
+
- Open and close drawers/modals rapidly while a toast is mid-animation. No console errors.
|
|
364
|
+
- Visit a page that renders the cart-count Badge while opening a modal. The Badge animation should play smoothly with no console error.
|
|
365
|
+
- Run typecheck:
|
|
366
|
+
```bash
|
|
367
|
+
docker compose exec -u app node-app sh -c 'cd /app && npx nx run frontend:typecheck'
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Rollback
|
|
371
|
+
|
|
372
|
+
Reverting any of the framer-motion 11 changes is safe — they're behavioral fixes with no schema or data implications. Down-migration: re-introduce the transform strings or trimmed `initial` shapes in the affected files. Note that the crash returns once you do — the framer-motion 11.x parser hasn't changed.
|
|
373
|
+
|
|
374
|
+
## Files Changed
|
|
375
|
+
|
|
376
|
+
```
|
|
377
|
+
libs/ui/src/components/Dialog/Dialog.tsx
|
|
378
|
+
libs/ui/src/components/ModalOverlay/ModalOverlay.tsx
|
|
379
|
+
libs/ui/src/components/Badge/Badge.tsx
|
|
380
|
+
libs/ui/src/components/Loader/CircularEasing.tsx
|
|
381
|
+
libs/ui/src/components/Loader/Pulse.tsx
|
|
382
|
+
apps/frontend/components/Toast/Toast.tsx
|
|
383
|
+
apps/frontend/components/Toast/Notification.tsx
|
|
384
|
+
apps/frontend/components/Checkout/Step/Payment/Cybersource/CybersourcePaymentMethod.tsx
|
|
385
|
+
apps/frontend/components/Checkout/Step/Payment/Authorizenet/AuthorizenetPaymentMethod.tsx
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## References
|
|
389
|
+
|
|
390
|
+
- framer-motion `mixObject` source: `node_modules/framer-motion/dist/es/utils/mix/complex.mjs:48` — `if (a[key] !== undefined && b[key] !== undefined)` dereferences a key on `null` because `typeof null === 'object'` made `getMixer(null)` return `mixObject`.
|
|
391
|
+
- The framer-motion 11.x keyframe resolver is the upstream root cause; tracked in framer-motion's own issue tracker. 12.x rewrites this path. Adopt the rules above as long-term convention regardless of upgrade timing.
|