Skip to content

Commit 2e5a4db

Browse files
committed
fix: table re-rendering during stream
in safari this meant the animations started over on every tick
1 parent f709a5f commit 2e5a4db

File tree

3 files changed

+116
-29
lines changed

3 files changed

+116
-29
lines changed

packages/ai-chat-components/src/components/markdown/src/markdown-renderer.ts

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,13 @@ export interface RenderTokenTreeOptions {
116116
tooltipContent?: string;
117117
/** Function to get formatted line count text */
118118
getLineCountText?: ({ count }: { count: number }) => string;
119-
}
120119

121-
const EMPTY_ATTRS = {};
122-
const EMPTY_TABLE_HEADERS: TableCellContent[] = [];
123-
const EMPTY_TABLE_ROWS: TableRowContent[] = [];
120+
/**
121+
* Force markdown tables to render in loading mode.
122+
* Useful for freezing streaming table visuals until stream completion.
123+
*/
124+
forceTableLoading?: boolean;
125+
}
124126

125127
/**
126128
* Converts TokenTree to Lit TemplateResult.
@@ -432,6 +434,7 @@ function renderWithStaticTag(
432434

433435
const {
434436
streaming,
437+
forceTableLoading,
435438
context: parentContext,
436439
filterPlaceholderText,
437440
previousPageText,
@@ -444,8 +447,9 @@ function renderWithStaticTag(
444447
} = options;
445448

446449
// Determine if we should show loading state during streaming
447-
let isLoading = false;
450+
let isLoading = Boolean(forceTableLoading);
448451
if (
452+
!isLoading &&
449453
streaming &&
450454
parentContext?.parentChildren &&
451455
parentContext?.currentIndex !== undefined
@@ -482,33 +486,28 @@ function renderWithStaticTag(
482486
: null,
483487
});
484488

485-
// Extract table data or use empty placeholders for loading state
486-
let headers: TableCellContent[];
487-
let tableRows: TableRowContent[];
488-
489-
if (!isLoading) {
490-
const extractedData = extractTableData(node);
489+
if (isLoading) {
490+
// Keep loading output stable during streaming table assembly.
491+
return html`<div class="cds-aichat-table-holder">
492+
<cds-aichat-table .loading=${true}></cds-aichat-table>
493+
</div>`;
494+
}
491495

492-
headers = extractedData.headers.map((cell) =>
493-
createCellContent(cell, { isInThead: true }),
494-
);
496+
const extractedData = extractTableData(node);
495497

496-
tableRows = extractedData.rows.map((row) => ({
497-
cells: row.map((cell) => createCellContent(cell)),
498-
}));
499-
} else {
500-
// Use static empty arrays to prevent re-renders during streaming
501-
headers = EMPTY_TABLE_HEADERS;
502-
tableRows = EMPTY_TABLE_ROWS;
503-
}
498+
const headers: TableCellContent[] = extractedData.headers.map((cell) =>
499+
createCellContent(cell, { isInThead: true }),
500+
);
504501

505-
const tableAttrs = isLoading ? EMPTY_ATTRS : attrs;
502+
const tableRows: TableRowContent[] = extractedData.rows.map((row) => ({
503+
cells: row.map((cell) => createCellContent(cell)),
504+
}));
506505

507506
return html`<div class="cds-aichat-table-holder">
508507
<cds-aichat-table
509508
.headers=${headers}
510509
.rows=${tableRows}
511-
.loading=${isLoading}
510+
.loading=${false}
512511
.filterPlaceholderText=${filterPlaceholderText || "Filter table..."}
513512
.previousPageText=${previousPageText || "Previous page"}
514513
.nextPageText=${nextPageText || "Next page"}
@@ -519,7 +518,7 @@ function renderWithStaticTag(
519518
DEFAULT_PAGINATION_SUPPLEMENTAL_TEXT}
520519
.getPaginationStatusText=${getPaginationStatusText ||
521520
DEFAULT_PAGINATION_STATUS_TEXT}
522-
...=${tableAttrs}
521+
...=${attrs}
523522
></cds-aichat-table>
524523
</div>`;
525524
}

packages/ai-chat-components/src/components/markdown/src/markdown.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ import { renderTokenTree } from "./markdown-renderer.js";
2020
import { consoleError } from "./utils.js";
2121
import { markdownTemplate } from "./markdown.template.js";
2222

23+
function hasTrailingTableToken(node: TokenTree): boolean {
24+
const children = node.children || [];
25+
for (let index = 0; index < children.length; index++) {
26+
const child = children[index];
27+
if (child.token.tag === "table" && index === children.length - 1) {
28+
return true;
29+
}
30+
if (hasTrailingTableToken(child)) {
31+
return true;
32+
}
33+
}
34+
return false;
35+
}
36+
2337
/**
2438
* Markdown component
2539
* @element cds-aichat-markdown
@@ -144,6 +158,8 @@ class CDSAIChatMarkdown extends LitElement {
144158
*/
145159
private renderTask: Promise<void> | null = null;
146160

