@codecademy/gamut

68.3.068.3.1-alpha.0b3b4f.0
dist/PopoverContainer/hooks.js
~dist/PopoverContainer/hooks.jsModified
+50−27
Index: package/dist/PopoverContainer/hooks.js
===================================================================
--- package/dist/PopoverContainer/hooks.js
+++ package/dist/PopoverContainer/hooks.js
@@ -1,19 +1,49 @@
-import { useEffect, useMemo } from 'react';
+import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
+import { isNullish } from '../utils/nullish';
 import { findAllAdditionalScrollingParents, findResizingParent } from './utils';
+
+/**
+ * Minimal element shape required for popover positioning.
+ * Accepts both HTMLElement and TargetRef so Popover and PopoverContainer can share hooks.
+ */
+
+/** Resolves ref object to current element; returns null when unset. */
+export function getRefElement(ref) {
+  if (isNullish(ref)) return null;
+  return ref.current;
+}
+
+/** Casts minimal target to HTMLElement for utils that need full DOM (e.g. parentElement). */
+export function getTargetAsElement(target) {
+  return target;
+}
+
+/**
+ * Syncs ref.current to React state after each commit so hooks can depend on the
+ * resolved element when ref object identity is stable but .current updates.
+ */
+function useResolvedRefTarget(targetRef) {
+  const [resolved, setResolved] = useState(() => getRefElement(targetRef));
+
+  // ref.current updates do not change targetRef identity; run after every commit
+  // to sync. Functional setState bails out when the resolved node is unchanged.
+  // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional post-commit ref sync
+  useLayoutEffect(() => {
+    const el = getRefElement(targetRef);
+    setResolved(prev => prev === el ? prev : el);
+  });
+  return resolved;
+}
 export const useScrollingParentsEffect = (targetRef, setTargetRect) => {
+  const resolvedTarget = useResolvedRefTarget(targetRef);
   useEffect(() => {
-    if (!targetRef.current) {
-      return;
-    }
-    const target = targetRef.current;
-    const scrollingParents = findAllAdditionalScrollingParents(target);
+    if (!resolvedTarget) return;
+    const scrollingParents = findAllAdditionalScrollingParents(getTargetAsElement(resolvedTarget));
     const updatePosition = () => {
-      setTargetRect(targetRef?.current?.getBoundingClientRect());
+      setTargetRect(resolvedTarget.getBoundingClientRect());
     };
     const cleanup = [];
-
-    // Add listeners to all scrolling parents (window scroll handled by useWindowScroll)
     scrollingParents.forEach(parent => {
       if (parent.addEventListener) {
         parent.addEventListener('scroll', updatePosition, {
           passive: true
@@ -23,39 +53,32 @@
     });
     return () => {
       cleanup.forEach(fn => fn());
     };
-  }, [targetRef, setTargetRect]);
+  }, [resolvedTarget, setTargetRect]);
 };
 export const useResizingParentEffect = (targetRef, setTargetRect) => {
+  const resolvedTarget = useResolvedRefTarget(targetRef);
   useEffect(() => {
-    // handles movement of target within a clipped container e.g. Drawer
-    if (!targetRef.current || typeof ResizeObserver === 'undefined') {
-      return;
-    }
-    const resizingParent = findResizingParent(targetRef.current);
-    if (!resizingParent?.addEventListener) {
-      return;
-    }
+    if (!resolvedTarget || typeof ResizeObserver === 'undefined') return;
+    const resizingParent = findResizingParent(getTargetAsElement(resolvedTarget));
+    if (!resizingParent?.addEventListener) return;
     const handler = () => {
-      setTargetRect(targetRef?.current?.getBoundingClientRect());
+      setTargetRect(resolvedTarget.getBoundingClientRect());
     };
     const ro = new ResizeObserver(handler);
     ro.observe(resizingParent);
     return () => ro.unobserve(resizingParent);
-  }, [targetRef, setTargetRect]);
+  }, [resolvedTarget, setTargetRect]);
 };
 
 /**
  * Memoizes the list of scrolling parent elements for a target element.
- * This avoids expensive DOM traversals and getComputedStyle calls on every render.
  * Returns an empty array if the target element is not available.
  */
 export const useScrollingParents = targetRef => {
+  const resolvedTarget = useResolvedRefTarget(targetRef);
   return useMemo(() => {
-    if (!targetRef.current) {
-      return [];
-    }
-    return findAllAdditionalScrollingParents(targetRef.current);
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [targetRef.current]);
+    if (!resolvedTarget) return [];
+    return findAllAdditionalScrollingParents(getTargetAsElement(resolvedTarget));
+  }, [resolvedTarget]);
 };
\ No newline at end of file