diff --git a/web/src/components/PieceList.tsx b/web/src/components/PieceList.tsx index f9f55167..8e0c058d 100644 --- a/web/src/components/PieceList.tsx +++ b/web/src/components/PieceList.tsx @@ -442,8 +442,26 @@ const PieceList = (props: PieceListProps) => { }, [onLoadMore]); const sentinelRef = useRef(null); + // Declare masonry geometry here so masonryWidth is in scope for the sentinel + // effect below. The positioner useMemo further down still consumes these same + // values — nothing else in the component changes. + const windowHeight = useWindowHeight(); + const masonryRef = useRef(null); + const columnWidth = isMobile + ? MASONRY_COLUMN_WIDTH_MOBILE + : MASONRY_COLUMN_WIDTH_DESKTOP; + const { width: masonryWidth, offset: masonryOffset } = useContainerPosition( + masonryRef, + [isMobile], + ); + useEffect(() => { - if (!hasMore) return; + // When masonryWidth is 0 the ResizeObserver hasn't fired yet: the masonry + // grid hasn't rendered so the sentinel sits at top≈0. Calling check() at + // that point fires onLoadMore before any cards are visible, triggering an + // immediate second-page fetch and the resulting flash. Defer until the + // container has a real width so the sentinel is at its true position. + if (!hasMore || masonryWidth === 0) return; function check() { const sentinel = sentinelRef.current; if (!sentinel) return; @@ -453,7 +471,7 @@ const PieceList = (props: PieceListProps) => { window.addEventListener("scroll", check, { passive: true }); check(); return () => window.removeEventListener("scroll", check); - }, [hasMore]); + }, [hasMore, masonryWidth]); const availableTags = useMemo(() => { const deduped = new Map(); @@ -505,15 +523,6 @@ const PieceList = (props: PieceListProps) => { }, [activeFilters, activeTagIds, activeTags]); const hasActiveFilters = activeFilters.length > 0 || activeTagIds.length > 0; - const windowHeight = useWindowHeight(); - const masonryRef = useRef(null); - const columnWidth = isMobile - ? MASONRY_COLUMN_WIDTH_MOBILE - : MASONRY_COLUMN_WIDTH_DESKTOP; - const { width: masonryWidth, offset: masonryOffset } = useContainerPosition( - masonryRef, - [isMobile], - ); const positioner = useMemo(() => { const [computedColumnWidth, computedColumnCount] = getMasonryColumns( masonryWidth, diff --git a/web/src/components/__tests__/PieceList.test.tsx b/web/src/components/__tests__/PieceList.test.tsx index eb85ffa3..8f30bf28 100644 --- a/web/src/components/__tests__/PieceList.test.tsx +++ b/web/src/components/__tests__/PieceList.test.tsx @@ -1209,4 +1209,40 @@ describe("PieceList", () => { ).toContain("background-color: transparent"); }); }); + + describe("scroll sentinel", () => { + it("does not call onLoadMore while the masonry container width is unmeasured", async () => { + // Regression for #734: with the pre-fix code, the sentinel effect fires + // check() immediately on mount regardless of masonryWidth. At that moment + // masonryWidth=0 (ResizeObserver hasn't fired), the sentinel element sits + // at top≈0 in the unmeasured document, and check() calls onLoadMore before + // any cards are visible — fetching page 2 prematurely and causing the flash. + // + // The fix adds masonryWidth to the effect's deps and guards with + // `if (masonryWidth === 0) return`, so the effect is a no-op until the + // container has been measured. + // + // We let the event loop run (setTimeout) so React's MessageChannel-based + // scheduler has time to fire the mount effect before we assert. + mockContainerPosition.width = 0; + const onLoadMore = vi.fn(); + const firstPage = Array.from({ length: 16 }, (_, i) => + makePiece({ id: `piece-${i}` }), + ); + const router = createMemoryRouter( + [ + { + path: "/", + element: ( + + ), + }, + ], + { initialEntries: ["/"] }, + ); + render(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(onLoadMore).not.toHaveBeenCalled(); + }); + }); });