161+
private hasRenderedStreamingTableLoadingFrame = false;
162+
147163
connectedCallback() {
148164
super.connectedCallback();
149165
this.needsReparse = true;
@@ -235,23 +251,61 @@ class CDSAIChatMarkdown extends LitElement {
235251
*/
236252
private renderMarkdown = async () => {
237253
try {
254+
let nextTokenTree = this.tokenTree;
255+
238256
if (this.needsReparse) {
239257
// First, we take the markdown we were given and use the markdown-it parser to turn is into a tree we can
240258
// transform into Lit components and compare smartly to avoid re-renders of components that were already
241259
// rendered when the markdown is updated (likely by streaming, but possibly by an edit somewhere in the
242260
// middle). It takes the current tokenTree as an argument for quick diffing to avoid re-creating parts
243261
// of the tree.
244-
this.tokenTree = markdownToTokenTree(
262+
nextTokenTree = markdownToTokenTree(
245263
this._slottedMarkdown,
246264
this.tokenTree,
247265
!this.removeHTML,
248266
);
249267
this.needsReparse = false;
250268
}
251269

270+
const hasStreamingTailTable =
271+
Boolean(this.streaming) && hasTrailingTableToken(nextTokenTree);
272+
273+
if (nextTokenTree !== this.tokenTree) {
274+
this.tokenTree = nextTokenTree;
275+
}
276+
277+
if (hasStreamingTailTable) {
278+
if (!this.hasRenderedStreamingTableLoadingFrame) {
279+
this.renderedContent = renderTokenTree(nextTokenTree, {
280+
sanitize: this.sanitizeHTML,
281+
streaming: this.streaming,
282+
highlight: this.highlight,
283+
// Table strings
284+
filterPlaceholderText: this.filterPlaceholderText,
285+
previousPageText: this.previousPageText,
286+
nextPageText: this.nextPageText,
287+
itemsPerPageText: this.itemsPerPageText,
288+
downloadLabelText: this.downloadLabelText,
289+
locale: this.locale,
290+
getPaginationSupplementalText: this.getPaginationSupplementalText,
291+
getPaginationStatusText: this.getPaginationStatusText,
292+
// Code snippet strings
293+
feedback: this.feedback,
294+
showLessText: this.showLessText,
295+
showMoreText: this.showMoreText,
296+
tooltipContent: this.tooltipContent,
297+
getLineCountText: this.getLineCountText,
298+
});
299+
this.hasRenderedStreamingTableLoadingFrame = true;
300+
}
301+
return;
302+
}
303+
304+
this.hasRenderedStreamingTableLoadingFrame = false;
305+
252306
// Next we take that tree and transform it into Lit content to be rendered into the template.
253307
// this.renderedContent is what is rendered in the template directly.
254-
this.renderedContent = renderTokenTree(this.tokenTree, {
308+
this.renderedContent = renderTokenTree(nextTokenTree, {
255309
sanitize: this.sanitizeHTML,
256310
streaming: this.streaming,
257311
highlight: this.highlight,

packages/ai-chat-components/src/components/table/src/table.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ class CDSAIChatTable extends LitElement {
228228
* @internal
229229
*/
230230
private tableRuntimePromise: Promise<TableRuntimeModule> | null = null;
231+
private hasRenderedLoadingFrame = false;
231232

232233
connectedCallback() {
233234
super.connectedCallback();
@@ -248,6 +249,25 @@ class CDSAIChatTable extends LitElement {
248249
this._setPageSize();
249250
}
250251

252+
/**
253+
* While loading we only need to paint the skeleton once. Streaming updates can
254+
* mutate rows/headers rapidly, so skip subsequent updates until loading ends.
255+
*/
256+
protected shouldUpdate(changedProperties: PropertyValues<this>) {
257+
if (changedProperties.has("loading")) {
258+
if (this.loading) {
259+
this.hasRenderedLoadingFrame = false;
260+
}
261+
return true;
262+
}
263+
264+
if (this.loading && this.hasRenderedLoadingFrame) {
265+
return false;
266+
}
267+
268+
return true;
269+
}
270+
251271
/**
252272
* Updates the CSS custom property `--cds-chat-table-width` based on the parent element's width.
253273
* Also calculates the default page size on first run based on the measured width.
@@ -286,19 +306,31 @@ class CDSAIChatTable extends LitElement {
286306
* @protected
287307
*/
288308
protected willUpdate(changedProperties: PropertyValues<this>) {
309+
const loadingJustFinished =
310+
changedProperties.has("loading") &&
311+
changedProperties.get("loading") === true &&
312+
this.loading === false;
313+
289314
// If the headers or rows has recently updated and both are defined than we should validate the table
290315
// data. This will likely only happen on the web components first render cycle when the props go from undefined to
291316
// defined.
292317
if (
293-
(changedProperties.has("headers") || changedProperties.has("rows")) &&
318+
(changedProperties.has("headers") ||
319+
changedProperties.has("rows") ||
320+
loadingJustFinished) &&
321+
!this.loading &&
294322
this.headers !== undefined &&
295323
this.rows !== undefined
296324
) {
297325
this._calcIsTableValid();
298326
}
299327

300328
// If the value of tableRows updated then initialize the internal rows arrays.
301-
if (changedProperties.has("rows") && this.rows !== undefined) {
329+
if (
330+
(changedProperties.has("rows") || loadingJustFinished) &&
331+
!this.loading &&
332+
this.rows !== undefined
333+
) {
302334
this._initializeRowsArrays();
303335
this._setPageSize();
304336
}
@@ -579,11 +611,13 @@ class CDSAIChatTable extends LitElement {
579611
// This could be used while we wait for a md stream containing a table to complete.
580612
const runtime = this.tableRuntime;
581613
if (this.loading || !runtime) {
614+
this.hasRenderedLoadingFrame = true;
582615
if (!runtime) {
583616
void this.ensureTableRuntime();
584617
}
585618
return tableSkeletonTemplate(this._currentPageSize);
586619
}
620+
this.hasRenderedLoadingFrame = false;
587621

588622
const { tableTemplate, tablePaginationTemplate } = runtime;
589623

0 commit comments

Comments
 (0)