Skip to content

fix(perf): virtualize watchlist table rows#4427

Merged
koala73 merged 4 commits into
mainfrom
codex/issue-3997-virtualize-tbody
Jun 27, 2026
Merged

fix(perf): virtualize watchlist table rows#4427
koala73 merged 4 commits into
mainfrom
codex/issue-3997-virtualize-tbody

Conversation

@koala73

@koala73 koala73 commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Virtualizes the reusable watchlist table body used by the premium stock analysis/backtest panels when the filtered list exceeds 100 rows.
  • Renders a 44-row visible/overscan window for the 618-row issue case while preserving the full sorted/filtered/searchable data set through spacer rows.
  • Adds focused regression coverage for the 618-row DOM reduction, virtual window movement, sort, filter, search, row expansion, expanded-detail spacer accounting, and scroll-handler cost.

Intent

Fix #3997 by removing the large eager <tbody> render path from WatchlistTableView without changing the owning panels' data contracts or user-facing table controls.

Non-goals: changing premium data fetching, changing watchlist defaults, or introducing a new virtualization dependency.

Validation Matrix

Check Result
Live issue and duplicate PR truth Passed: issue #3997 is open; no open/closed PR search hit for #3997.
Baseline reproduction Passed: pre-fix 618 items rendered 618 .watchlist-row entries and ~42 KB of table HTML.
Focused regression Passed: TMPDIR=/tmp ./node_modules/.bin/tsx --test tests/watchlist-table-view-virtualization.test.mts outside sandbox, 5 tests passed.
TypeScript Passed: npm run typecheck.
Browser DOM property proof Passed: Playwright page.setContent() probe reported 618 total source rows, 44 mounted .watchlist-rows, 152 total DOM elements, and 1 spacer.
Full browser build Passed: VITE_VARIANT=full ./node_modules/.bin/vite build.
Pre-push hook Passed: typecheck, API typecheck, boundary lint, safe HTML lint, premium-fetch parity, changed test file, edge function tests, version sync, and other configured hook gates.
Greptile follow-up Passed: fixed the initial 2 P1 + 1 P2 findings by avoiding full sort work in the scroll handler, preserving expanded-detail height in virtual spacers, and making virtual-start clamping side effects explicit.

Sandbox note: sandboxed tsx failed before tests with listen EPERM on its IPC pipe, even with TMPDIR=/tmp; the exact focused command was rerun outside the sandbox and passed.

Review Gates

  • Baseline reviewer: passed, no findings.
  • Greptile: initial findings fixed in follow-up commit 022fa8944992b8f1e3ecc25f98a3d2489112da17.
  • Code Review Expert: passed after SOLID, security, reliability, and code-quality checklist review; no findings.
  • Outcome reviewer: passed; issue acceptance rechecked against the final diff and validation evidence.

Documentation

Not applicable. This is an internal rendering/performance fix with focused tests.

Screenshots / UI Evidence

Not applicable for a non-visual table virtualization change. Runtime DOM property evidence is included in the validation matrix.

Residual Findings

None.

@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jun 26, 2026 2:59am

Request Review

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Virtualizes the WatchlistTableView tbody so that only a 44-row window (32 visible + 6 overscan each side) is mounted in the DOM when the filtered list exceeds 100 rows, replacing the previous eager full-list render. A scrollable wrapper div with sticky headers, row spacers, and scroll-position state tracks the visible window across rerenders.

  • Virtual window: renderTableBody emits top/bottom spacer <tr> rows sized by rowCount * 33px and slices the source list to the active window; bind restores scrollTop on every DOM rebuild and wires a passive scroll listener that computes nextStart from scrollTop.
  • Scroll handler regression: the scroll listener calls getFilteredSorted() (including Array.sort) on every scroll event, introducing O(n log n) CPU work per frame even when no rerender is triggered.
  • Expanded-row spacer mismatch: spacer height assumes a fixed 33 px per row; when the one expanded row scrolls above the virtual window its extra detail height is omitted from the spacer, causing the total table height to shrink abruptly and the browser to snap scroll position.

Confidence Score: 3/5

The virtualization reduces DOM nodes as intended, but two correctness issues in WatchlistTableView.ts need to be addressed before merging.

The scroll handler re-sorts the full list on every scroll event, adding CPU work proportional to list size per frame and directly countering the performance goal. The spacer height assumes all rows are 33 px; when a user expands a row and scrolls past it, the expanded detail height is missing from the top spacer, causing the browser to shrink the table height abruptly and snap the scroll position. Both defects are reproducible in normal premium-panel usage.

src/components/WatchlistTableView.ts — scroll handler and spacer height logic both need attention.

Important Files Changed

