From 4141a515c43ff8bfb7021e98c65c0e097bdf13fb Mon Sep 17 00:00:00 2001 From: Phil Shao Date: Thu, 28 May 2026 17:26:53 -0400 Subject: [PATCH 1/3] fix: eliminate PieceList flash on pagination (#734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: the scroll sentinel effect called check() immediately when hasMore became true, but masonryWidth=0 at that point so the sentinel was at top≈0 and onLoadMore fired before any cards were visible. Fix: add masonryWidth to the effect deps and return early when masonryWidth === 0. Bug 2: the positioner useMemo depended on filteredPieces, so every pagination append called createPositioner from scratch and MasonryScroller re-laid out all items from zero — the flash. Fix: switch to usePositioner with sort/filter keys as deps so the positioner is reused on append and only reset on sort or filter changes. Crop heights are seeded inline with a get(index) === undefined guard so existing positions survive. Co-Authored-By: Claude Sonnet 4.6 --- web/src/components/PieceList.tsx | 103 +++++++----------- .../components/__tests__/PieceList.test.tsx | 96 +++++++++++++++- 2 files changed, 135 insertions(+), 64 deletions(-) diff --git a/web/src/components/PieceList.tsx b/web/src/components/PieceList.tsx index f9f55167..12a5b337 100644 --- a/web/src/components/PieceList.tsx +++ b/web/src/components/PieceList.tsx @@ -24,8 +24,8 @@ import type { PieceSortOrder } from "../util/api"; import { DEFAULT_PIECE_SORT, PIECE_SORT_OPTIONS } from "../util/api"; import { MasonryScroller, - createPositioner, useContainerPosition, + usePositioner, useResizeObserver, } from "masonic"; import { @@ -47,31 +47,6 @@ const MASONRY_GUTTER = 8; const MASONRY_MAX_COLUMNS_MOBILE = 2; const MASONRY_MAX_COLUMNS_DESKTOP = 4; -function getMasonryColumns( - width = 0, - minimumWidth = 0, - gutter = 8, - columnCount?: number, - maxColumnCount?: number, - maxColumnWidth?: number, -): [number, number] { - columnCount = - columnCount || - Math.min( - Math.floor((width + gutter) / (minimumWidth + gutter)), - maxColumnCount || Infinity, - ) || - 1; - let computedColumnWidth = Math.floor( - (width - gutter * (columnCount - 1)) / columnCount, - ); - - if (maxColumnWidth !== undefined && computedColumnWidth > maxColumnWidth) { - computedColumnWidth = maxColumnWidth; - } - - return [computedColumnWidth, columnCount]; -} function useWindowHeight(): number { const [height, setHeight] = useState(() => @@ -442,8 +417,22 @@ const PieceList = (props: PieceListProps) => { }, [onLoadMore]); const sentinelRef = useRef(null); + 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; + // Guard on masonryWidth: when width=0 the masonry grid hasn't rendered yet, + // so the sentinel sits at top≈0 and check() would fire onLoadMore prematurely + // before any cards are visible. Deferring until width>0 ensures the sentinel + // is at its true position in the document. + if (!hasMore || masonryWidth === 0) return; function check() { const sentinel = sentinelRef.current; if (!sentinel) return; @@ -453,7 +442,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,41 +494,33 @@ 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, + // usePositioner resets only when its deps (sort/filter keys) or layout opts change. + // Pagination appends change neither, so the positioner is reused and existing + // item positions are preserved — eliminating the full re-layout flash. + const positioner = usePositioner( + { + width: masonryWidth, columnWidth, - MASONRY_GUTTER, - undefined, - isMobile ? MASONRY_MAX_COLUMNS_MOBILE : MASONRY_MAX_COLUMNS_DESKTOP, - ); - const nextPositioner = createPositioner( - computedColumnCount, - computedColumnWidth, - MASONRY_GUTTER, - MASONRY_GUTTER, - ); - - filteredPieces.forEach((piece, index) => { - if (piece.thumbnail?.crop) { - nextPositioner.set( - index, - getPieceCardLayout(piece, nextPositioner.columnWidth).estimatedHeight, - ); - } - }); + columnGutter: MASONRY_GUTTER, + rowGutter: MASONRY_GUTTER, + maxColumnCount: isMobile + ? MASONRY_MAX_COLUMNS_MOBILE + : MASONRY_MAX_COLUMNS_DESKTOP, + }, + [sortOrder ?? "", activeFilters.join(","), activeTagIds.join(",")], + ); - return nextPositioner; - }, [filteredPieces, masonryWidth, columnWidth, isMobile]); + // Seed crop heights for unpositioned items only. On a pure append the existing + // items already have positions (get returns non-undefined) so they are skipped. + // After a sort/filter reset all items are undefined and get fully reseeded. + filteredPieces.forEach((piece, index) => { + if (piece.thumbnail?.crop && positioner.get(index) === undefined) { + positioner.set( + index, + getPieceCardLayout(piece, positioner.columnWidth).estimatedHeight, + ); + } + }); const resizeObserver = useResizeObserver(positioner); const toggleFilter = useCallback( diff --git a/web/src/components/__tests__/PieceList.test.tsx b/web/src/components/__tests__/PieceList.test.tsx index eb85ffa3..3584b0b3 100644 --- a/web/src/components/__tests__/PieceList.test.tsx +++ b/web/src/components/__tests__/PieceList.test.tsx @@ -1,5 +1,6 @@ import type React from "react"; import { useState } from "react"; +import { fireEvent } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -205,14 +206,23 @@ function RerenderHarness({ pieces }: { pieces: PieceSummary[] }) { describe("PieceList", () => { beforeEach(() => { + // Use a stateful seededMap so get() returns the value written by set(). + // This mirrors the real positioner's behaviour: once an index is placed, + // subsequent calls to get() return non-undefined and the seeding loop + // skips that item on the next render. + const seededMap = new Map(); mockPositioner.set.mockReset(); mockPositioner.get.mockReset(); mockPositioner.update.mockReset(); - mockPositioner.set.mockImplementation(() => { + mockPositioner.set.mockImplementation((index: number, height: number) => { + seededMap.set(index, height); rerenderMasonryScroller?.(); - return undefined; }); - mockPositioner.get.mockImplementation(() => undefined); + mockPositioner.get.mockImplementation((index: number) => + seededMap.has(index) + ? { height: seededMap.get(index)!, top: 0, left: 0, column: 0 } + : undefined, + ); mockPositioner.update.mockImplementation(() => undefined); rerenderMasonryScroller = undefined; mockContainerPosition.width = 440; @@ -339,6 +349,58 @@ describe("PieceList", () => { expect(mockPositioner.set).toHaveBeenCalledTimes(1); }); + it("does not re-seed existing crop heights when pieces are appended via pagination", () => { + // Regression for #734: each filteredPieces change previously called createPositioner + // from scratch, discarding all cached positions and re-seeding every item. + // The fix switches to usePositioner so the positioner is reused on append, + // and guards the seeding loop with positioner.get(index) === undefined so + // already-positioned items are never re-seeded. + // beforeEach makes get() stateful (returns seeded value after set()), + // matching real positioner behaviour. + const pieceWithCrop = makePiece({ + id: "p-crop", + thumbnail: { + url: "https://example.com/img.jpg", + cloudinary_public_id: "id", + cloud_name: "demo", + crop: { x: 0, y: 0, width: 200, height: 400 }, + }, + }); + + function AppendHarness() { + const [pieces, setPieces] = useState([pieceWithCrop]); + return ( + <> + + + + ); + } + + const router = createMemoryRouter( + [{ path: "/", element: }], + { initialEntries: ["/"] }, + ); + render(); + + // Initial render: crop piece at index 0 is seeded once + expect(mockPositioner.set).toHaveBeenCalledTimes(1); + expect(mockPositioner.set).toHaveBeenCalledWith(0, expect.any(Number)); + + // Simulate pagination: append a plain piece + fireEvent.click(screen.getByRole("button", { name: /append/i })); + + // Index 0 is already positioned — must not be re-seeded + expect(mockPositioner.set).toHaveBeenCalledTimes(1); + }); + it("reserves the thumbnail crop ratio in the card shell", () => { renderPieceList([ makePiece({ @@ -1166,6 +1228,34 @@ describe("PieceList", () => { }); }); + describe("scroll sentinel", () => { + it("does not call onLoadMore before the masonry container has a measured width", () => { + // Regression for #734: when hasMore=true, check() fires immediately on mount. + // At that moment masonryWidth=0 (ResizeObserver not yet fired), so the sentinel + // sits at top≈0 and onLoadMore would fire before any cards are visible. + // The fix adds masonryWidth to the effect deps and returns early when it is 0. + mockContainerPosition.width = 0; + const onLoadMore = vi.fn(); + const router = createMemoryRouter( + [ + { + path: "/", + element: ( + + ), + }, + ], + { initialEntries: ["/"] }, + ); + render(); + expect(onLoadMore).not.toHaveBeenCalled(); + }); + }); + describe("loading states", () => { it("dims the existing list during replace-style refreshes", () => { const router = createMemoryRouter( From 1a6a0363f1e230b88ef8a250d0dfb07dfa73db9d Mon Sep 17 00:00:00 2001 From: Phil Shao Date: Thu, 28 May 2026 17:33:21 -0400 Subject: [PATCH 2/3] fix: reset positioner on handleCreated prepend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new piece prepended via handleCreated shifts all existing item indices. Without a reset, the positioner reuses stale index→height mappings, causing masonic to size the prepended item incorrectly. Fix: track whether filteredPieces is a pure suffix-append of the previous render. Any non-append mutation (prepend, sort, filter) increments positionerResetCounterRef, which is the sole dep passed to usePositioner. A changed dep clears the seeded-heights cache and creates a fresh positioner. Pagination appends leave the counter unchanged so their positions survive. Updates the usePositioner test mock to simulate dep-based resets by clearing the shared seededMap when deps change, enabling the new regression test to distinguish "reset happened" from "reuse happened". Co-Authored-By: Claude Sonnet 4.6 --- web/src/components/PieceList.tsx | 21 +++- .../components/__tests__/PieceList.test.tsx | 110 ++++++++++++++++-- 2 files changed, 117 insertions(+), 14 deletions(-) diff --git a/web/src/components/PieceList.tsx b/web/src/components/PieceList.tsx index 12a5b337..9ad2ec8e 100644 --- a/web/src/components/PieceList.tsx +++ b/web/src/components/PieceList.tsx @@ -494,9 +494,22 @@ const PieceList = (props: PieceListProps) => { }, [activeFilters, activeTagIds, activeTags]); const hasActiveFilters = activeFilters.length > 0 || activeTagIds.length > 0; - // usePositioner resets only when its deps (sort/filter keys) or layout opts change. - // Pagination appends change neither, so the positioner is reused and existing - // item positions are preserved — eliminating the full re-layout flash. + + // Detect changes that invalidate existing item positions: sort, filter, and + // non-append mutations such as the prepend done by handleCreated. A pure + // pagination append (items grow from the end, existing order preserved) does + // NOT increment the counter so the positioner is reused without a flash. + const prevFilteredIdsRef = useRef([]); + const positionerResetCounterRef = useRef(0); + const currentFilteredIds = filteredPieces.map((p) => p.id); + const isPureAppend = + currentFilteredIds.length >= prevFilteredIdsRef.current.length && + prevFilteredIdsRef.current.every((id, i) => currentFilteredIds[i] === id); + if (!isPureAppend) positionerResetCounterRef.current++; + prevFilteredIdsRef.current = currentFilteredIds; + + // usePositioner resets only when the counter or layout opts change. + // Pagination appends leave the counter unchanged so existing positions survive. const positioner = usePositioner( { width: masonryWidth, @@ -507,7 +520,7 @@ const PieceList = (props: PieceListProps) => { ? MASONRY_MAX_COLUMNS_MOBILE : MASONRY_MAX_COLUMNS_DESKTOP, }, - [sortOrder ?? "", activeFilters.join(","), activeTagIds.join(",")], + [positionerResetCounterRef.current], ); // Seed crop heights for unpositioned items only. On a pure append the existing diff --git a/web/src/components/__tests__/PieceList.test.tsx b/web/src/components/__tests__/PieceList.test.tsx index 3584b0b3..d73fe307 100644 --- a/web/src/components/__tests__/PieceList.test.tsx +++ b/web/src/components/__tests__/PieceList.test.tsx @@ -43,8 +43,14 @@ vi.mock("../CloudinaryImage", () => ({ ), })); -const { mockPositioner, mockContainerPosition } = vi.hoisted(() => { +const { mockPositioner, mockContainerPosition, mockState } = vi.hoisted(() => { const mockContainerPosition = { width: 440, offset: 0 }; + // mockState is shared between vi.mock (hoisted) and beforeEach so that the + // usePositioner mock can simulate positioner resets by clearing seededMap. + const mockState = { + seededMap: new Map(), + positionerDeps: [] as unknown[], + }; return { mockPositioner: { get: vi.fn().mockReturnValue(undefined), @@ -59,6 +65,7 @@ const { mockPositioner, mockContainerPosition } = vi.hoisted(() => { all: vi.fn().mockReturnValue([]), }, mockContainerPosition, + mockState, }; }); @@ -139,7 +146,19 @@ vi.mock("masonic", () => ({ width: mockContainerPosition.width, offset: mockContainerPosition.offset, }), - usePositioner: () => mockPositioner, + usePositioner: (_opts: unknown, deps: unknown[] = []) => { + // Simulate usePositioner's reset behaviour: when deps change, clear the + // seeded-heights map so get() returns undefined for all items, exactly as + // the real hook creates a fresh positioner and discards cached positions. + const depsChanged = + deps.length !== mockState.positionerDeps.length || + !deps.every((d, i) => Object.is(d, mockState.positionerDeps[i])); + if (depsChanged) { + mockState.positionerDeps = [...deps]; + mockState.seededMap.clear(); + } + return mockPositioner; + }, createPositioner: ( columnCount: number, columnWidth: number, @@ -206,21 +225,21 @@ function RerenderHarness({ pieces }: { pieces: PieceSummary[] }) { describe("PieceList", () => { beforeEach(() => { - // Use a stateful seededMap so get() returns the value written by set(). - // This mirrors the real positioner's behaviour: once an index is placed, - // subsequent calls to get() return non-undefined and the seeding loop - // skips that item on the next render. - const seededMap = new Map(); + // Reset mockState so each test starts with an empty positioner. + mockState.seededMap.clear(); + mockState.positionerDeps = []; mockPositioner.set.mockReset(); mockPositioner.get.mockReset(); mockPositioner.update.mockReset(); + // set() writes to mockState.seededMap so get() can return the seeded value, + // matching real positioner behaviour (once placed, get() returns non-undefined). mockPositioner.set.mockImplementation((index: number, height: number) => { - seededMap.set(index, height); + mockState.seededMap.set(index, height); rerenderMasonryScroller?.(); }); mockPositioner.get.mockImplementation((index: number) => - seededMap.has(index) - ? { height: seededMap.get(index)!, top: 0, left: 0, column: 0 } + mockState.seededMap.has(index) + ? { height: mockState.seededMap.get(index)!, top: 0, left: 0, column: 0 } : undefined, ); mockPositioner.update.mockImplementation(() => undefined); @@ -401,6 +420,77 @@ describe("PieceList", () => { expect(mockPositioner.set).toHaveBeenCalledTimes(1); }); + it("resets the positioner when pieces are prepended (non-pure-append)", () => { + // Regression for the handleCreated prepend edge case: prepending a new piece + // shifts all existing item indices. Without a reset, the positioner still has + // heights cached at the old indices, so masonic uses the wrong height for the + // prepended item and any crop-seeded item at its new index is skipped. + // The fix detects the non-pure-append and increments positionerResetCounterRef, + // which changes usePositioner's deps → fresh positioner → all items re-seeded. + const pieceA = makePiece({ + id: "p-a", + thumbnail: { + url: "https://example.com/a.jpg", + cloudinary_public_id: "a", + cloud_name: "demo", + crop: { x: 0, y: 0, width: 200, height: 400 }, + }, + }); + const pieceB = makePiece({ + id: "p-b", + thumbnail: { + url: "https://example.com/b.jpg", + cloudinary_public_id: "b", + cloud_name: "demo", + crop: { x: 0, y: 0, width: 100, height: 150 }, + }, + }); + + function PrependHarness() { + const [pieces, setPieces] = useState([pieceA]); + return ( + <> + + + + ); + } + + const router = createMemoryRouter( + [{ path: "/", element: }], + { initialEntries: ["/"] }, + ); + render(); + + // Initial render: pieceA at index 0 seeded + expect(mockPositioner.set).toHaveBeenCalledTimes(1); + expect(mockPositioner.set).toHaveBeenCalledWith( + 0, + estimateCardHeight(pieceA, mockPositioner.columnWidth), + ); + + // Prepend pieceB: order is now [pieceB(0), pieceA(1)] + fireEvent.click(screen.getByRole("button", { name: /prepend/i })); + + // Positioner was reset → both items re-seeded at their new indices. + // Without the fix: seededMap still has {0: h_A}, so get(0)!==undefined + // and pieceB is never seeded, leaving masonic with the wrong height at 0. + expect(mockPositioner.set).toHaveBeenCalledTimes(3); + expect(mockPositioner.set).toHaveBeenCalledWith( + 0, + estimateCardHeight(pieceB, mockPositioner.columnWidth), + ); + expect(mockPositioner.set).toHaveBeenCalledWith( + 1, + estimateCardHeight(pieceA, mockPositioner.columnWidth), + ); + }); + it("reserves the thumbnail crop ratio in the card shell", () => { renderPieceList([ makePiece({ From fadfe2efaad763ecec9cb3bdcff638d942b97f6a Mon Sep 17 00:00:00 2001 From: Phil Shao Date: Thu, 28 May 2026 17:41:15 -0400 Subject: [PATCH 3/3] fix: use positionerResetKey prop instead of refs during render The previous approach read and wrote useRef.current during render to detect non-pure-appends, which violates the react-hooks/refs lint rule (refs must only be accessed outside render). Rework: PieceListPage, which already knows when handleCreated performs a prepend, increments a positionerResetKey state counter and passes it to PieceList as a prop. PieceList includes it alongside sort/filter keys in the usePositioner deps array. No ref reads during render, and the parent explicitly signals the reset rather than inferring it from item order. Update the prepend regression test to mirror this contract: the harness increments resetKey alongside the prepend, exactly as the real parent does. Co-Authored-By: Claude Sonnet 4.6 --- web/src/components/PieceList.tsx | 29 +++++++++---------- .../components/__tests__/PieceList.test.tsx | 23 ++++++++------- web/src/pages/PieceListPage.tsx | 5 ++++ 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/web/src/components/PieceList.tsx b/web/src/components/PieceList.tsx index 9ad2ec8e..b7f0ce4c 100644 --- a/web/src/components/PieceList.tsx +++ b/web/src/components/PieceList.tsx @@ -383,6 +383,12 @@ type PieceListProps = { hasMore?: boolean; loading?: boolean; loadingMore?: boolean; + /** + * Increment this whenever the parent mutates pieces in a non-append way + * (e.g. prepending a newly created piece). The positioner resets so masonic + * re-lays out all items with correct index→height mappings. + */ + positionerResetKey?: number; }; const PieceList = (props: PieceListProps) => { @@ -395,6 +401,7 @@ const PieceList = (props: PieceListProps) => { hasMore = false, loading = false, loadingMore = false, + positionerResetKey = 0, } = props; const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); @@ -495,21 +502,11 @@ const PieceList = (props: PieceListProps) => { const hasActiveFilters = activeFilters.length > 0 || activeTagIds.length > 0; - // Detect changes that invalidate existing item positions: sort, filter, and - // non-append mutations such as the prepend done by handleCreated. A pure - // pagination append (items grow from the end, existing order preserved) does - // NOT increment the counter so the positioner is reused without a flash. - const prevFilteredIdsRef = useRef([]); - const positionerResetCounterRef = useRef(0); - const currentFilteredIds = filteredPieces.map((p) => p.id); - const isPureAppend = - currentFilteredIds.length >= prevFilteredIdsRef.current.length && - prevFilteredIdsRef.current.every((id, i) => currentFilteredIds[i] === id); - if (!isPureAppend) positionerResetCounterRef.current++; - prevFilteredIdsRef.current = currentFilteredIds; - - // usePositioner resets only when the counter or layout opts change. - // Pagination appends leave the counter unchanged so existing positions survive. + // usePositioner resets when sort, filters, or positionerResetKey change. + // Pagination appends change none of these, so the positioner is reused and + // existing item positions survive — eliminating the re-layout flash. + // positionerResetKey is incremented by the parent for non-append mutations + // (e.g. handleCreated prepend) that would otherwise leave stale positions. const positioner = usePositioner( { width: masonryWidth, @@ -520,7 +517,7 @@ const PieceList = (props: PieceListProps) => { ? MASONRY_MAX_COLUMNS_MOBILE : MASONRY_MAX_COLUMNS_DESKTOP, }, - [positionerResetCounterRef.current], + [positionerResetKey, sortOrder ?? "", activeFilters.join(","), activeTagIds.join(",")], ); // Seed crop heights for unpositioned items only. On a pure append the existing diff --git a/web/src/components/__tests__/PieceList.test.tsx b/web/src/components/__tests__/PieceList.test.tsx index d73fe307..2b80df18 100644 --- a/web/src/components/__tests__/PieceList.test.tsx +++ b/web/src/components/__tests__/PieceList.test.tsx @@ -420,13 +420,12 @@ describe("PieceList", () => { expect(mockPositioner.set).toHaveBeenCalledTimes(1); }); - it("resets the positioner when pieces are prepended (non-pure-append)", () => { + it("resets the positioner when positionerResetKey changes (e.g. piece prepended)", () => { // Regression for the handleCreated prepend edge case: prepending a new piece // shifts all existing item indices. Without a reset, the positioner still has // heights cached at the old indices, so masonic uses the wrong height for the - // prepended item and any crop-seeded item at its new index is skipped. - // The fix detects the non-pure-append and increments positionerResetCounterRef, - // which changes usePositioner's deps → fresh positioner → all items re-seeded. + // prepended slot. PieceListPage increments positionerResetKey on create, + // which changes usePositioner deps → fresh positioner → all items re-seeded. const pieceA = makePiece({ id: "p-a", thumbnail: { @@ -448,15 +447,19 @@ describe("PieceList", () => { function PrependHarness() { const [pieces, setPieces] = useState([pieceA]); + const [resetKey, setResetKey] = useState(0); return ( <> - + ); } @@ -474,12 +477,12 @@ describe("PieceList", () => { estimateCardHeight(pieceA, mockPositioner.columnWidth), ); - // Prepend pieceB: order is now [pieceB(0), pieceA(1)] + // Prepend pieceB with explicit reset signal: order is now [pieceB(0), pieceA(1)] fireEvent.click(screen.getByRole("button", { name: /prepend/i })); - // Positioner was reset → both items re-seeded at their new indices. - // Without the fix: seededMap still has {0: h_A}, so get(0)!==undefined - // and pieceB is never seeded, leaving masonic with the wrong height at 0. + // usePositioner deps changed → seededMap cleared → both items re-seeded. + // Without the reset: seededMap still has {0: h_A}, get(0)!==undefined, + // pieceB never seeded, masonic uses pieceA's height for the wrong slot. expect(mockPositioner.set).toHaveBeenCalledTimes(3); expect(mockPositioner.set).toHaveBeenCalledWith( 0, diff --git a/web/src/pages/PieceListPage.tsx b/web/src/pages/PieceListPage.tsx index 22cf694e..73d9ba03 100644 --- a/web/src/pages/PieceListPage.tsx +++ b/web/src/pages/PieceListPage.tsx @@ -39,6 +39,7 @@ export default function PieceListPage() { ? sortFromUrl : DEFAULT_PIECE_SORT; const [dialogOpen, setDialogOpen] = useState(false); + const [positionerResetKey, setPositionerResetKey] = useState(0); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); @@ -137,6 +138,9 @@ export default function PieceListPage() { function handleCreated(piece: PieceDetail) { setPieces((prev) => [piece, ...prev]); setCount((c) => c + 1); + // Prepending shifts all existing item indices, invalidating cached positions. + // Signal PieceList to reset the positioner so masonic re-lays out correctly. + setPositionerResetKey((k) => k + 1); } const hasMore = pieces.length < count; @@ -182,6 +186,7 @@ export default function PieceListPage() { hasMore={hasMore} loading={refreshing} loadingMore={loadingMore} + positionerResetKey={positionerResetKey} /> )}