feat: implement infinite scroll for task columns and optimize loading… - #463
Conversation
… state management
There was a problem hiding this comment.
Important
The auto-fill effect in ColumnScrollArea retries failed load-mores on every render with no backoff and no error surface — please address before merging.
Reviewed changes
- Per-column infinite scroll (
board-view.tsx) —ColumnScrollAreawraps each no-swimlane column's card list with acreateLoadMoreScrollHandlerplus an always-running effect that auto-fetches the next page whilescrollHeight <= clientHeight, and replaces the "view more" button with an in-flightLoader2spinner. - Pinned column chrome — column headers and the add-task row now render outside the scroll area (
shrink-0);renderAddTaskRowwas factored out ofrenderCellCards, which gainedminHeightClassName/showAddTaskRow/useScrollPaginationknobs. Swimlane layout is untouched. - Query-key optimization (
interaction-layout.tsx) —pageSizeno longer part of the per-column query key (stable-key refetch semantics), placeholderData dropped for column queries (kept on the fallback), and a synchronouscolLoadingMoreRefguard closes the double-fetch window between scroll ticks.
ℹ️ Filter-change depth reset is now a no-op for column queries
setColExpandedPageSizes({}) on colBaseOptsKey change (interaction-layout.tsx:1017-1019) was written to drop an expanded column back to initialColPageSize when filters change. With pageSize out of the query key, that reset no longer re-keys the query: after a filter change the new-filter fetch simply runs at the previously-scrolled page size, and nothing reverts to the initial size until a later invalidation. The data is still correct (just a larger-than-intended fetch), but the reset is now silently inert for the column path — worth confirming that's intended, and noting it in the comment.
Technical details
# Depth reset no longer reverts the column query on filter change
## Affected sites
- apps/web/src/components/projects/interactions/interaction-layout.tsx:1017-1019 — the reset effect only changes colExpandedPageSizes; with pageSize excluded from the key it can no longer trigger a smaller re-fetch.
- apps/web/src/components/projects/interactions/interaction-layout.tsx:856, 864 — colOptsForKey strips pageSize, so the key is indifferent to depth.
## Required outcome
- A filter/sort/search change should reset an expanded column to the initial page size (previous behavior), or the deviation should be intentional and documented.
## Open questions for the human
- Is "keep the scrolled depth across a filter change until the next natural refetch" the desired UX? If yes, the reset effect and its comment should say so.ℹ️ Nitpicks
- The fallback query's placeholder comment (
interaction-layout.tsx:926-931) still cross-references "the matching comment on the column queries above" (pageSize grows the key) — after this PR the column queries are the opposite case; the fallback is now the one query that keepspageSizein the key, which is exactly whykeepPreviousDataOnPageSizeChangeOnlyis still needed there.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
ℹ️ No new issues in this delta — the prior review's important finding (unbounded auto-retry on failed load-more) is properly addressed by the backoff rework.
Reviewed changes
- Backoff retry for failed auto-fill (
board-view.tsx) —ColumnScrollAreacarries a newlastLoadMoreFailedflag and, instead of re-invokingonLoadMore()on every render after a failure, schedules a single retry afterAUTO_FILL_RETRY_BACKOFF_MS(4s), re-reading current state throughpaginationRefand gating duplicate timers withretryScheduledRef. - Failure tracking and error handling (
interaction-layout.tsx) —handleLoadMoreColumnwraps thelistAllTaskscall in try/catch, logging viaconsole.errorand settingcolLoadMoreFailed[colKey](cleared on the next success), soonLoadMore()is now safe to fire-and-forget from the auto-fill effect, scroll handler, and swimlane button alike.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…nd cancellation on unmount
There was a problem hiding this comment.
Important
The new backoff-retry cancellation works in production but silently breaks under React StrictMode in dev, which the app enables in main.tsx — please address before merging.
Reviewed changes
- Added
retryTimeoutRef+ a mount/unmount cleanup toColumnScrollAreaso a pending backoff timer is cancelled if a column unmounts before it fires. - Extended
handleLoadMoreColumnto treat a no-progress response (0 items + non-nullnext_cursor) as a stall, so the auto-fill backs off instead of tight-looping. - Added 5 unit tests for the auto-fill effect: mount-time request, in-flight guard, exhausted guard, backoff timing (fake timers), and unmount cancellation.
Verification: vitest (16 board-view + 15 view-utils), tsc -b, and biome check all pass on the head commit.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- StrictMode double-invoke fix (
board-view.tsx) —ColumnScrollArea's unmount cleanup now also resetsretryScheduledRefand nullsretryTimeoutRefalongside clearing the pending timer, so under React StrictMode's dev mount→cleanup→remount the remount's effect run reschedules the 4s backoff instead of bailing on the stale guard. The component was exported so the retry path can be tested in isolation. - StrictMode regression test (
board-view.test.tsx) — rendersColumnScrollAreadirectly under<StrictMode>withlastLoadMoreFailed: true, advances the backoff timer, and assertsonLoadMorefires exactly once. Verified to fail without the fix and pass with it; the full board-view + view-utils suites (32 tests) pass on head.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Summary
Redesigns board view scrolling/pagination UX to fix the issues reported in #457, and converts task-column pagination to infinite scroll.
sticky), so it never scrolls out of view no matter how far down the column you scroll.createLoadMoreScrollHandler, the same pattern used by the epic/team-member pickers). A small spinner row shows while a page is loading.scrollevent would ever fire to trigger more loading. Columns now detect this and keep requesting more pages on their own until either the content fills the viewport or there's nothing left to load.Notable fixes along the way
handleLoadMoreColumnnow guards re-entrancy with auseRef(synchronous) instead of relying solely on state.undefined, tripping the loading-skeleton gate and flashing the whole board back to a loading state mid-scroll (on top of roughly doubling backend load). Fixed by excluding page size from the query key — a column's key now only changes on a genuine filter/sort/search change, while an actual refetch (websocket invalidation, window refocus, etc.) still naturally picks up the current expanded depth.Notes
apps/web); no backend/API changes.Test plan
tsc -b,biome check—apps/webvitest run—board-view.test.tsx,view-utils.test.ts(26/26 passing)BoardView, real@tanstack/react-query) with headless Chromium:Closes #457.
🤖 Generated with Claude Code