Skip to content

Chunk initial row render so large tables paint immediately#31

Open
bemky wants to merge 1 commit into
mainfrom
chunked-row-render
Open

Chunk initial row render so large tables paint immediately#31
bemky wants to merge 1 commit into
mainfrom
chunked-row-render

Conversation

@bemky
Copy link
Copy Markdown
Owner

@bemky bemky commented May 1, 2026

Summary

Implements recommendation #4 from the spreadsheet performance review — lazy initial render with chunked, idle-time streaming of subsequent rows. Targets main as a 2.0 feature.

The first paint of a 1,000-row spreadsheet previously waited on construction of ~10K cell custom elements (constructors, connectedCallbacks, listeners, column.cells.add, etc.). With this change, the first 50 rows mount synchronously and the rest stream in via requestIdleCallback so the browser gets a chance to paint, accept input, and run other work between batches.

Design

  • New attribute: chunkSize (default 50). chunkSize=0 disables chunking and restores the previous "render everything up front" behavior.
  • Initial chunk: built synchronously, mounted via replaceChildren(frag) exactly like before.
  • Remaining chunks: each batch awaits requestIdleCallback (setTimeout(0) fallback for Safari < 16 / SSR), constructs a DocumentFragment, and appends. A requestIdleCallback timeout: 100 ensures forward progress on busy main threads.
  • setTemplateRows() suppression: a _chunkRenderInProgress flag tells the row mutation observer to skip its per-batch setTemplateRows() rebuild while chunks are streaming. setTemplateRows() is called once when streaming completes. Without this, each chunk insertion would do an O(rows-so-far) querySelectorAll — total cost O(N²/chunkSize).
  • this.rendering promise semantics unchanged: it still resolves only when every row has rendered. Code paths like appendRow/removeRow that chain on this.rendering continue to wait for full render. Only first paint moves earlier.

Why not full virtualization

Discussed in the review: with CSS subgrid as the layout mechanism, removing rows on scroll loses subgrid alignment and breaks slice()/at()/outlineCells() which all walk DOM cells. Virtualization is the right fix for steady-state scroll/edit slowness; this PR is the right fix for initial paint slowness, and is a much smaller change.

Breaking change consideration

The new default (chunkSize: 50) means await this.rendering settles slightly later for tables with > 50 rows (one or more idle callbacks worth of delay vs synchronous). Consumers that depended on synchronous behavior — new Table(...) then immediately reading .rows — were already broken because the original render was async too. Setting chunkSize: 0 restores the exact previous timing.

Test plan

  • Render a 1,000-row spreadsheet with default chunkSize. Confirm first paint shows ~50 rows immediately and remaining rows stream in within ~1s without jank.
  • Render with chunkSize: 0. Confirm behavior matches pre-change (single mount, no streaming).
  • Render with data.length < chunkSize. Confirm everything mounts in the initial batch and _renderRemainingChunks is not entered.
  • Render with empty data. Confirm header renders and rendered event fires.
  • During streaming, click a cell in the initial chunk — confirm interactions work before all chunks finish (delegated listeners on Spreadsheet).
  • Call appendRow/removeRow while streaming. Confirm the operation chains correctly via this.rendering and resulting row order is correct.
  • Resize a column during streaming. Confirm no layout corruption (initial chunk has correct widths; later chunks pick up the resized template).
  • Verify setTemplateRows() is called exactly once at the end of streaming (set a breakpoint or temporary log).

🤖 Generated with Claude Code

Table.render previously constructed every row before the first paint, so a
1,000-row spreadsheet stalled the main thread through the entire mount of
~10K cell custom elements before anything appeared on screen.

Render the first `chunkSize` rows synchronously, then stream the remainder
in `chunkSize`-sized batches via requestIdleCallback (with a setTimeout
fallback). Each deferred batch goes through a DocumentFragment so the row
mutation observer fires once per chunk instead of once per row.

`setTemplateRows()` is suppressed via a `_chunkRenderInProgress` flag while
streaming and called once at the end — otherwise the observer would run
setTemplateRows for every chunk, scaling O(N²/chunkSize) with the running
row count.

Default chunkSize is 50, which covers a typical viewport. Set chunkSize=0
to restore the previous behavior of rendering every row up front. The
`this.rendering` promise still resolves only when every row has mounted,
so consumers awaiting full render see no behavior change — only first
paint moves earlier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant