fix(chat): size markdown table columns by content (#3268)

Thanks for fixing the cramped table columns and covering the streaming-to-settled transition. The content-sized layout and horizontal scrolling look ready to merge.
This commit is contained in:
ChangeHow
2026-09-05 10:46:22 +03:00
committed by GitHub
parent c5086e0250
commit df7e018e60
4 changed files with 218 additions and 6 deletions
@@ -12,6 +12,7 @@ type OperationCounts = {
replaceCalls: number;
removeCalls: number;
getBoundingClientRectCalls: number;
tableProbeReads: number;
viewBoxWrites: number;
resizeObserverCreates: number;
resizeObserverObserveCalls: number;
@@ -62,6 +63,7 @@ let windowInstance: Window;
let previousGlobals: Map<string, PropertyDescriptor | undefined>;
let activeCounts: OperationCounts | null = null;
let animationFrameQueue: FrameRequestCallback[] = [];
let tableProbeWidths: Map<string, number> | null = null;
let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) | null = null;
let MarkdownRenderer: React.ComponentType<{
content: string;
@@ -82,6 +84,7 @@ const makeCounts = (): OperationCounts => ({
replaceCalls: 0,
removeCalls: 0,
getBoundingClientRectCalls: 0,
tableProbeReads: 0,
viewBoxWrites: 0,
resizeObserverCreates: 0,
resizeObserverObserveCalls: 0,
@@ -239,11 +242,16 @@ const initializePerformanceDom = async (): Promise<void> => {
return originalRemove.call(this);
} });
const originalGetBoundingClientRect = elementPrototype.getBoundingClientRect;
Object.defineProperty(elementPrototype, 'getBoundingClientRect', { configurable: true, value: function (): DOMRect {
Object.defineProperty(elementPrototype, 'getBoundingClientRect', { configurable: true, value: function (this: Element): DOMRect {
if (activeCounts) {
activeCounts.getBoundingClientRectCalls += 1;
activeCounts.geometrySequence.push('read');
}
if (this.matches('table') && this.closest('[data-md-table-measure]')) {
if (activeCounts) activeCounts.tableProbeReads += 1;
const key = (this.textContent ?? '').trim();
return new windowInstance.DOMRect(0, 0, tableProbeWidths?.get(key) ?? 0, 20);
}
return originalGetBoundingClientRect.call(this);
} });
const svgSetAttribute = SVGElement.prototype.setAttribute;
@@ -318,6 +326,77 @@ afterAll(() => {
});
describe('MarkdownRenderer DOM mount performance contract', () => {
test('fixes body-sized table columns once the stream settles', async () => {
const content = [
'| An intentionally oversized header | Another oversized header | A third oversized header |',
'| --- | --- | --- |',
'| short | medium body value | very long body value |',
].join('\n');
tableProbeWidths = new Map([
['short', 48],
['medium body value', 186],
['very long body value', 800],
]);
const counts = makeCounts();
activeCounts = counts;
const host = document.createElement('div');
document.body.replaceChildren(host);
const root = createRoot(host);
const render = (isStreaming: boolean) => root.render(
<MarkdownRenderer
content={content}
messageId="table-width-message"
isAnimated={false}
isStreaming={isStreaming}
enableFileReferences={false}
/>,
);
try {
await act(async () => {
render(true);
await waitForSettledEffects();
});
await flushAnimationFrame();
expect(host.querySelector('[data-markdown="table"]')?.getAttribute('data-md-table-layout')).toBe('pending');
expect(counts.tableProbeReads).toBe(0);
await act(async () => {
render(false);
await waitForSettledEffects();
});
await flushAnimationFrame();
const table = host.querySelector<HTMLTableElement>('[data-markdown="table"]');
const cells = Array.from(table?.querySelectorAll('th, td') ?? []);
const columnWidths = Array.from(table?.querySelectorAll<HTMLTableColElement>('colgroup[data-md-table-columns] col') ?? [])
.map((column) => column.style.width);
const tableProbeReads = counts.tableProbeReads;
await flushAnimationFrame();
expect(table).not.toBeNull();
expect(table?.getAttribute('data-md-table-layout')).toBe('fixed');
expect(table?.style.tableLayout).toBe('fixed');
expect(table?.style.width).toBe('626px');
expect(columnWidths).toEqual(['120px', '186px', '320px']);
expect(table?.classList.contains('w-max')).toBe(true);
expect(table?.classList.contains('min-w-full')).toBe(false);
expect(table?.classList.contains('w-full')).toBe(false);
expect(table?.parentElement?.classList.contains('overflow-x-auto')).toBe(true);
expect(cells.length).toBeGreaterThan(0);
expect(cells.every((cell) => cell.classList.contains('min-w-[120px]'))).toBe(true);
expect(cells.every((cell) => cell.classList.contains('max-w-[320px]'))).toBe(true);
expect(cells.every((cell) => (
cell.classList.contains('whitespace-normal')
&& cell.classList.contains('[overflow-wrap:anywhere]')
))).toBe(true);
expect(counts.tableProbeReads).toBe(tableProbeReads);
} finally {
tableProbeWidths = null;
await act(async () => root.unmount());
}
});
test('builds Markdown sprite controls without parsing SVG markup', async () => {
const mounted = await mountFixture(1);
@@ -517,7 +596,8 @@ describe('MarkdownRenderer DOM mount performance contract', () => {
expect(metrics.innerHTMLWrites).toBeGreaterThan(0);
expect(metrics.querySelectorAllCalls).toBeGreaterThan(0);
expect(metrics.appendCalls).toBeGreaterThan(0);
expect(metrics.getBoundingClientRectCalls).toBe(metrics.mermaidRenderedCount);
expect(metrics.tableProbeReads).toBe(fixtureWorkload.rendererCount * 2);
expect(metrics.getBoundingClientRectCalls).toBe(metrics.mermaidRenderedCount + metrics.tableProbeReads);
expect(metrics.viewBoxWrites).toBe(metrics.mermaidRenderedCount);
expect(metrics.resizeObserverCreates).toBe(1);
expect(metrics.resizeObserverObserveCalls).toBe(metrics.mermaidRenderedCount);
@@ -129,6 +129,7 @@ const installRendererDom = () => {
setTimeout,
clearTimeout,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
@@ -288,6 +289,7 @@ mock.module('./markdown/decorate', () => ({
);
},
getMarkdownCodeText: () => '',
stabilizeMarkdownTableWidths: () => undefined,
}));
mock.module('./markdown/textPosition', () => ({ findTextPosition: () => null }));
mock.module('./markdown/mermaidViewer', () => ({
@@ -33,6 +33,7 @@ import {
applyMarkdownCodeBlockWrapState,
decorateMarkdown,
getMarkdownCodeText,
stabilizeMarkdownTableWidths,
type DecorateContext,
type DecorateLabels,
type MermaidControlOptions,
@@ -814,6 +815,7 @@ const useMorphdomMarkdown = ({
syntaxVars,
ctx,
domCacheKey,
tableLayoutSettled,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
text: string;
@@ -822,6 +824,7 @@ const useMorphdomMarkdown = ({
syntaxVars: Record<string, string>;
ctx: DecorateContext;
domCacheKey?: DetachedMarkdownDomKey | null;
tableLayoutSettled: boolean;
}) => {
React.useEffect(() => {
ensureMarkdownShikiTheme();
@@ -829,6 +832,7 @@ const useMorphdomMarkdown = ({
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
const renderRevisionRef = React.useRef(0);
const tableLayoutFrameRef = React.useRef<number | null>(null);
// A provisional first paint (blocks not in the settled cache) holds the
// timeline reveal until the async render lands, so the session opens with
// final code highlighting instead of a visible restyle.
@@ -859,6 +863,28 @@ const useMorphdomMarkdown = ({
}
mermaidViewerRef.current.refresh();
}, [containerRef]);
const scheduleTableLayout = React.useCallback(() => {
if (!tableLayoutSettled) return;
const previousFrame = tableLayoutFrameRef.current;
if (previousFrame !== null) window.cancelAnimationFrame(previousFrame);
const renderRevision = renderRevisionRef.current;
const frame = window.requestAnimationFrame(() => {
if (tableLayoutFrameRef.current !== frame) return;
tableLayoutFrameRef.current = null;
if (renderRevisionRef.current !== renderRevision) return;
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (target) stabilizeMarkdownTableWidths(target);
});
tableLayoutFrameRef.current = frame;
}, [containerRef, tableLayoutSettled]);
React.useEffect(() => () => {
const frame = tableLayoutFrameRef.current;
if (frame === null) return;
window.cancelAnimationFrame(frame);
tableLayoutFrameRef.current = null;
}, []);
React.useLayoutEffect(() => {
renderRevisionRef.current += 1;
@@ -984,6 +1010,7 @@ const useMorphdomMarkdown = ({
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
streamPerfCount('ui.markdown_renderer.settled_paint.reused');
scheduleTableLayout();
releaseRevealHold();
return;
}
@@ -1078,13 +1105,14 @@ const useMorphdomMarkdown = ({
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
scheduleTableLayout();
releaseRevealHold();
});
return () => {
active = false;
};
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, releaseRevealHold, streaming, text]);
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, releaseRevealHold, scheduleTableLayout, streaming, text]);
React.useEffect(() => {
const container = containerRef.current;
@@ -1203,6 +1231,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
syntaxVars,
ctx,
domCacheKey,
tableLayoutSettled: !isStreaming,
});
const markdownContent = (
@@ -1293,6 +1322,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
streaming: false,
syntaxVars,
ctx,
tableLayoutSettled: true,
});
return (
@@ -333,6 +333,10 @@ const buildTableMenu = (action: string, items: Array<{ key: string; label: strin
return menu;
};
const TABLE_COLUMN_MIN_WIDTH = 120;
const TABLE_COLUMN_MAX_WIDTH = 320;
const TABLE_LAYOUT_ATTR = 'data-md-table-layout';
const decorateTables = (root: HTMLElement, labels: DecorateLabels): void => {
const tables = root.querySelectorAll<HTMLTableElement>('table');
for (const table of Array.from(tables)) {
@@ -373,7 +377,8 @@ const decorateTables = (root: HTMLElement, labels: DecorateLabels): void => {
if (!parent) continue;
parent.replaceChild(wrapper, table);
table.setAttribute('data-markdown', 'table');
table.classList.add('w-full', 'border-collapse', 'text-sm');
table.setAttribute(TABLE_LAYOUT_ATTR, 'pending');
table.classList.add('w-max', 'border-collapse', 'text-sm');
for (const tr of Array.from(table.querySelectorAll('tr'))) {
tr.classList.add('border-b', 'border-border/60');
@@ -382,10 +387,10 @@ const decorateTables = (root: HTMLElement, labels: DecorateLabels): void => {
lastBodyRow?.classList.remove('border-b');
lastBodyRow?.classList.add('border-0');
for (const th of Array.from(table.querySelectorAll('th'))) {
th.classList.add('border-r', 'border-border/60', 'px-4', 'py-2.5', 'text-left', 'align-middle', 'font-semibold', 'text-foreground', 'last:border-r-0');
th.classList.add('min-w-[120px]', 'max-w-[320px]', 'whitespace-normal', '[overflow-wrap:anywhere]', 'border-r', 'border-border/60', 'px-4', 'py-2.5', 'text-left', 'align-middle', 'font-semibold', 'text-foreground', 'last:border-r-0');
}
for (const td of Array.from(table.querySelectorAll('td'))) {
td.classList.add('border-r', 'border-border/60', 'px-4', 'py-2.5', 'align-middle', 'text-foreground/90', 'last:border-r-0');
td.classList.add('min-w-[120px]', 'max-w-[320px]', 'whitespace-normal', '[overflow-wrap:anywhere]', 'border-r', 'border-border/60', 'px-4', 'py-2.5', 'align-middle', 'text-foreground/90', 'last:border-r-0');
}
scroll.appendChild(table);
@@ -394,6 +399,101 @@ const decorateTables = (root: HTMLElement, labels: DecorateLabels): void => {
}
};
export const stabilizeMarkdownTableWidths = (root: HTMLElement): void => {
const tables = Array.from(root.querySelectorAll<HTMLTableElement>(
`table[data-markdown="table"]:not([${TABLE_LAYOUT_ATTR}="fixed"])`,
));
if (tables.length === 0 || !root.isConnected) return;
const measurementRoot = root.ownerDocument.createElement('div');
measurementRoot.setAttribute('aria-hidden', 'true');
measurementRoot.setAttribute('data-md-table-measure', '');
measurementRoot.style.position = 'fixed';
measurementRoot.style.left = '-100000px';
measurementRoot.style.top = '0';
measurementRoot.style.visibility = 'hidden';
measurementRoot.style.pointerEvents = 'none';
measurementRoot.style.width = 'max-content';
const probes = tables.map((table) => {
const getRowCells = (row: HTMLTableRowElement): HTMLTableCellElement[] => (
Array.from(row.children).filter((child): child is HTMLTableCellElement => (
child.tagName === 'TH' || child.tagName === 'TD'
))
);
const bodyRows = Array.from(table.querySelectorAll<HTMLTableRowElement>('tbody tr'));
const sourceRows = bodyRows.some((row) => getRowCells(row).length > 0)
? bodyRows
: Array.from(table.querySelectorAll<HTMLTableRowElement>('thead tr'));
const columnCount = Math.max(
0,
...Array.from(table.querySelectorAll<HTMLTableRowElement>('tr')).map((row) => getRowCells(row).length),
);
const columnProbes: HTMLTableElement[] = [];
for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
const probeTable = root.ownerDocument.createElement('table');
probeTable.className = table.className;
probeTable.style.tableLayout = 'auto';
probeTable.style.width = 'max-content';
const probeBody = root.ownerDocument.createElement('tbody');
for (const row of sourceRows) {
const sourceCell = getRowCells(row)[columnIndex];
if (!sourceCell) continue;
const probeRow = root.ownerDocument.createElement('tr');
const probeCell = sourceCell.cloneNode(true);
if (!(probeCell instanceof HTMLElement)) continue;
probeCell.style.width = 'auto';
probeCell.style.minWidth = '0';
probeCell.style.maxWidth = 'none';
probeCell.style.whiteSpace = 'nowrap';
probeCell.style.overflowWrap = 'normal';
probeRow.appendChild(probeCell);
probeBody.appendChild(probeRow);
}
probeTable.appendChild(probeBody);
measurementRoot.appendChild(probeTable);
columnProbes.push(probeTable);
}
return { table, columnProbes };
});
root.appendChild(measurementRoot);
const plans = probes.map(({ table, columnProbes }) => ({
table,
widths: columnProbes.map((probe) => Math.min(
TABLE_COLUMN_MAX_WIDTH,
Math.max(TABLE_COLUMN_MIN_WIDTH, Math.ceil(probe.getBoundingClientRect().width)),
)),
}));
measurementRoot.remove();
for (const { table, widths } of plans) {
const existingColumns = Array.from(table.children).find((child) => (
child.matches('colgroup[data-md-table-columns]')
));
existingColumns?.remove();
const colgroup = root.ownerDocument.createElement('colgroup');
colgroup.setAttribute('data-md-table-columns', '');
for (const width of widths) {
const column = root.ownerDocument.createElement('col');
column.style.width = `${width}px`;
colgroup.appendChild(column);
}
const firstSection = Array.from(table.children).find((child) => (
child.tagName === 'THEAD' || child.tagName === 'TBODY' || child.tagName === 'TFOOT'
)) ?? null;
table.insertBefore(colgroup, firstSection);
table.style.tableLayout = 'fixed';
table.style.width = `${widths.reduce((total, width) => total + width, 0)}px`;
table.setAttribute(TABLE_LAYOUT_ATTR, 'fixed');
}
};
// ---------------------------------------------------------------------------
// Mermaid: replace ```mermaid code fences with rendered diagram blocks
// ---------------------------------------------------------------------------