Fix chat ResizeObserver loop by observing rows only while mounted (#327753)

Chat telemetry reported a large volume of "ResizeObserver loop completed
with undelivered notifications" warnings attributed to
ChatInputPart.containerHeight. That attribution is misleading: the loop
error is dispatched by Chromium after the observation phase, so the
DisposableResizeObserver name recorded is simply whichever observer
fired last, not the one that caused the loop.

The actual cause is in ChatListItemRenderer. Row templates observed
their rowContainer for the entire template lifetime, but the chat list
is virtualized and recycles rows. When an observation phase disconnects
an observed target, its notification can never be delivered, and the
browser raises the loop error.

Bind the row observation to mount state using the existing
connection-observer element: observe on connect, clear on disconnect.
This is synchronous with mount, so it needs no frame deferral.

Also stop ChatViewPane's stacked-sessions input-height autorun from
re-entering inputPart.layout(). It now uses a targeted path that
updates the height budget and lays out only list-side surfaces.

Adds a component-explorer harness with fully mocked model traffic and a
Playwright regression covering four host-layout scenarios, plus a unit
test locking the targeted layout dispatch order.

Refs #316501

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: aacd276e-cf84-48bd-a2ab-6f6a4d4c3431
This commit is contained in:
Bryan Chen
2026-07-28 12:04:41 -07:00
committed by GitHub
co-authored by Copilot
parent a7a9d0e787
commit 938777f227
6 changed files with 324 additions and 12 deletions
@@ -901,10 +901,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
const template: IChatListItemTemplate = { header, avatarContainer, requestHover, username, detail, value, rowContainer, elementDisposables, templateDisposables, contextKeyService, instantiationService: scopedInstantiationService, agentHover, titleToolbar, footerToolbar, footerToolbarContainer, footerDetailsContainer, disabledOverlay, checkpointToolbar, checkpointRestoreToolbar, checkpointContainer, checkpointRestoreContainer };
this.templateDataByRow.set(rowContainer, template);
connectionObserver.onDidDisconnect = () => {
template.renderedPartsMounted = false;
};
templateDisposables.add(this._onDidUpdateViewModel.event(() => {
if (!template.currentElement || !this.viewModel?.sessionResource || !isEqual(template.currentElement.sessionResource, this.viewModel.sessionResource)) {
this.clearRenderedParts(template);
@@ -933,7 +929,17 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
this.fireItemHeightChange(template, entry.borderBoxSize.at(0)?.blockSize);
}
}));
templateDisposables.add(resizeObserver.observe(rowContainer));
const resizeObservation = templateDisposables.add(new MutableDisposable<IDisposable>());
connectionObserver.onDidConnect = () => {
resizeObservation.value = resizeObserver.observe(rowContainer);
};
connectionObserver.onDidDisconnect = () => {
template.renderedPartsMounted = false;
resizeObservation.clear();
};
if (rowContainer.isConnected) {
connectionObserver.onDidConnect();
}
return template;
}
@@ -3188,6 +3188,17 @@ export class ChatWidget extends Disposable implements IChatWidget {
this._layoutListForInputHeight();
}
/**
* Updates the widget's available space after the intrinsic input height changed.
* The input has already laid itself out, so this only resizes the list-side
* surfaces and must not call {@link ChatInputPart.layout}.
*/
layoutForInputHeight(height: number, width: number): void {
width = Math.min(width, this.viewOptions.renderStyle === 'minimal' ? width : 950);
this.bodyDimension = new dom.Dimension(width, height);
this._layoutListForInputHeight();
}
/**
* Re-layout just the list, welcome container, and list container to match
* the current input-part height. Called both from {@link layout} and from
@@ -3399,4 +3410,9 @@ export class ChatWidget extends Disposable implements IChatWidget {
}
}
export function layoutChatWidgetForInputHeight(widget: Pick<ChatWidget, 'setInputPartMaxHeightOverride' | 'layoutForInputHeight'>, inputMaxHeight: number | undefined, height: number, width: number): void {
widget.setInputPartMaxHeightOverride(inputMaxHeight);
widget.layoutForInputHeight(height, width);
}
const MIN_LIST_HEIGHT = 50;
@@ -55,7 +55,7 @@ import { LocalChatSessionUri, getChatSessionType, isUntitledChatSession } from '
import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getDefaultNewChatSessionResource, getDefaultNewChatSessionType } from '../../../common/constants.js';
import { AgentSessionsControl } from '../../agentSessions/agentSessionsControl.js';
import { ACTION_ID_NEW_CHAT } from '../../actions/chatActions.js';
import { ChatWidget } from '../../widget/chatWidget.js';
import { ChatWidget, layoutChatWidgetForInputHeight } from '../../widget/chatWidget.js';
import { ChatViewWelcomeController, IViewWelcomeDelegate } from '../../viewsWelcome/chatViewWelcomeController.js';
import { IChatViewsWelcomeDescriptor } from '../../viewsWelcome/chatViewsWelcome.js';
import { IWorkbenchLayoutService, LayoutSettings, Position } from '../../../../../services/layout/browser/layoutService.js';
@@ -1089,7 +1089,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
this._register(autorun(reader => {
chatWidget.inputPart.height.read(reader);
if (this.sessionsViewerVisible && this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) {
this.relayout();
this.relayoutForInputHeight();
}
}));
@@ -1452,6 +1452,14 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
}
}
private relayoutForInputHeight(): void {
if (this.layoutingBody || !this._widget?.visible || !this.lastDimensions) {
return;
}
this.layoutChatAndSessions(this.lastDimensions.height, this.lastDimensions.width, false);
}
protected override layoutBody(height: number, width: number): void {
if (this.layoutingBody) {
return; // prevent re-entrancy
@@ -1469,7 +1477,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
super.layoutBody(height, width);
this.lastDimensions = { height, width };
this.layoutChatAndSessions(height, width, true);
}
private layoutChatAndSessions(height: number, width: number, layoutInput: boolean): void {
let remainingHeight = height;
const remainingWidth = width;
@@ -1488,10 +1499,15 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
// the sessions viewer deduction) so the input can grow freely. As the input
// grows, an autorun triggers relayout which shrinks the sessions viewer,
// giving the widget more space and converging to the right sizes.
this._widget.setInputPartMaxHeightOverride(this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked ? remainingHeight : undefined);
const inputMaxHeight = this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked ? remainingHeight : undefined;
// Chat Widget
this._widget.layout(remainingHeight - heightReduction, remainingWidth - widthReduction);
if (layoutInput) {
this._widget.setInputPartMaxHeightOverride(inputMaxHeight);
this._widget.layout(remainingHeight - heightReduction, remainingWidth - widthReduction);
} else {
layoutChatWidgetForInputHeight(this._widget, inputMaxHeight, remainingHeight - heightReduction, remainingWidth - widthReduction);
}
// Remember last dimensions per orientation
this.lastDimensionsPerOrientation.set(this.sessionsViewerOrientation, { height, width });
@@ -7,7 +7,7 @@ import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { OffsetRange } from '../../../../../../editor/common/core/ranges/offsetRange.js';
import { Range } from '../../../../../../editor/common/core/range.js';
import { getImmediateSilentSlashCommandPart } from '../../../browser/widget/chatWidget.js';
import { getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight } from '../../../browser/widget/chatWidget.js';
import { ChatAgentLocation } from '../../../common/constants.js';
import { ChatRequestSlashCommandPart, ChatRequestTextPart, IParsedChatRequest } from '../../../common/requestParser/chatParserTypes.js';
@@ -68,4 +68,19 @@ suite('ChatWidget', () => {
undefined,
]);
});
test('input height changes update the budget without re-laying out the input', () => {
const calls: unknown[] = [];
const target = {
setInputPartMaxHeightOverride: (height: number | undefined) => calls.push(['setInputPartMaxHeightOverride', height]),
layoutForInputHeight: (height: number, width: number) => calls.push(['layoutForInputHeight', height, width]),
};
layoutChatWidgetForInputHeight(target, 600, 420, 720);
assert.deepStrictEqual(calls, [
['setInputPartMaxHeightOverride', 600],
['layoutForInputHeight', 420, 720],
]);
});
});
@@ -6,7 +6,7 @@
import * as dom from '../../../../../base/browser/dom.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
import { constObservable } from '../../../../../base/common/observable.js';
import { autorun, constObservable } from '../../../../../base/common/observable.js';
import { mock } from '../../../../../base/test/common/mock.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { URI } from '../../../../../base/common/uri.js';
@@ -97,6 +97,17 @@ export interface IChatWidgetFixtureOptions {
* response.
*/
readonly turnStatusPills?: ChatTurnStatusPillsSetting;
readonly onRendered?: (handle: IChatWidgetFixtureHandle) => void;
/** Selects the input-height consumer used by the ResizeObserver harness. */
readonly hostLayoutMode?: 'none' | 'listOnly' | 'stackedFull' | 'stackedTargeted';
}
interface IChatWidgetFixtureHandle {
readonly inputPart: ChatInputPart;
readonly listWidget: ChatListWidget;
readonly model: ChatModel;
readonly width: number;
readonly addTerminalConfirmation: (request: ReturnType<ChatModel['addRequest']>, command: string) => void;
}
function makeFileDiff(change: IFixtureFileChange): IEditSessionEntryDiff {
@@ -355,7 +366,7 @@ export async function renderChatWidget(context: ComponentFixtureContext, options
inputPart.element.classList.toggle('chat-input-hidden', options.inputVisible === false);
const listContainer = dom.$('.interactive-list');
listContainer.style.flex = '1 1 auto';
listContainer.style.flex = options.hostLayoutMode ? '0 0 auto' : '1 1 auto';
listContainer.style.minHeight = '0';
listContainer.style.position = 'relative';
// Prepend the list before the input so the visual order matches production.
@@ -378,6 +389,7 @@ export async function renderChatWidget(context: ComponentFixtureContext, options
},
},
));
listWidget.setViewModel(viewModel);
listWidget.setVisible(true);
listWidget.refresh();
@@ -385,6 +397,60 @@ export async function renderChatWidget(context: ComponentFixtureContext, options
const listHeight = 420;
listWidget.layout(listHeight, width);
listWidget.scrollTop = 0;
if (options.hostLayoutMode && options.hostLayoutMode !== 'none') {
let layouting = false;
disposableStore.add(autorun(reader => {
const inputHeight = inputPart.height.read(reader);
if (layouting) {
return;
}
layouting = true;
try {
if (options.hostLayoutMode === 'stackedFull') {
// Mirrors ChatViewPane's stacked-sessions convergence path:
// the host synchronously lays out the input again.
inputPart.setMaxHeight(Math.max(0, height - 50));
inputPart.layout(width);
}
const contentHeight = options.hostLayoutMode === 'stackedFull' || options.hostLayoutMode === 'stackedTargeted'
? Math.max(0, Math.max(116, inputHeight) - inputHeight)
: Math.max(0, height - inputHeight);
listContainer.style.height = `${contentHeight}px`;
listContainer.dataset['expectedHeight'] = String(contentHeight);
listWidget.layout(contentHeight, width);
} finally {
layouting = false;
}
}));
}
options.onRendered?.({
inputPart,
listWidget,
model,
width,
addTerminalConfirmation: (request, command) => {
model.acceptResponseProgress(request, new ChatToolInvocation(
{
invocationMessage: new MarkdownString(`Running \`${command}\``),
pastTenseMessage: new MarkdownString(`Ran \`${command}\``),
confirmationMessages: { title: 'Run diagnostic command?', message: new MarkdownString(`\`${command}\``) },
toolSpecificData: {
kind: 'terminal',
commandLine: { original: command },
language: 'pwsh',
},
},
fixtureToolData,
generateUuid(),
undefined,
{ command },
));
},
});
}
const SIMPLE_QA: IFixtureMessage[] = [
@@ -559,10 +625,152 @@ const CODE_BLOCK_IN_LIST: IFixtureMessage[] = [
},
];
async function renderResizeObserverLoopHarness(context: ComponentFixtureContext, hostLayoutMode: IChatWidgetFixtureOptions['hostLayoutMode']): Promise<void> {
const targetWindow = context.container.ownerDocument.defaultView;
if (!targetWindow) {
throw new Error('ResizeObserver harness requires a window');
}
let handle: IChatWidgetFixtureHandle | undefined;
await renderChatWidget(context, {
messages: [{
user: [
'Investigate ResizeObserver re-entry.',
'',
'Context (text/plain; no binary upload):',
'Issue #316501 tracks chat list and input resize-observer loop warnings.',
].join('\n'),
assistant: [{
kind: 'markdown',
text: 'The mocked chat harness is ready.',
}],
}],
width: 720,
height: 600,
renderStyle: 'default',
hostLayoutMode,
onRendered: value => handle = value,
});
if (!handle) {
throw new Error('ResizeObserver harness did not initialize');
}
const fixtureHandle = handle;
const controls = dom.$('.resize-observer-loop-harness');
const runButton = dom.append(controls, dom.$<HTMLButtonElement>('button.resize-observer-loop-run'));
runButton.type = 'button';
runButton.textContent = 'Run 20-turn burst';
const status = dom.append(controls, dom.$('span.resize-observer-loop-status'));
status.role = 'status';
status.textContent = 'Ready';
const warnings = dom.append(controls, dom.$('span.resize-observer-loop-warnings'));
warnings.textContent = 'Warnings: 0';
controls.style.position = 'absolute';
controls.style.top = '8px';
controls.style.right = '8px';
controls.style.zIndex = '100';
controls.style.display = 'flex';
controls.style.gap = '8px';
controls.style.alignItems = 'center';
controls.style.padding = '6px 8px';
controls.style.background = 'var(--vscode-editorWidget-background)';
controls.style.border = '1px solid var(--vscode-widget-border)';
context.container.style.position = 'relative';
context.container.appendChild(controls);
let warningCount = 0;
context.disposableStore.add(dom.addDisposableListener(targetWindow, dom.EventType.ERROR, event => {
if (event instanceof ErrorEvent && event.message.includes('ResizeObserver loop')) {
warningCount++;
warnings.textContent = `Warnings: ${warningCount}`;
warnings.dataset['lastAttribution'] = dom.getRecentDisposableResizeObserverAttributionForLoopError(event.message) ?? event.message;
status.textContent = 'Captured ResizeObserver warning';
}
}));
const nextFrame = () => new Promise<void>(resolve => targetWindow.requestAnimationFrame(() => resolve()));
const runBurst = async () => {
runButton.disabled = true;
status.textContent = 'Adding queued turns...';
const responses = [];
for (let index = 1; index <= 20; index++) {
const prompt = [
`Queued prompt ${index}`,
'',
'Context (text/plain; no binary upload):',
...Array.from({ length: 12 }, (_, line) => `Resize stress sample ${index}.${line + 1}: ${'layout '.repeat(index % 5 + 1)}`),
].join('\n');
fixtureHandle.inputPart.setValue(prompt, true);
fixtureHandle.inputPart.layout(fixtureHandle.width);
const request = fixtureHandle.model.addRequest(makeUserMessage(prompt), { variables: [] }, 0);
fixtureHandle.model.acceptResponseProgress(request, {
kind: 'progressMessage',
content: new MarkdownString(`Processing queued prompt ${index}...`),
});
if (index === 1) {
fixtureHandle.addTerminalConfirmation(request, 'git status --short');
}
responses.push(request.response!);
fixtureHandle.listWidget.refresh();
await nextFrame();
fixtureHandle.inputPart.setValue('', true);
fixtureHandle.inputPart.layout(fixtureHandle.width);
fixtureHandle.model.acceptResponseProgress(request, {
kind: 'markdownContent',
content: new MarkdownString(`Mock streamed output ${index}\n\n${'- response line\n'.repeat(index % 7 + 1)}`),
});
fixtureHandle.listWidget.refresh();
await nextFrame();
}
status.textContent = 'Completing mocked responses...';
for (const response of responses) {
response.complete();
fixtureHandle.listWidget.refresh();
await nextFrame();
}
status.textContent = warningCount > 0
? 'Completed with ResizeObserver warning'
: 'Completed without warning';
runButton.disabled = false;
};
context.disposableStore.add(dom.addDisposableListener(runButton, dom.EventType.CLICK, () => {
void runBurst();
}));
}
export default defineThemedFixtureGroup({ path: 'chat/widget/' }, {
SimpleQA: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: SIMPLE_QA }) }),
Streaming: defineComponentFixture({ labels: { kind: 'animated' }, render: ctx => renderChatWidget(ctx, { messages: STREAMING }) }),
PendingToolApproval: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: PENDING_TOOL_APPROVAL }) }),
ResizeObserverLoopHarness: defineComponentFixture({
labels: { kind: 'animated' },
virtualTime: { enabled: false },
render: context => renderResizeObserverLoopHarness(context, 'stackedFull'),
}),
ResizeObserverLoopListOnly: defineComponentFixture({
labels: { kind: 'animated' },
virtualTime: { enabled: false },
render: context => renderResizeObserverLoopHarness(context, 'listOnly'),
}),
ResizeObserverLoopStackedTargeted: defineComponentFixture({
labels: { kind: 'animated' },
virtualTime: { enabled: false },
render: context => renderResizeObserverLoopHarness(context, 'stackedTargeted'),
}),
ResizeObserverLoopNoHostLayout: defineComponentFixture({
labels: { kind: 'animated' },
virtualTime: { enabled: false },
render: context => renderResizeObserverLoopHarness(context, 'none'),
}),
CodeBlockInList: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: CODE_BLOCK_IN_LIST }) }),
bugs: defineThemedFixtureGroup({
'issue-309796-missing-backslash': defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: ISSUE_309796_MISSING_BACKSLASH }) }),
@@ -0,0 +1,51 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { expect, test } from '@playwright/test';
import { openFixture } from './utils.js';
const scenarios = [
{ name: 'no host layout', fixture: 'ResizeObserverLoopNoHostLayout', expectedWarnings: 0 },
{ name: 'list-only layout', fixture: 'ResizeObserverLoopListOnly', expectedWarnings: 0 },
{ name: 'stacked full layout', fixture: 'ResizeObserverLoopHarness', expectedWarnings: 0 },
{ name: 'stacked targeted layout', fixture: 'ResizeObserverLoopStackedTargeted', expectedWarnings: 0 },
] as const;
for (const scenario of scenarios) {
test(`runs the mocked chat resize-observer burst harness with ${scenario.name}`, async ({ page }) => {
const resizeObserverErrors: string[] = [];
page.on('pageerror', error => {
if (error.message.includes('ResizeObserver loop')) {
resizeObserverErrors.push(error.message);
}
});
await openFixture(page, `chat/widget/chatWidget/${scenario.fixture}/Dark`, '.resize-observer-loop-harness');
await page.getByRole('button', { name: 'Run 20-turn burst' }).click();
await expect(page.getByRole('status')).toContainText(/Completed/, { timeout: 30_000 });
const warningText = await page.locator('.resize-observer-loop-warnings').textContent();
const lastAttribution = await page.locator('.resize-observer-loop-warnings').getAttribute('data-last-attribution');
console.log(`[chat-resize-harness:${scenario.name}] ${warningText}; page errors: ${resizeObserverErrors.length}; last attribution: ${lastAttribution}`);
const warningCount = Number(warningText?.replace('Warnings: ', ''));
expect(warningCount).toBe(scenario.expectedWarnings);
if (scenario.name === 'stacked targeted layout') {
const geometry = await page.locator('.interactive-list').evaluate(element => {
const list = element.querySelector<HTMLElement>('.monaco-list');
return {
expectedHeight: Number((element as HTMLElement).dataset['expectedHeight']),
containerHeight: element.getBoundingClientRect().height,
listHeight: list?.getBoundingClientRect().height,
};
});
expect(geometry.containerHeight).toBeCloseTo(geometry.expectedHeight);
expect(geometry.listHeight).toBeCloseTo(geometry.expectedHeight);
}
await test.info().attach('resize-observer-errors.json', {
body: Buffer.from(JSON.stringify(resizeObserverErrors, null, 2)),
contentType: 'application/json',
});
});
}