diff --git a/proof-of-work/components/workouts/WorkoutForm.tsx b/proof-of-work/components/workouts/WorkoutForm.tsx index 8336618..b96ba60 100644 --- a/proof-of-work/components/workouts/WorkoutForm.tsx +++ b/proof-of-work/components/workouts/WorkoutForm.tsx @@ -58,44 +58,61 @@ function ExerciseHistoryPopup({ }; }, [exerciseId]); - // Infinite scroll — observe a sentinel below the rendered list. The - // root is the popup's scroll container (the popup itself), not the - // viewport, since the user scrolls inside the popup. + // v1.1.0:7 — Infinite scroll via a plain scroll listener on the + // popup itself. The previous IntersectionObserver implementation was + // unreliable inside an absolute-positioned scroll container (the + // popup is `position: absolute` + `overflow-y-auto`, which some + // browsers don't observe consistently when the root is the same + // element). A `scroll` event on the popup is rock-solid. + // + // Fires whenever the user scrolls within ~300px of the popup's + // bottom edge, mirroring the rootMargin used by the workouts-list + // infinite-scroll on the main page. + // + // Also runs once on first render after history loads — important + // because if the user has 100+ history entries and the first page + // doesn't fill the popup OR the user opens the popup and immediately + // sees content without scrolling, we still want to fetch ahead. useEffect(() => { - if (loading || !hasMore || !sentinelRef.current || !popupRef.current) { - return; - } - const sentinel = sentinelRef.current; - const root = popupRef.current; - const observer = new IntersectionObserver( - (entries) => { - if (!entries[0]?.isIntersecting) return; - if (loadingMore || !hasMore) return; - setLoadingMore(true); - (async () => { - try { - const res = await fetch( - `/api/exercises/${exerciseId}?offset=${history.length}&limit=${HISTORY_PAGE_SIZE}`, - ); - if (res.ok) { - const data = await res.json(); - setHistory((prev) => [...prev, ...(data.history || [])]); - setHasMore(!!data.hasMore); - } else { - setError(`Failed to load more (${res.status})`); - setHasMore(false); - } - } catch { - setError("Failed to load more"); - setHasMore(false); - } - setLoadingMore(false); - })(); - }, - { root, rootMargin: "60px" }, - ); - observer.observe(sentinel); - return () => observer.disconnect(); + if (loading || !hasMore || loadingMore || !popupRef.current) return; + const el = popupRef.current; + + const loadMore = async () => { + if (loadingMore || !hasMore) return; + setLoadingMore(true); + try { + const res = await fetch( + `/api/exercises/${exerciseId}?offset=${history.length}&limit=${HISTORY_PAGE_SIZE}`, + ); + if (res.ok) { + const data = await res.json(); + setHistory((prev) => [...prev, ...(data.history || [])]); + setHasMore(!!data.hasMore); + } else { + setError(`Failed to load more (${res.status})`); + setHasMore(false); + } + } catch { + setError("Failed to load more"); + setHasMore(false); + } + setLoadingMore(false); + }; + + const maybeLoad = () => { + const { scrollTop, scrollHeight, clientHeight } = el; + // 300px lookahead — match WorkoutsList's rootMargin behavior. + if (scrollHeight - scrollTop - clientHeight < 300) { + loadMore(); + } + }; + + el.addEventListener("scroll", maybeLoad, { passive: true }); + // Initial check: if the loaded page doesn't fill the popup, we + // still want to fetch the next page so the user doesn't have to + // scroll a near-empty container before more arrives. + maybeLoad(); + return () => el.removeEventListener("scroll", maybeLoad); }, [loading, hasMore, loadingMore, history.length, exerciseId]); // Close on outside click @@ -167,10 +184,21 @@ function ExerciseHistoryPopup({ ); })} - {/* Sentinel + status row at the bottom of the list */} + {/* Status row at the bottom of the list. The sentinel ref + is no longer the load trigger (we use a scroll listener + on the popup itself in v1.1.0:7), but the visual marker + still tells the user whether more is loading or done. */}