From df7e018e6061f9b1e3e22710b335b3c55f4c32b0 Mon Sep 17 00:00:00 2001 From: ChangeHow <23733347+ChangeHow@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:46:22 +0800 Subject: [PATCH] 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. --- .../MarkdownRendererImpl.performance.test.tsx | 84 +++++++++++++- .../chat/MarkdownRendererImpl.test.ts | 2 + .../components/chat/MarkdownRendererImpl.tsx | 32 +++++- .../src/components/chat/markdown/decorate.ts | 106 +++++++++++++++++- 4 files changed, 218 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx index 3151033c..dae0a10d 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx @@ -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; let activeCounts: OperationCounts | null = null; let animationFrameQueue: FrameRequestCallback[] = []; +let tableProbeWidths: Map | 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 => { 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( + , + ); + + 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('[data-markdown="table"]'); + const cells = Array.from(table?.querySelectorAll('th, td') ?? []); + const columnWidths = Array.from(table?.querySelectorAll('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); diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts index a57e6710..b6f9b734 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts @@ -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', () => ({ diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index b6279b24..8b2a57ec 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -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; text: string; @@ -822,6 +824,7 @@ const useMorphdomMarkdown = ({ syntaxVars: Record; ctx: DecorateContext; domCacheKey?: DetachedMarkdownDomKey | null; + tableLayoutSettled: boolean; }) => { React.useEffect(() => { ensureMarkdownShikiTheme(); @@ -829,6 +832,7 @@ const useMorphdomMarkdown = ({ const mermaidViewerRef = React.useRef | null>(null); const renderRevisionRef = React.useRef(0); + const tableLayoutFrameRef = React.useRef(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('[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 = ({ syntaxVars, ctx, domCacheKey, + tableLayoutSettled: !isStreaming, }); const markdownContent = ( @@ -1293,6 +1322,7 @@ const SimpleMarkdownRendererImpl: React.FC<{ streaming: false, syntaxVars, ctx, + tableLayoutSettled: true, }); return ( diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts index 1ab6a6bd..bcfe02d0 100644 --- a/packages/ui/src/components/chat/markdown/decorate.ts +++ b/packages/ui/src/components/chat/markdown/decorate.ts @@ -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('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( + `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('tbody tr')); + const sourceRows = bodyRows.some((row) => getRowCells(row).length > 0) + ? bodyRows + : Array.from(table.querySelectorAll('thead tr')); + const columnCount = Math.max( + 0, + ...Array.from(table.querySelectorAll('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 // ---------------------------------------------------------------------------