fix: eliminate PieceList flash on pagination#735
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Fixes #734. On the PieceList page with 17+ pieces, a dark flash appears immediately after the first 16 cards render. Two bugs compound to cause it.
Fix
Bug 1 — Sentinel fires before masonry renders (
PieceList.tsx):The scroll sentinel
useEffectcalledcheck()immediately whenhasMorebecametrue. At that momentmasonryWidth=0(the ResizeObserver hasn't fired yet), so the sentinel sits attop≈0andonLoadMorefires before any cards are visible — triggering the second page fetch while the masonry is still empty.Fix: add
masonryWidthto the effect's dependency list and return early whenmasonryWidth === 0. The sentinel check is deferred until the masonry has rendered its cards and the sentinel is at its true document position.Bug 2 — Full positioner reset on every append (
PieceList.tsx):The
positionerwas computed viauseMemowithfilteredPiecesas a dependency. Every time the second page arrived andfilteredPiecesgrew 16→32,createPositionerwas called from scratch — discarding all cached positions — andMasonryScrollerre-laid out all items from zero. That re-layout is the flash.Fix: replace
createPositioner+useMemowith masonic'susePositionerhook.usePositioneronly resets when itsdepsarray (sortOrder, active filters, active tags) or layout options (masonryWidth,columnWidth,isMobile) change — not when items are appended. Crop heights are seeded inline with apositioner.get(index) === undefinedguard so only new unpositioned items are seeded; existing positions survive pagination.Also removes the now-unused
getMasonryColumnshelper (its logic is handled internally byusePositioner).Regression Tests
Two tests added to
web/src/components/__tests__/PieceList.test.tsx:scroll sentinel > does not call onLoadMore before the masonry container has a measured width— renders withmasonryWidth=0andhasMore=true; assertsonLoadMoreis never called. Failed before the fix (Bug 1).masonry height pre-seeding > does not re-seed existing crop heights when pieces are appended via pagination— renders with a cropped piece, appends a second piece, assertspositioner.setis still called exactly once (index 0 not re-seeded). Failed before the fix (Bug 2).The
beforeEachmock formockPositioner.getwas updated to be stateful (returns the value written byset), matching the real positioner's behaviour and enabling the "does not reseed on unrelated rerender" test to remain valid.Verification
Closes #734