Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions web/src/components/PieceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -442,8 +442,26 @@ const PieceList = (props: PieceListProps) => {
}, [onLoadMore]);
const sentinelRef = useRef<HTMLDivElement>(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<HTMLElement | null>(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;
Expand All @@ -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<string, TagEntry>();
Expand Down Expand Up @@ -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<HTMLElement | null>(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,
Expand Down
36 changes: 36 additions & 0 deletions web/src/components/__tests__/PieceList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<PieceList pieces={firstPage} onLoadMore={onLoadMore} hasMore />
),
},
],
{ initialEntries: ["/"] },
);
render(<RouterProvider router={router} />);
await new Promise<void>((resolve) => setTimeout(resolve, 50));
expect(onLoadMore).not.toHaveBeenCalled();
});
});
});
Loading