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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user