Skip to content

feat: implement infinite scroll for task columns and optimize loading… - #463

Merged
pikann merged 4 commits into
masterfrom
feature/improve-board-view-ux
Sep 5, 2026
Merged

feat: implement infinite scroll for task columns and optimize loading…#463
pikann merged 4 commits into
masterfrom
feature/improve-board-view-ux

Conversation

@pikann

@pikann pikann commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Redesigns board view scrolling/pagination UX to fix the issues reported in #457, and converts task-column pagination to infinite scroll.

  • Sticky column headers: each column's header now sits outside its scroll area (structurally, not via CSS sticky), so it never scrolls out of view no matter how far down the column you scroll.
  • Independent per-column scrolling: columns now scroll vertically on their own, like Trello/Jira, instead of the whole board sharing one scroll container. The board container itself only scrolls horizontally.
  • Fixed scroll jumping to the top on "load more": root cause was the whole board sharing a single scroll region — each column now keeps its own native scroll position, so loading more items in one column can no longer disturb the board's (or another column's) scroll position.
  • Pinned "Add task" row: like the header, it now sits outside the scrollable area at the bottom of the column, always reachable without scrolling.
  • Infinite scroll pagination: replaced the "View more" button with scroll-triggered loading (reusing the app's existing createLoadMoreScrollHandler, the same pattern used by the epic/team-member pickers). A small spinner row shows while a page is loading.
  • Auto-load when the first page doesn't fill the column: if a column's initial page (e.g. a small configured page size) doesn't produce enough content to make it scrollable, no scroll event 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

  • Duplicate-fetch race: scroll events fire far more often than a deliberate click, so a fast scroll gesture could call "load more" several times before React's loading-state update actually took effect, double-fetching the same page. handleLoadMoreColumn now guards re-entrancy with a useRef (synchronous) instead of relying solely on state.
  • Periodic full-board "reload" flash during active scrolling: each "load more" was also bumping the column's React Query key (via the tracked expanded page size), which caused an additional, redundant background refetch of the whole column in parallel with the actual "next page" fetch — every single time. Chained across a fast scroll session, these overlapping refetches could leave a column's data briefly 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

  • Scope is the default (no-swimlane) board layout, which is what [Bug] Lists scrolling issue #457 describes. The niche, opt-in swimlanes grouping mode is unchanged — it doesn't have a per-column scroll container to hook into, so it keeps its existing sticky header and click-to-load button.
  • Frontend-only change (apps/web); no backend/API changes.

Test plan

  • tsc -b, biome checkapps/web
  • vitest runboard-view.test.tsx, view-utils.test.ts (26/26 passing)
  • Manually verified in an isolated harness (real BoardView, real @tanstack/react-query) with headless Chromium:
    • Scrolling one column moves only that column; headers and the pinned "Add task" row stay fixed in place.
    • "Load more" appends items in place with scroll position preserved; other columns' scroll and the page itself are untouched.
    • A stress test of continuous rapid scrolling showed 0 skeleton flashes and 0 redundant background fetches (previously 7 flashes / 8 redundant fetches over the same session).
    • A column with a small initial page size (5) in a tall viewport auto-loaded additional pages with zero scroll events, then correctly stopped once it became scrollable.

Closes #457.

🤖 Generated with Claude Code

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) — ColumnScrollArea wraps each no-swimlane column's card list with a createLoadMoreScrollHandler plus an always-running effect that auto-fetches the next page while scrollHeight <= clientHeight, and replaces the "view more" button with an in-flight Loader2 spinner.
  • Pinned column chrome — column headers and the add-task row now render outside the scroll area (shrink-0); renderAddTaskRow was factored out of renderCellCards, which gained minHeightClassName / showAddTaskRow / useScrollPagination knobs. Swimlane layout is untouched.
  • Query-key optimization (interaction-layout.tsx) — pageSize no longer part of the per-column query key (stable-key refetch semantics), placeholderData dropped for column queries (kept on the fallback), and a synchronous colLoadingMoreRef guard 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 keeps pageSize in the key, which is exactly why keepPreviousDataOnPageSizeChangeOnly is still needed there.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread apps/web/src/components/projects/interactions/board-view.tsx
Comment thread apps/web/src/components/projects/interactions/board-view.tsx Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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) — ColumnScrollArea carries a new lastLoadMoreFailed flag and, instead of re-invoking onLoadMore() on every render after a failure, schedules a single retry after AUTO_FILL_RETRY_BACKOFF_MS (4s), re-reading current state through paginationRef and gating duplicate timers with retryScheduledRef.
  • Failure tracking and error handling (interaction-layout.tsx) — handleLoadMoreColumn wraps the listAllTasks call in try/catch, logging via console.error and setting colLoadMoreFailed[colKey] (cleared on the next success), so onLoadMore() is now safe to fire-and-forget from the auto-fill effect, scroll handler, and swimlane button alike.

Pullfrog  | Fix it ➔View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to ColumnScrollArea so a pending backoff timer is cancelled if a column unmounts before it fires.
  • Extended handleLoadMoreColumn to treat a no-progress response (0 items + non-null next_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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread apps/web/src/components/projects/interactions/board-view.tsx

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • StrictMode double-invoke fix (board-view.tsx) — ColumnScrollArea's unmount cleanup now also resets retryScheduledRef and nulls retryTimeoutRef alongside 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) — renders ColumnScrollArea directly under <StrictMode> with lastLoadMoreFailed: true, advances the backoff timer, and asserts onLoadMore fires exactly once. Verified to fail without the fix and pass with it; the full board-view + view-utils suites (32 tests) pass on head.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pikann
pikann merged commit 1a9d5fa into master Sep 5, 2026
2 checks passed
@pikann
pikann deleted the feature/improve-board-view-ux branch September 5, 2026 05:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Lists scrolling issue

1 participant