Chunk initial row render so large tables paint immediately#31
Open
bemky wants to merge 1 commit into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements recommendation #4 from the spreadsheet performance review — lazy initial render with chunked, idle-time streaming of subsequent rows. Targets
mainas 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 viarequestIdleCallbackso the browser gets a chance to paint, accept input, and run other work between batches.Design
chunkSize(default50).chunkSize=0disables chunking and restores the previous "render everything up front" behavior.replaceChildren(frag)exactly like before.requestIdleCallback(setTimeout(0)fallback for Safari < 16 / SSR), constructs aDocumentFragment, and appends. ArequestIdleCallbacktimeout: 100ensures forward progress on busy main threads.setTemplateRows()suppression: a_chunkRenderInProgressflag tells the row mutation observer to skip its per-batchsetTemplateRows()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.renderingpromise semantics unchanged: it still resolves only when every row has rendered. Code paths likeappendRow/removeRowthat chain onthis.renderingcontinue 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) meansawait this.renderingsettles 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. SettingchunkSize: 0restores the exact previous timing.Test plan
chunkSize. Confirm first paint shows ~50 rows immediately and remaining rows stream in within ~1s without jank.chunkSize: 0. Confirm behavior matches pre-change (single mount, no streaming).data.length < chunkSize. Confirm everything mounts in the initial batch and_renderRemainingChunksis not entered.data. Confirm header renders andrenderedevent fires.appendRow/removeRowwhile streaming. Confirm the operation chains correctly viathis.renderingand resulting row order is correct.setTemplateRows()is called exactly once at the end of streaming (set a breakpoint or temporary log).🤖 Generated with Claude Code