From 33e83ba998c8ec94c9fe8631bdf7373a5194f9a8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 23 Sep 2026 19:07:16 -0700 Subject: [PATCH 1/2] improvement(chat): reduce streaming render work and completion delay --- .../components/chat-content/chat-content.tsx | 14 +-- .../chat-content/highlighted-lines.test.tsx | 83 +++++++++++++++++ .../chat-content/highlighted-lines.tsx | 47 ++++++++++ .../chat-content/remark-plain-text.test.ts | 71 +++++++++++++++ .../chat-content/remark-plain-text.ts | 39 ++++++++ apps/sim/hooks/use-smooth-text.test.tsx | 88 +++++++++++++++++++ apps/sim/hooks/use-smooth-text.ts | 42 +++++++-- 7 files changed, 374 insertions(+), 10 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 6d95322b2a6..93be09a70a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -11,7 +11,7 @@ import { useState, } from 'react' import type { Nodes } from 'hast' -import { defaultRehypePlugins, Streamdown } from 'streamdown' +import { defaultRehypePlugins, defaultRemarkPlugins, Streamdown } from 'streamdown' import 'streamdown/styles.css' // prismjs core must load before its language components — they register on the // global `Prism` it installs (on `window`/`global`); fixes SSR + client order. @@ -35,6 +35,8 @@ import { isInlineFileReference, } from '@/lib/mothership/chat/inline-image-reference' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' +import { HighlightedLines } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines' +import { remarkPlainText } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text' import { SourceChip, sourceLabel, @@ -66,6 +68,8 @@ const LANG_ALIASES: Record = { py: 'python', } +const MARKDOWN_REMARK_PLUGINS = [...Object.values(defaultRemarkPlugins), remarkPlainText] + const PROSE_CLASSES = cn( 'prose prose-base dark:prose-invert max-w-none', 'font-[family-name:var(--font-inter)] antialiased break-words tracking-[0]', @@ -308,10 +312,9 @@ const MARKDOWN_COMPONENTS = { />
-
+          
+            
+          
) @@ -704,6 +707,7 @@ function ChatContentInner({ isAnimating={streamingTree} components={MARKDOWN_COMPONENTS} rehypePlugins={imageRehypePlugins} + remarkPlugins={MARKDOWN_REMARK_PLUGINS} > {group.markdown} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.test.tsx new file mode 100644 index 00000000000..58fb7a0a1f7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.test.tsx @@ -0,0 +1,83 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import Prism from 'prismjs' +import { createRoot } from 'react-dom/client' +import 'prismjs/components/prism-typescript' +import { describe, expect, it } from 'vitest' +import { + HighlightedLines, + splitHighlightedLines, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines' + +/** Compare per-character token ancestry, ignoring unstyled line wrappers. */ +function styledCharacters(element: Element): Array<[string, string]> { + const result: Array<[string, string]> = [] + function visit(node: Node, classes: string[]) { + if (node.nodeType === Node.TEXT_NODE) { + for (const char of node.textContent ?? '') { + if (char !== '\n') result.push([char, classes.join('/')]) + } + } else if (node instanceof Element) { + const nextClasses = node.className ? [...classes, node.className] : classes + for (const child of node.childNodes) visit(child, nextClasses) + } + } + visit(element, []) + return result +} + +describe('highlighted code lines', () => { + it('preserves full-fence Prism text and token styles across multiline constructs', () => { + const cases = [ + `const value = \`first\nsecond \${1 + 2}\nthird\`\n`, + '/* a comment\nwith \n*/\nconst x = "safe"', + '
\n \n
', + 'const x = ""\n\nconst y = 1', + ] + for (const code of cases) { + for (let end = 1; end <= code.length; end++) { + const prefix = code.slice(0, end) + for (const language of ['typescript', 'markup']) { + const html = Prism.highlight(prefix, Prism.languages[language], language) + const before = document.createElement('pre') + before.innerHTML = html + const after = document.createElement('pre') + for (const line of splitHighlightedLines(html)) { + const span = document.createElement('span') + span.innerHTML = line + after.appendChild(span) + } + expect(after.textContent).toBe(before.textContent) + expect(styledCharacters(after)).toEqual(styledCharacters(before)) + expect(after.querySelector('script, img')).toBeNull() + } + } + } + }) + + it('retains unchanged token nodes and updates earlier lines when grammar changes', async () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('pre') + const root = createRoot(container) + const render = (code: string) => + act(async () => { + root.render( + + ) + }) + try { + await render('const first = 1\nconst second = 2') + const firstToken = container.querySelector('.token.keyword') + await render('const first = 1\nconst second = 234\nconst third = 3') + expect(container.querySelector('.token.keyword')).toBe(firstToken) + await render('/* first line\nsecond line') + await render('/* first line\nsecond line */\nconst end = 3') + expect(container.querySelector('.token.comment')?.textContent).toBe('/* first line') + expect(container.textContent).toBe('/* first line\nsecond line */\nconst end = 3') + } finally { + await act(async () => root.unmount()) + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.tsx new file mode 100644 index 00000000000..1045e4e1b14 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.tsx @@ -0,0 +1,47 @@ +import { memo } from 'react' + +/** + * Partition Prism output after highlighting the entire fence, so multiline + * grammar state stays intact. Reopen crossing spans on each line: React can + * retain unchanged line HTML instead of replacing every token in the fence. + * This accepts only Prism's generated spans, never raw code or arbitrary HTML. + */ +export function splitHighlightedLines(html: string): string[] { + const lines: string[] = [] + const openSpans: string[] = [] + let line = '' + let offset = 0 + for (const match of html.matchAll(/]*>|<\/span>|\n/g)) { + line += html.slice(offset, match.index) + const token = match[0] + offset = match.index + token.length + if (token === '\n') { + lines.push(`${line}${''.repeat(openSpans.length)}\n`) + line = openSpans.join('') + } else { + if (token === '') openSpans.pop() + else openSpans.push(token) + line += token + } + } + lines.push(line + html.slice(offset)) + return lines +} + +interface HighlightedLineProps { + html: string +} + +const HighlightedLine = memo(function HighlightedLine({ html }: HighlightedLineProps) { + return +}) + +interface HighlightedLinesProps { + html: string +} + +export function HighlightedLines({ html }: HighlightedLinesProps) { + return splitHighlightedLines(html).map((line, index) => ( + + )) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.test.ts new file mode 100644 index 00000000000..19e0eebbb50 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.test.ts @@ -0,0 +1,71 @@ +import remarkGfm from 'remark-gfm' +import remarkParse from 'remark-parse' +import { unified } from 'unified' +import { describe, expect, it } from 'vitest' +import { remarkPlainText } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text' + +const reference = unified().use(remarkParse).use(remarkGfm) +const optimized = unified().use(remarkParse).use(remarkGfm).use(remarkPlainText) + +describe('plain-paragraph parsing', () => { + it('matches remark positions, whitespace, and Unicode text', () => { + for (const text of [ + 'Hello world! ', + 'Hello ', + 'A café, 日本語, العربية, 😀 and nonbreaking\u00a0spaces.\u00a0', + 'A sentence with (parentheses), quotes, 1.23 and 20% off.', + '12)word', + 'Plain\u000btext\u000ctext', + ]) { + for (let end = 0; end <= text.length; end++) { + const prefix = text.slice(0, end) + expect(optimized.parse(prefix), JSON.stringify(prefix)).toEqual(reference.parse(prefix)) + } + } + }) + + it('preserves Markdown interpretation when a plain prefix gains syntax', () => { + const cases = [ + 'Text **bold**, _italic_, ~~deleted~~ and `code`.', + 'Text [link](https://example.com), ![image](files/chart.png).', + 'Text www.example.com, https://example.com and user@example.com.', + 'Text WWW.EXAMPLE.COM and foo@bar.test.', + 'Text & and A and raw.', + 'Text \\*escaped\\* and \\\nline break.', + 'Text \nline break.\r\n\r\nNext paragraph.', + 'Text\u0000replacement\tand tab', + '1. Ordered list\n2. Next item', + '1) Ordered list', + ' Indented text\n\n code', + '# Heading\n\n> Quote\n\n---\n\n- [x] task', + 'Heading\n=======\n\n| Table |\n| --- |\n| Cell |', + '[link][ref]\n\n[ref]: https://example.com', + '```typescript\nconst text = `multiline\nvalue`\n```', + '\ufeffText', + ] + for (const text of cases) { + for (let end = 0; end <= text.length; end++) { + const prefix = text.slice(0, end) + expect(optimized.parse(prefix), JSON.stringify(prefix)).toEqual(reference.parse(prefix)) + } + } + }) + + it('matches the reference on growing paragraphs and every ASCII character', () => { + const cases = [ + 'A plain paragraph describing the workflow. '.repeat(50), + '## Heading\n\nA paragraph.\n\n- A list item\n\n| Column |\n| --- |\n| Cell |\n\n'.repeat(30), + `\`\`\`typescript\n${'const value = items.map((item) => item.value)\n'.repeat(40)}\`\`\``, + 'A citation. {"url":"https://example.com"}\n\n'.repeat(30), + ] + for (const text of cases) { + for (let end = 7; end <= text.length; end += 7) { + expect(optimized.parse(text.slice(0, end))).toEqual(reference.parse(text.slice(0, end))) + } + } + for (let char = 0; char < 128; char++) { + const text = `Before ${String.fromCharCode(char)} after ` + expect(optimized.parse(text), JSON.stringify(text)).toEqual(reference.parse(text)) + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.ts new file mode 100644 index 00000000000..5ce5b7b2690 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.ts @@ -0,0 +1,39 @@ +import type { Root } from 'mdast' +import type { Plugin } from 'unified' + +/** + * A conservative shortcut for a single plain paragraph. Everything that could + * introduce Markdown, GFM autolinks, escapes, entities, or another block still + * goes through remark-parse. This changes only tokenization; the usual rehype, + * sanitization, components, and word-fade pipeline receive the same tree. + */ +export const remarkPlainText: Plugin<[], Root> = function remarkPlainText() { + const parse = this.parser + if (!parse) throw new Error('remarkPlainText requires remark-parse') + this.parser = (document, file) => { + if ( + !/^[A-Za-z0-9]/.test(document) || + /[\n\r\t\0\\`*_{}[\]<>~&#@:|]/.test(document) || + /^\d+[.)](?: |$)/.test(document) || + /www\./i.test(document) + ) { + return parse(document, file) + } + const value = document.replace(/ +$/, '') + const position = (end: number) => ({ + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: end + 1, offset: end }, + }) + return { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value, position: position(value.length) }], + position: position(document.length), + }, + ], + position: position(document.length), + } + } +} diff --git a/apps/sim/hooks/use-smooth-text.test.tsx b/apps/sim/hooks/use-smooth-text.test.tsx index c80221ddcef..6a02b70e336 100644 --- a/apps/sim/hooks/use-smooth-text.test.tsx +++ b/apps/sim/hooks/use-smooth-text.test.tsx @@ -23,9 +23,11 @@ function renderSmoothText(initial: ProbeProps) { const root: Root = createRoot(container) const props = { ...initial } let latest = '' + const values: string[] = [] function Probe(p: ProbeProps) { latest = useSmoothText(p.content, p.isStreaming, { snapOnNonAppend: p.snapOnNonAppend }) + values.push(latest) return null } @@ -37,6 +39,7 @@ function renderSmoothText(initial: ProbeProps) { return { value: () => latest, + values: () => values, rerender: (next: Partial) => { Object.assign(props, next) render() @@ -90,6 +93,91 @@ describe('useSmoothText — streaming that begins on an already-open document', }) }) +describe('useSmoothText — frame cadence and completion', () => { + let now = 0 + let frameId = 0 + const frames = new Map() + + beforeEach(() => { + now = 0 + frameId = 0 + frames.clear() + vi.spyOn(performance, 'now').mockImplementation(() => now) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.set(++frameId, callback) + return frameId + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + function frame(dt: number) { + now += dt + const callbacks = [...frames.values()] + frames.clear() + act(() => { + for (const callback of callbacks) callback(now) + }) + } + + function advance(duration: number, refreshRate = 120) { + for (let elapsed = 0; elapsed < duration; elapsed += 1000 / refreshRate) { + frame(1000 / refreshRate) + } + } + + it.each([60, 90, 120, 144])('paces at most 60 React updates/sec on a %s Hz display', (rate) => { + const probe = renderSmoothText({ content: '', isStreaming: true }) + probe.rerender({ content: 'word '.repeat(2000) }) + const before = probe.values().length + advance(1000, rate) + const updates = probe.values().slice(before) + expect(updates.length).toBeGreaterThanOrEqual(58) + expect(updates.length).toBeLessThanOrEqual(61) + expect(probe.value().length).toBeGreaterThan(2300) + expect(probe.value().length).toBeLessThan(2450) + for (let index = 1; index < updates.length; index++) { + expect(updates[index].startsWith(updates[index - 1])).toBe(true) + expect(updates[index].endsWith(' ')).toBe(true) + } + probe.unmount() + expect(frames.size).toBe(0) + }) + + it.each(['The end.', 'word '.repeat(200), 'word '.repeat(10000)])( + 'finishes buffered text over one horizon without snapping or a slow last word', + (content) => { + const probe = renderSmoothText({ content: '', isStreaming: true }) + probe.rerender({ content }) + advance(50) + const before = probe.value() + probe.rerender({ isStreaming: false }) + expect(probe.value()).toBe(before) + advance(100) + expect(probe.value().length).toBeLessThan(content.length) + advance(320) + expect(probe.value()).toBe(content) + probe.unmount() + } + ) + + it('does not bank a background-tab pause as reveal time', () => { + const probe = renderSmoothText({ content: '', isStreaming: true }) + const content = 'word '.repeat(1000) + probe.rerender({ content, isStreaming: false }) + frame(30_000) + expect(probe.value().length).toBeGreaterThan(0) + expect(probe.value().length).toBeLessThan(content.length) + advance(320) + expect(probe.value()).toBe(content) + probe.unmount() + }) +}) + describe('snapAllSmoothText — user Stop must end the paced reveal instantly', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/apps/sim/hooks/use-smooth-text.ts b/apps/sim/hooks/use-smooth-text.ts index 0b38aa11b47..ff2c2b53fd5 100644 --- a/apps/sim/hooks/use-smooth-text.ts +++ b/apps/sim/hooks/use-smooth-text.ts @@ -46,6 +46,8 @@ const DRAIN_HORIZON_MS = 400 const MIN_CPS = 45 /** Cap so a huge backlog (resume, giant paste) sweeps in over ~a second. */ const MAX_CPS = 2400 +/** Keep React/Markdown work at 60 updates/sec even on high-refresh displays. */ +const PUBLISH_INTERVAL_MS = 1000 / 60 /** Chars/second that drains `remaining` over the horizon, clamped. */ function drainRate(remaining: number): number { @@ -97,8 +99,8 @@ interface SmoothTextOptions { * * Content that is already complete at mount (history, or a resume past * {@link RESUME_SKIP_THRESHOLD}) is returned in full and never animates. When a - * live stream ends mid-reveal the remaining tail keeps draining at the paced - * cadence rather than snapping — so the reveal stays smooth right to the end and + * live stream ends mid-reveal the remaining tail drains over one fixed horizon + * rather than an exponentially slowing tail — so the reveal stays smooth and * the caller can hold its streaming render until `useSmoothText` reports the * full string, avoiding a flash on the streaming→static handoff. * @@ -133,6 +135,8 @@ export function useSmoothText( /** Fractional character budget carried between frames (see the frame loop). */ const budgetRef = useRef(0) const lastFrameAtRef = useRef(0) + const publishBudgetMsRef = useRef(0) + const completionRemainingMsRef = useRef(null) const prevContentRef = useRef(content) const prevIsStreamingRef = useRef(isStreaming) @@ -184,6 +188,11 @@ export function useSmoothText( // `prev` and skip the snap. Updating them in a committed effect keeps `prev` in lockstep with the // render that actually committed, so the snap decision is identical across discarded attempts. useEffect(() => { + if (isStreaming) { + completionRemainingMsRef.current = null + } else if (content !== prevContentRef.current || prevIsStreamingRef.current) { + completionRemainingMsRef.current = DRAIN_HORIZON_MS + } prevContentRef.current = content prevIsStreamingRef.current = isStreaming }, [content, isStreaming]) @@ -212,11 +221,33 @@ export function useSmoothText( // Clamp dt so a background tab's paused rAF doesn't bank a giant budget. const dt = Math.min(now - lastFrameAtRef.current, 100) lastFrameAtRef.current = now - budgetRef.current += (drainRate(target - current) * dt) / 1000 + const completionRemaining = completionRemainingMsRef.current + const rate = + completionRemaining === null + ? drainRate(target - current) + : Math.max( + MIN_CPS, + ((target - current - budgetRef.current) * 1000) / Math.max(1, completionRemaining) + ) + budgetRef.current += (rate * dt) / 1000 + publishBudgetMsRef.current += dt + if (completionRemaining !== null) { + completionRemainingMsRef.current = Math.max(0, completionRemaining - dt) + } - const next = nextIndex(text, current, budgetRef.current) + const finishing = completionRemainingMsRef.current === 0 + const canPublish = finishing || publishBudgetMsRef.current >= PUBLISH_INTERVAL_MS - 0.5 + const next = finishing + ? target + : canPublish + ? nextIndex(text, current, budgetRef.current) + : current if (next > current) { - budgetRef.current -= next - current + budgetRef.current = Math.max(0, budgetRef.current - (next - current)) + publishBudgetMsRef.current = + publishBudgetMsRef.current < PUBLISH_INTERVAL_MS + ? 0 + : publishBudgetMsRef.current % PUBLISH_INTERVAL_MS revealedRef.current = next setRevealed(next) } @@ -227,6 +258,7 @@ export function useSmoothText( if (hasBacklog && rafRef.current === null) { lastFrameAtRef.current = performance.now() + publishBudgetMsRef.current = 0 rafRef.current = requestAnimationFrame(run) } }) From a545a90d15dd6de2b2bced6130d8508dccaaf8e2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 23 Sep 2026 19:29:07 -0700 Subject: [PATCH 2/2] chore(chat): align streaming constants with type conventions --- .../components/chat-content/chat-content.tsx | 5 ++++- .../components/chat-content/highlighted-lines.test.tsx | 4 ++-- .../components/chat-content/remark-plain-text.test.ts | 6 +++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 93be09a70a8..6e3a0594ab2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -68,7 +68,10 @@ const LANG_ALIASES: Record = { py: 'python', } -const MARKDOWN_REMARK_PLUGINS = [...Object.values(defaultRemarkPlugins), remarkPlainText] +const MARKDOWN_REMARK_PLUGINS = [ + ...Object.values(defaultRemarkPlugins), + remarkPlainText, +] as const satisfies NonNullable['remarkPlugins']> const PROSE_CLASSES = cn( 'prose prose-base dark:prose-invert max-w-none', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.test.tsx index 58fb7a0a1f7..80bb289de80 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines.test.tsx @@ -33,11 +33,11 @@ describe('highlighted code lines', () => { '/* a comment\nwith \n*/\nconst x = "safe"', '
\n \n
', 'const x = ""\n\nconst y = 1', - ] + ] as const for (const code of cases) { for (let end = 1; end <= code.length; end++) { const prefix = code.slice(0, end) - for (const language of ['typescript', 'markup']) { + for (const language of ['typescript', 'markup'] as const) { const html = Prism.highlight(prefix, Prism.languages[language], language) const before = document.createElement('pre') before.innerHTML = html diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.test.ts index 19e0eebbb50..9fefaaa7c77 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text.test.ts @@ -16,7 +16,7 @@ describe('plain-paragraph parsing', () => { 'A sentence with (parentheses), quotes, 1.23 and 20% off.', '12)word', 'Plain\u000btext\u000ctext', - ]) { + ] as const) { for (let end = 0; end <= text.length; end++) { const prefix = text.slice(0, end) expect(optimized.parse(prefix), JSON.stringify(prefix)).toEqual(reference.parse(prefix)) @@ -42,7 +42,7 @@ describe('plain-paragraph parsing', () => { '[link][ref]\n\n[ref]: https://example.com', '```typescript\nconst text = `multiline\nvalue`\n```', '\ufeffText', - ] + ] as const for (const text of cases) { for (let end = 0; end <= text.length; end++) { const prefix = text.slice(0, end) @@ -57,7 +57,7 @@ describe('plain-paragraph parsing', () => { '## Heading\n\nA paragraph.\n\n- A list item\n\n| Column |\n| --- |\n| Cell |\n\n'.repeat(30), `\`\`\`typescript\n${'const value = items.map((item) => item.value)\n'.repeat(40)}\`\`\``, 'A citation. {"url":"https://example.com"}\n\n'.repeat(30), - ] + ] as const for (const text of cases) { for (let end = 7; end <= text.length; end += 7) { expect(optimized.parse(text.slice(0, end))).toEqual(reference.parse(text.slice(0, end)))