Filename Overview
src/components/WatchlistTableView.ts Adds virtual-scroll window (44 rendered rows out of 618+) with scroll event listener, spacer rows, and virtual state; scroll handler calls getFilteredSorted() (O(n log n)) on every scroll event and spacer height silently breaks when the expanded row leaves the window.
src/styles/panels.css Adds scroll container styles (max-height, overflow-y, overscroll-behavior) and sticky thead; pointer-events: none on virtual spacer rows. Straightforward CSS with no issues.
tests/watchlist-table-view-virtualization.test.mts New focused regression tests for DOM reduction, window shift, sort/filter/search interaction, and row expansion; does not cover the expanded-row spacer-height misalignment scenario.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant ScrollEl as .watchlist-table-scroll
    participant WTV as WatchlistTableView
    participant Panel as Owning Panel

    User->>ScrollEl: scroll event
    ScrollEl->>WTV: scroll handler fires
    WTV->>WTV: getFilteredSorted() [O(n log n) per frame]
    WTV->>WTV: shouldVirtualize(list)
    alt nextStart unchanged
        WTV->>WTV: update virtualScrollTop only
    else nextStart changed
        WTV->>WTV: update virtualStart + virtualScrollTop
        WTV->>Panel: onRerender()
        Panel->>Panel: setSafeContent(view.render())
        Panel->>WTV: view.bind(rootEl, onRerender)
        WTV->>ScrollEl: "scrollEl.scrollTop = virtualScrollTop"
        WTV->>ScrollEl: addEventListener scroll (passive)
    end

    User->>ScrollEl: click row (expand)
    ScrollEl->>WTV: row click handler
    WTV->>WTV: toggle expandedKey
    WTV->>Panel: onRerender()
    Panel->>Panel: setSafeContent(view.render())
    Note over WTV: renderSpacerRow uses rowCount*33px, ignores expanded detail height
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant ScrollEl as .watchlist-table-scroll
    participant WTV as WatchlistTableView
    participant Panel as Owning Panel

    User->>ScrollEl: scroll event
    ScrollEl->>WTV: scroll handler fires
    WTV->>WTV: getFilteredSorted() [O(n log n) per frame]
    WTV->>WTV: shouldVirtualize(list)
    alt nextStart unchanged
        WTV->>WTV: update virtualScrollTop only
    else nextStart changed
        WTV->>WTV: update virtualStart + virtualScrollTop
        WTV->>Panel: onRerender()
        Panel->>Panel: setSafeContent(view.render())
        Panel->>WTV: view.bind(rootEl, onRerender)
        WTV->>ScrollEl: "scrollEl.scrollTop = virtualScrollTop"
        WTV->>ScrollEl: addEventListener scroll (passive)
    end

    User->>ScrollEl: click row (expand)
    ScrollEl->>WTV: row click handler
    WTV->>WTV: toggle expandedKey
    WTV->>Panel: onRerender()
    Panel->>Panel: setSafeContent(view.render())
    Note over WTV: renderSpacerRow uses rowCount*33px, ignores expanded detail height
Loading

Reviews (1): Last reviewed commit: "fix(perf): virtualize watchlist table ro..." | Re-trigger Greptile

Comment thread src/components/WatchlistTableView.ts Outdated
Comment thread src/components/WatchlistTableView.ts Outdated
Comment thread src/components/WatchlistTableView.ts
…nder

Addresses the P1 + P2 review findings on the inline watchlist virtualization
without changing the data contracts or adopting a new primitive.

- P1: scroll now updates the visible window IN PLACE (swap only the <tbody> via
  setTrustedHtml) instead of calling onRerender(). The owning panels route
  rerenders through Panel.setSafeContent, which trailing-debounces the innerHTML
  swap 150ms; a fling never let that settle, so the window froze (blank rows)
  while listeners piled up on the un-swapped DOM. Row clicks are now delegated
  on the stable <table> so they survive the in-place tbody swaps.
- rAF-gate the scroll handler so at most one window update runs per frame.
- Clamp the scroll->index mapping to maxStart (no more redundant rerenders at
  the bottom) and subtract the expanded-detail height when the expanded row is
  above the window (no more blank band).
- Pin each data row to the virtual row height via a --watchlist-row-height
  custom property (single-line, no wrap) so the spacer + scroll math can't drift
  with cell content (notably wrapped names on mobile); cross-referenced with the
  VIRTUAL_ROW_HEIGHT_PX constant.
- Make render() pure: compute the clamped start once and thread it through
  renderTableBody / getRenderedRowCount (no state mutation, no ordering trap).
- Memoize the filtered+sorted list (keyed on items/sort/filter/search) so a
  pure window shift no longer re-sorts the whole list.
- Tests: replace the source-text "no resort" assertion with a behavioral
  sort-count test; add coverage for maxStart clamping, expanded-detail offset,
  pure-render clamp-on-shrink, below-window spacer, and the 100/101 threshold.

Verified: tsc --noEmit, the focused test (10/10), biome lint, safe-html and
boundary lints all pass. The full `vite build` could not run in this worktree
(the shared node_modules is missing the declared @fontsource/nunito dep — same
failure on a clean checkout, unrelated to this change); CI runs the real build.
Live scroll/fling behavior warrants a browser QA pass.

Deferred: review finding #2 (reuse the repo's VirtualList/WindowedList
primitive) is a larger refactor left as a follow-up.

Claude-Session: https://claude.ai/code/session_01SfU43TAhhi8VSE5geCdCjT
@koala73
koala73 merged commit ff9448d into main Jun 27, 2026
24 checks passed
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.

perf(J): virtualize the 618-child tbody panel

1 participant