@clicktap/ui 2.1.2 → 2.1.4

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.
@@ -19,5 +19,5 @@ import { ModalOverlayProps } from './ModalOverlay.types';
19
19
  * Exported for unit tests only.
20
20
  */
21
21
  export declare function useOverlayAnimationState(overlayState: OverlayTriggerState | null | undefined): [DriverAnimationState, Dispatch<SetStateAction<DriverAnimationState>>];
22
- export declare function ModalOverlay(props: ModalOverlayProps): import("react/jsx-runtime").JSX.Element;
22
+ export declare function ModalOverlay(props: ModalOverlayProps): import("react/jsx-runtime").JSX.Element | null;
23
23
  export default ModalOverlay;
@@ -1 +1 @@
1
- "use client";import{jsx as a}from"react/jsx-runtime";import{forwardRef as N,useContext as M,useState as k,useEffect as A,useId as R,useCallback as O}from"react";import{ModalOverlay as E,OverlayTriggerStateContext as w}from"react-aria-components";import{motion as T,AnimatePresence as F}from"framer-motion";import{cn as j}from"../../utils/cn.js";const q="body > [inert]:not(#__next):not([data-overlay-container])";function z(e){return Object.keys(e).some(t=>t.startsWith("__react"))}function p(){document.querySelectorAll(q).forEach(e=>e.removeAttribute("inert"))}function L(e){return!!(e.closest("[data-overlay-container]")||e.closest("#__next"))}const W=N(({style:e,children:t,...i},n)=>{const r=typeof e=="function"?e(i):e;return a(E,{...i,ref:n,style:r,children:t})});let m=null;function $(){return typeof window>"u"?null:(m||(m=T.create(W)),m)}const h=$();function g({animate:e,animation:t,setAnimation:i,className:n,animationVariants:r,isDismissable:u=!0,children:v,...x}){const C=R();A(()=>{p();const o=new MutationObserver(l=>{for(const d of l)if(d.type==="attributes"&&d.attributeName==="inert"){const f=d.target;f.parentElement===document.body?p():z(f)||f.removeAttribute("inert")}});return o.observe(document.body,{attributes:!0,attributeFilter:["inert"],subtree:!0}),()=>o.disconnect()},[]);const{key:S,shouldCloseOnInteractOutside:c,onOpenChange:s,..._}=x,P=O(o=>c?c(o):L(o),[c]),y=M(w),I=O(o=>{!u||o.target!==o.currentTarget||(s?s(!1):y?.close())},[u,s,y]),b={isDismissable:u,shouldCloseOnInteractOutside:P,onOpenChange:s,onPointerDown:I,className:j("bg-black/30 backdrop-blur-sm","fixed top-0 left-0","z-[1000]","w-screen h-[var(--visual-viewport-height)]",n),..._};return h?a(h,{isExiting:t==="hidden",onAnimationComplete:o=>{i(l=>o==="hidden"&&l==="hidden"?"unmounted":l)},variants:r||{hidden:{opacity:0,transition:{delay:.08}},visible:{opacity:1}},initial:"hidden",animate:e,exit:"hidden",...b,children:v},S||C):a(E,{...b,children:v})}function B(e){const t=e?.isOpen??!1,[i,n]=k(t?"visible":"unmounted");return A(()=>{n(t?"visible":r=>r==="visible"?"hidden":r)},[t]),[i,n]}function Q(e){const{isOpen:t}=e,i=M(w),[n,r]=B(i);return t!==void 0?a(F,{children:t&&a(g,{...e,animate:"visible",animation:n,setAnimation:r})}):a(g,{...e,animate:n,animation:n,setAnimation:r})}export{Q as ModalOverlay,Q as default,B as useOverlayAnimationState};
1
+ "use client";import{jsx as a}from"react/jsx-runtime";import{forwardRef as N,useContext as M,useState as k,useEffect as A,useId as R,useCallback as O}from"react";import{ModalOverlay as E,OverlayTriggerStateContext as w}from"react-aria-components";import{motion as T,AnimatePresence as F}from"framer-motion";import{cn as j}from"../../utils/cn.js";const q="body > [inert]:not(#__next):not([data-overlay-container])";function z(e){return Object.keys(e).some(t=>t.startsWith("__react"))}function p(){document.querySelectorAll(q).forEach(e=>e.removeAttribute("inert"))}function L(e){return!!(e.closest("[data-overlay-container]")||e.closest("#__next"))}const W=N(({style:e,children:t,...i},n)=>{const r=typeof e=="function"?e(i):e;return a(E,{...i,ref:n,style:r,children:t})});let m=null;function $(){return typeof window>"u"?null:(m||(m=T.create(W)),m)}const h=$();function g({animate:e,animation:t,setAnimation:i,className:n,animationVariants:r,isDismissable:u=!0,children:v,...x}){const C=R();A(()=>{p();const o=new MutationObserver(l=>{for(const d of l)if(d.type==="attributes"&&d.attributeName==="inert"){const f=d.target;f.parentElement===document.body?p():z(f)||f.removeAttribute("inert")}});return o.observe(document.body,{attributes:!0,attributeFilter:["inert"],subtree:!0}),()=>o.disconnect()},[]);const{key:S,shouldCloseOnInteractOutside:c,onOpenChange:s,..._}=x,P=O(o=>c?c(o):L(o),[c]),y=M(w),I=O(o=>{!u||o.target!==o.currentTarget||(s?s(!1):y?.close())},[u,s,y]),b={isDismissable:u,shouldCloseOnInteractOutside:P,onOpenChange:s,onPointerDown:I,className:j("bg-black/30 backdrop-blur-sm","fixed top-0 left-0","z-[1000]","w-screen h-[var(--visual-viewport-height)]",n),..._};return h?a(h,{isExiting:t==="hidden",onAnimationComplete:o=>{i(l=>o==="hidden"&&l==="hidden"?"unmounted":l)},variants:r||{hidden:{opacity:0,transition:{delay:.08}},visible:{opacity:1}},initial:"hidden",animate:e,exit:"hidden",...b,children:v},S||C):a(E,{...b,children:v})}function B(e){const t=e?.isOpen??!1,[i,n]=k(t?"visible":"unmounted");return A(()=>{n(t?"visible":r=>r==="visible"?"hidden":r)},[t]),[i,n]}function Q(e){const{isOpen:t}=e,i=M(w),[n,r]=B(i);return t!==void 0?a(F,{children:t&&a(g,{...e,animate:"visible",animation:n,setAnimation:r})}):n==="unmounted"?null:a(g,{...e,animate:n,animation:n,setAnimation:r})}export{Q as ModalOverlay,Q as default,B as useOverlayAnimationState};
@@ -0,0 +1,71 @@
1
+ # BUGFIX: a closed trigger-driven `ModalOverlay` no longer keeps a detached framer-motion element mounted
2
+
3
+ **Date:** 2026-09-16
4
+ **Package:** `@clicktap/ui`
5
+ **Affects:** `src/components/ModalOverlay/ModalOverlay.tsx` — every trigger-driven `<ModalOverlay>` (no `isOpen` prop, state from `DialogTrigger` / `OverlayTriggerStateContext`). Controlled overlays (`isOpen` given) are unchanged.
6
+ **Classification:** BUGFIX
7
+ **Breaking:** No API change. **Behavioral:** while its trigger is closed and has never opened, the overlay now renders `null` instead of a mounted `motion()` wrapper around an empty react-aria overlay. Nothing consumer-visible was in the DOM before either.
8
+ **Ticket:** CTAP-1729
9
+
10
+ ## Problem
11
+
12
+ `ModalOverlay` has two render paths. The controlled one wraps `InnerModalOverlay` in
13
+ `AnimatePresence` and mounts it only while `isOpen`. The **trigger-driven** one mounted
14
+ `InnerModalOverlay` **unconditionally** — including while `useOverlayAnimationState` reported
15
+ `unmounted`. In that state react-aria's `ModalOverlay` renders nothing (closed, not exiting), but
16
+ our `motion.create(...)` wrapper was still mounted around it: a framer-motion `VisualElement` with
17
+ `element.current === null`, holding `initial="hidden"`, `exit="hidden"` and `animate="unmounted"`
18
+ (which names no variant), for as long as the trigger stayed shut.
19
+
20
+ Any animation that reached that element crashed framer-motion 11.18.2 with
21
+ `TypeError: Cannot read properties of null (reading '0')`: the animation seeds `[null, target]`
22
+ and expects the DOM read to hydrate the first keyframe; `DOMKeyframesResolver` bails on a missing
23
+ element, so `null` survives into `mix(null, 0)` → `mixObject(null, '0')`. Two routes in, both
24
+ observed:
25
+
26
+ 1. **An ancestor `AnimatePresence` exits** and propagates `exit="hidden"` down the subtree.
27
+ 2. **The subtree unmounts** — `VisualElement.unmount` stops the motion values, which flushes
28
+ keyframe resolvers synchronously on the detached element.
29
+
30
+ 🚨 Thrown inside framer-motion's frame loop, so nothing reaches server logs and no React error
31
+ boundary catches it. noco hit it in the warranty admin: a controlled drawer (route 1) containing a
32
+ never-opened trigger-driven Page Builder overlay threw **13 errors on every close**, and in
33
+ production that broke the frame loop — the close animation and the success toast were both lost.
34
+ Every other trigger-driven overlay in that app (delete confirms, edit modals) carried the same
35
+ detached element and only stayed quiet because nothing above them animated on exit yet.
36
+
37
+ ## What changed
38
+
39
+ The trigger-driven path returns `null` while `animation === 'unmounted'` — exactly the DOM
40
+ react-aria was already producing, minus the motion element. The lifecycle the controlled path
41
+ already gets from `AnimatePresence` is now the same on both paths:
42
+
43
+ | trigger | `animation` | rendered |
44
+ |---|---|---|
45
+ | closed, never opened | `unmounted` | `null` |
46
+ | opens | `visible` | `InnerModalOverlay` mounts with `initial="hidden"` → `animate="visible"` (entrance plays) |
47
+ | closes while visible | `hidden` | stays mounted; `isExiting` keeps react-aria's portal alive while the exit plays |
48
+ | exit completes | `unmounted` (via `onAnimationComplete`) | `null` again |
49
+
50
+ ## Consumer impact
51
+
52
+ - **Nothing to change.** Take the release. If you gated the overlay yourself in a wrapper (noco
53
+ tried it in `@noco/ui`), **remove that gate**: mounting only once already open, with the hook
54
+ initialized to `visible`, gives framer-motion no transition to play and leaves the backdrop stuck
55
+ at `opacity: 0`. The fix has to live where the animation state is derived.
56
+ - ⚠️ Do not read the mount as a signal. Nothing should depend on a closed trigger-driven overlay
57
+ having a React subtree — refs into `InnerModalOverlay` are `null` while closed now, as they
58
+ effectively were before (react-aria rendered no node).
59
+
60
+ ## Verification
61
+
62
+ Unit: `ModalOverlay.mount.spec.tsx` mocks `motion.create` to record every render of the wrapper and
63
+ pins the contract — no motion render while closed and never opened; first render on open is
64
+ `initial: 'hidden', animate: 'visible'`; on close the last render is `animate: 'hidden', isExiting:
65
+ true`; after `onAnimationComplete('hidden')` no further renders, including on unrelated re-renders.
66
+ Controlled path covered for parity.
67
+
68
+ Manual, in any app: open a controlled `ModalOverlay` (a drawer) that contains a `DialogTrigger`
69
+ whose `ModalOverlay` you never open; close the drawer. Before: `Cannot read properties of null
70
+ (reading '0')` in the console, one per animated value. After: nothing. Then open the inner overlay
71
+ once and close both — entrance and exit both play.
@@ -1,4 +1,14 @@
1
1
  [
2
+ {
3
+ "path": "2026/09/ui-2026-09-16-modal-overlay-detached-motion-element.md",
4
+ "module": "libs/frontend-modules/ui",
5
+ "package": "@clicktap/ui",
6
+ "date": "2026-09-16",
7
+ "title": "BUGFIX: a closed trigger-driven `ModalOverlay` no longer keeps a detached framer-motion element mounted",
8
+ "classification": "BUGFIX",
9
+ "breaking": "No API change. **Behavioral:** while its trigger is closed and has never opened, the overlay now renders `null` instead of a mounted `motion()` wrapper around an empty react-aria overlay. Nothing consumer-visible was in the DOM before either.",
10
+ "affects": "`src/components/ModalOverlay/ModalOverlay.tsx` — every trigger-driven `<ModalOverlay>` (no `isOpen` prop, state from `DialogTrigger` / `OverlayTriggerStateContext`). Controlled overlays (`isOpen` given) are unchanged."
11
+ },
2
12
  {
3
13
  "path": "2026/09/ui-2026-09-09-graphql-field-errors-reported-in-production.md",
4
14
  "module": "libs/frontend-modules/ui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clicktap/ui",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
4
  "private": false,
5
5
  "author": "Clicktap",
6
6
  "description": "A library of React UI components and low-level hooks.",