The same mistake as the draw-phase culling, moved into another phase.
Each row held a `derivedStateOf` over the scroll position to decide
whether it stayed built. That reads correctly and costs the same shape:
every one of those derived states is invalidated by every scroll frame
and has to be re-evaluated to learn whether its answer changed, so the
per-frame work grew with the number of loaded rows again -- this time
landing in the recomposition pass, which the platform reports as the
frame's animation phase. On a Pixel 9 Pro XL at 153 rows that was 11.6ms
at the median, against an 8.3ms frame, and it was the largest term left.
The window is now one range that every row reads, recomputed once a
frame from one observer. And recomputed lazily: every row reads it, so
every change to it disturbs all of them, which is affordable once every
couple of screens and is not affordable at row boundaries, where a fling
would cross one every few frames. The window is eight screens either
side and it moves in steps of two, so the margin absorbs the staleness.
Worth naming as a pattern, since this is the third time: a per-row
answer to a question about the scroll position is O(rows) per frame
wherever it is evaluated -- in draw, in a derived state, anywhere. The
question has one answer and it belongs in one place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>