Skip to content

Batch chat table scrollbar measurements to avoid a layout per table - #342234

Draft
Karthik Nadig (karthiknadig) wants to merge 1 commit into
mainfrom
karthiknadig/bug-swarm/c2-f1-table-scroll-layout
Draft

Karthik Nadig (karthiknadig) wants to merge 1 commit into
mainfrom
karthiknadig/bug-swarm/c2-f1-table-scroll-layout

Conversation

@karthiknadig

@karthiknadig Karthik Nadig (karthiknadig) commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Refs #341282

Problem

Opening a chat session whose response contains many markdown tables forces the renderer to lay out the whole transcript once per table. In the Agents window, switching to a session with one ~100 KB response containing 44 tables triggers 44 forced layouts (about 280 ms) on every switch.

Median switch time: 2.60 s → 2.34 s (−0.26 s, −10%), 12 switches per build (3 runs × 4 switches). The time is measured from clicking the session to the end of its response being on screen. This removes one part of the slow session switching reported in #341282. Most of the remaining ~2.3 s goes to re-rendering the transcript, which this PR does not change.

Root cause

wrapTablesWithScrollable wraps each rendered table in a DomScrollableElement and registers scrollable.scanDomNode() as a layout participant (chatMarkdownTableScrolling.ts#L51-L52). scanDomNode() reads the table container's clientWidth, scrollWidth, clientHeight, scrollHeight, scrollLeft and scrollTop, then immediately updates the scrollbar DOM (scrollableElement.ts#L747-L759).

When a transcript is shown, every markdown part runs its layout participants from ChatMarkdownContentPart.layout() and from its resize observer (chatMarkdownContentPart.ts#L616-L633, #L403-L407). The tables are therefore scanned back to back: read, write, read, write. Each write invalidates layout, so the next table's read makes Chromium synchronously lay out the transcript again. This pattern was added with the table scrollbars in d63b8bd (#298604).

Fix

Layout participants now only schedule their table. All tables scheduled in a window are measured together in a single dom.measure callback at the next animation frame. Their scrollbars are then updated in a dom.modify callback later in that frame. All reads happen before any write, so they share one layout.

  • The code reads the same six properties as scanDomNode() and applies them with the same setScrollDimensions/setScrollPosition calls, so scrollbar behavior is unchanged.
  • Each table is scheduled at most once per frame. Disposing a table removes it from the pending batch, and disposed tables are skipped when scrollbars are updated.
  • Trade-off: the scrollbar now updates one animation frame after a resize instead of synchronously. With ScrollbarVisibility.Auto, the slider stays hidden until hover or scroll, so the delay is not visible.
  • KaTeX blocks in ChatMarkdownContentPart use the same per-element scanDomNode() pattern, but they are left unchanged here.

Measurements

Per switch, median of 12 switches main This PR
Click → end of the response visible 2.60 s (2.41–3.33) 2.34 s (2.16–3.03)
Renderer long-task time 1.27 s 0.97 s
Forced layouts from table scrollbar measurement 44 0
Time spent in those forced layouts 278 ms (257–305) None. The table reads now run before any scrollbar update, so all 528 reads per switch use the current layout (each under 0.5 ms).
  • Input: Code OSS dev build with VSCODE_DEV_DISABLE_DISPOSABLE_TRACKING=1. The Agents window is connected to a local scripted mock agent host with two completed sessions. Each session contains one ~100 KB response that renders 44 tables (3 columns × 5 rows) and 45 code blocks.
  • Procedure: Each session is opened once to warm up. Each run then makes 4 alternating switches, with 3 runs per build. main is at 1e81fc7. Between the two builds, only the compiled chatMarkdownTableScrolling.js differs.
  • Switch time: measured from a Playwright click on the session in the sessions list until that session's request and the last section of its response are visible. Visibility is polled every animation frame.
  • Long-task time: the sum of longtask PerformanceObserver entries from the click until 1.2 s after the response is visible.
  • Forced layouts: the six layout getters are wrapped on the table scroll containers. A read that takes ≥ 0.5 ms counts as a forced synchronous layout, while a read from an up-to-date layout takes microseconds. Counting runs from the click until 1.2 s after the response is visible.
  • Second check: the original whole-switch scenario (same sessions, 3 runs × 4 switches) gives a median of 2.69 s on main and 2.40 s with this PR.

Before / after

Both recordings show four switches between the two sessions, each containing 44 tables. After each switch, the overlay shows how long the response took to appear and how many forced layouts the table scrollbars caused.

Before

Before: four switches between two sessions with 44 tables each on main; table scrollbars force 44 layouts per switch

After

After: the same four switches with this PR; table scrollbars force no layouts

In these two recordings, main switches in 2.70–3.50 s, with a median of 2.79 s and 44 forced layouts on every switch. This PR switches in 2.22–2.51 s, with a median of 2.29 s, and the table scrollbars force no layouts. Each recording is a single run of 4 switches, so use the 12-switch medians under Measurements for the comparison.

How to test

  1. In a dev build, get a chat response that contains many markdown tables (for example, ask for 40 small markdown tables), then start a second session.
  2. Open Developer: Toggle Developer Tools, start a Performance recording, switch back to the session with the tables, and stop the recording.
  3. On main, the profile shows a forced reflow under scanDomNode ← wrapTablesWithScrollable for each table. With this PR, the tables are measured once in an animation frame callback and no forced reflows come from them.
  4. Check that table scrolling still works. A table wider than the chat shows a horizontal scrollbar on hover and scrolls, and a narrow table does not. Resizing the window, zooming, and changing the theme update the scrollbar.
Console snippet that counts forced layouts from table scrollbars

Paste this into the renderer Developer Tools console, switch to a session with tables, then evaluate __tableLayouts. On main, forced is about one per table. With this PR, it is 0.

(() => {
	const acc = window.__tableLayouts = { reads: 0, forced: 0, forcedMs: 0 };
	const isTableContainer = e => !!e.parentElement?.classList.contains('rendered-markdown-table-scroll-wrapper');
	for (const name of ['clientWidth', 'scrollWidth', 'clientHeight', 'scrollHeight', 'scrollLeft', 'scrollTop']) {
		const d = Object.getOwnPropertyDescriptor(Element.prototype, name);
		Object.defineProperty(Element.prototype, name, {
			...d,
			get() {
				if (!isTableContainer(this)) { return d.get.call(this); }
				const t = performance.now();
				try { return d.get.call(this); } finally {
					const ms = performance.now() - t;
					acc.reads++;
					if (ms >= 0.5) { acc.forced++; acc.forcedMs += ms; }
				}
			}
		});
	}
})();

Validation

Check Command Result
New unit test on main @ 1e81fc7 (source change stashed, tests kept) scripts\test.bat --grep "wrapTablesWithScrollable" ❌ fails as expected: "re-measures all tables on resize without forcing a layout per table" sees 14 layout reads after the first scrollbar update (expected 0). The other 9 pass.
Unit tests with this PR same ✅ 10/10 pass
Chat widget unit tests scripts\test.bat --runGlob "**/contrib/chat/test/browser/widget/**/*.test.js" ⚠️ 858 pass and 5 fail, none in table or markdown tests. Three tests also time out on main with this change stashed: ChatResourceGroupWidget "the glyph band stays painted…" at 320 px and 760 px, and ChatToolProgressSubPart "pauses clipped progress shimmer…". The other two failures are in the ResourcePool after-each hook. They report disposables leaked by the timed-out ChatToolProgressSubPart test (its waitForMotion retry loop is still running), plus that test's console output. Run alone with this PR, ResourcePool passes 18/18 (--runGlob "**/chatContentParts/resourcePool.test.js").
Type check npm run typecheck-client ✅ no errors
Lint npm run eslint -- <the two changed files> ✅ no problems
Forced layouts while switching between the two sessions with 44 tables each (3 runs × 4 switches) Agents window + mock agent host, instrumented as described under Measurements main: 44 per switch, 278 ms median. This PR: 0 per switch.
Switch time, same runs same main: 2.60 s median. This PR: 2.34 s.
Table behavior, identical on main and this PR Agents window, scripted checks ✅ A 40-column table gets a scrollbar and scrolls, while a 2-column table does not. Window resize, zoom, and theme changes re-measure correctly. All 44 tables and 45 code blocks render in both large sessions. Over 8 switch cycles, DOM nodes and listeners do not grow (Δ 0 / Δ 0).

Found and fixed with automated exploratory testing. Draft until reviewed by a human.

Copilot AI balanced review requested due to automatic review settings October 17, 2026 06:57

Copilot AI 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.

🟡 Changes recommended

Pending scans must follow tables into auxiliary windows so scrollbar initialization does not depend on the original window's animation frames.

1 open issue
What changed in this PR

Reduces repeated layout work when displaying table-heavy chat responses, addressing part of the slow session navigation reported in #341282.

Changes:

  • Batches table measurements per window before updating scrollbars.
  • Adds tests for scrollbar sizing and read-before-write ordering.
File Description
src/​vs/​workbench/​contrib/​chat/​test/​browser/​widget/​chatMarkdownTableScrolling.test.ts Tests scrollbar sizing and batched measurements.
src/​vs/​workbench/​contrib/​chat/​browser/​widget/​chatContentParts/​chatMarkdownTableScrolling.ts Schedules per-window measurement batches with disposal handling.

Review effort: Balanced

Comment on lines +29 to +33
if (table.pending || table.disposed) {
return;
}
const targetWindow = dom.getWindow(table.container);
let pending = pendingTableScans.get(targetWindow);
Each markdown table in a chat response re-measured its horizontal
scrollbar with scanDomNode(), which reads the table's layout and then
updates the scrollbar DOM. Running these back to back forced one
synchronous layout of the transcript per table (44 forced layouts,
~280 ms, when switching to a session with 44 tables).

Schedule the tables instead: all pending tables in a window are read in
one dom.measure callback and their scrollbars are updated in a later
dom.modify callback in the same animation frame.

Refs #341282

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@karthiknadig
Karthik Nadig (karthiknadig) force-pushed the karthiknadig/bug-swarm/c2-f1-table-scroll-layout branch from 1814fb6 to e7e97b2 Compare October 18, 2026 05:08

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants