Skip to content

Commit fb7554b

Browse files
committed
Address Cursor review
Bug: IntersectionObserver not set up due to ref timing The useEffect that sets up the IntersectionObserver has [elementRef] as its dependency, but ref objects maintain a stable identity - only elementRef.current changes after the DOM renders. When the component first mounts, elementRef.current is null, causing the effect to set isInViewport(true) and return without creating the observer. Since elementRef (the object itself) never changes, the effect won't re-run after the DOM assigns ref.current. This defeats the viewport detection feature, causing animations to always start immediately instead of waiting for the element to be visible.
1 parent ae0af36 commit fb7554b

File tree

1 file changed

+77
-27
lines changed

1 file changed

+77
-27
lines changed

special-pages/pages/new-tab/app/protections/utils/useAnimatedCount.js

Lines changed: 77 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export function useAnimatedCount(targetValue, elementRef) {
2525
const lastSeenValueRef = useRef(/** @type {number | null} */ (null));
2626
const wasInViewportRef = useRef(false);
2727

28+
// Track if observer has been set up to avoid duplicate setup
29+
const observerSetupRef = useRef(false);
30+
// Store observer instance for cleanup
31+
const observerRef = useRef(/** @type {IntersectionObserver | null} */ (null));
32+
2833
// Memoize the update callback to avoid recreating it on every render
2934
const updateAnimatedCount = useCallback(
3035
/** @type {import('./animateCount.js').AnimationUpdateCallback} */ (
@@ -36,43 +41,88 @@ export function useAnimatedCount(targetValue, elementRef) {
3641
[],
3742
);
3843

44+
// Helper function to create and set up IntersectionObserver
45+
const setupIntersectionObserver = useCallback(
46+
(element) => {
47+
// Prevent duplicate setup
48+
if (observerSetupRef.current || observerRef.current) {
49+
return;
50+
}
51+
52+
observerSetupRef.current = true;
53+
observerRef.current = new IntersectionObserver(
54+
(entries) => {
55+
entries.forEach((entry) => {
56+
const wasInViewport = wasInViewportRef.current;
57+
const isNowInViewport = entry.isIntersecting;
58+
59+
// When element exits viewport, save current displayed value
60+
if (wasInViewport && !isNowInViewport) {
61+
lastSeenValueRef.current = animatedValueRef.current;
62+
}
63+
64+
wasInViewportRef.current = isNowInViewport;
65+
setIsInViewport(isNowInViewport);
66+
});
67+
},
68+
{
69+
// Trigger when any part of the element is visible
70+
threshold: 0,
71+
// Optional: add some margin to trigger slightly before visible
72+
rootMargin: '0px',
73+
},
74+
);
75+
76+
observerRef.current.observe(element);
77+
},
78+
[],
79+
);
80+
3981
// Setup IntersectionObserver for viewport detection
82+
// Effect runs once on mount and checks elementRef.current
83+
// Since refs are assigned synchronously during commit, elementRef.current should be available
84+
// But we handle the edge case where it might not be yet using a microtask
85+
// Note: elementRef is not in dependencies because ref objects maintain stable identity
4086
useEffect(() => {
4187
// If no elementRef provided, element is always considered "in viewport"
42-
if (!elementRef || !elementRef.current) {
88+
if (!elementRef) {
4389
setIsInViewport(true);
4490
return;
4591
}
4692

47-
const observer = new IntersectionObserver(
48-
(entries) => {
49-
entries.forEach((entry) => {
50-
const wasInViewport = wasInViewportRef.current;
51-
const isNowInViewport = entry.isIntersecting;
52-
53-
// When element exits viewport, save current displayed value
54-
if (wasInViewport && !isNowInViewport) {
55-
lastSeenValueRef.current = animatedValueRef.current;
56-
}
57-
58-
wasInViewportRef.current = isNowInViewport;
59-
setIsInViewport(isNowInViewport);
60-
});
61-
},
62-
{
63-
// Trigger when any part of the element is visible
64-
threshold: 0,
65-
// Optional: add some margin to trigger slightly before visible
66-
rootMargin: '0px',
67-
},
68-
);
69-
70-
observer.observe(elementRef.current);
93+
// Check if element is available immediately
94+
const element = elementRef.current;
95+
let cancelled = false;
96+
97+
if (element) {
98+
// Element is available, set up observer immediately
99+
setupIntersectionObserver(element);
100+
} else {
101+
// If element not available, use a microtask to check again
102+
// This handles edge cases where ref assignment is delayed
103+
Promise.resolve().then(() => {
104+
// Check if effect was cleaned up before microtask executed
105+
if (cancelled) {
106+
return;
107+
}
108+
109+
const delayedElement = elementRef.current;
110+
if (delayedElement) {
111+
setupIntersectionObserver(delayedElement);
112+
}
113+
});
114+
}
71115

72116
return () => {
73-
observer.disconnect();
117+
cancelled = true;
118+
observerSetupRef.current = false;
119+
if (observerRef.current) {
120+
observerRef.current.disconnect();
121+
observerRef.current = null;
122+
}
74123
};
75-
}, [elementRef]);
124+
// eslint-disable-next-line react-hooks/exhaustive-deps
125+
}, []); // Empty array: run once on mount. elementRef is stable, setupIntersectionObserver is memoized
76126

77127
// Animate count when it changes, page is visible, and element is in viewport
78128
useEffect(() => {

0 commit comments

Comments
 (0)