/** * REGRESSION: `ChatMessageEnhanced` must hand the markdown engine a * REFERENTIALLY STABLE `componentOverrides` map across a streamed turn. * * The engine memoizes each COMPLETED block (`StreamingBlockRenderer`) on its * `components` prop identity. `ChatMessageEnhanced` is the sole production * caller, and it used to defeat that memo one level up: `cardComponentOverrides` * listed `renderingPlan` in its deps, `renderingPlan` is memoized on `segments`, * and the stream reducer replaces `segments` on EVERY text delta. So the * override map got a new identity per token → the engine's `components` memo * re-created → every completed block re-parsed on every token. * * The engine's own streaming test cannot catch this: it passes a module-scope * stable override map. This test drives the real component with the real * per-delta `content` churn a reducer produces, and asserts at the SEAM (the * props the component hands the renderer) rather than through the engine's * internals — the two are separately owned. */ import React from 'react' import { describe, it, expect, vi } from 'vitest' import { render } from '@testing-library/react' import type { MessageSegment } from '../types' /** Every `componentOverrides` / `additionalRemarkPlugins` identity the * component handed the markdown renderer, in render order. */ const seenOverrides: unknown[] = [] const seenPlugins: unknown[] = [] vi.mock('../../ui/markdown/simple-markdown-renderer', () => ({ SimpleMarkdownRenderer: ({ content, componentOverrides, additionalRemarkPlugins, }: { content: string componentOverrides?: unknown additionalRemarkPlugins?: unknown }) => { seenOverrides.push(componentOverrides) seenPlugins.push(additionalRemarkPlugins) return
{content}
}, })) import { ChatMessageEnhanced } from '../chat-message-enhanced' function NavLinkAnchor({ href, children }: React.AnchorHTMLAttributes) { return {children} } const renderMention = () => null /** Fresh array per call — the reducer replaces `segments` on every delta. */ const segmentsFor = (tail: string): MessageSegment[] => [ { type: 'text', text: `First paragraph, already complete.\n\n${tail}` }, ] describe('ChatMessageEnhanced — override identity across a streamed turn', () => { it('keeps componentOverrides + remark plugins referentially stable per token', () => { seenOverrides.length = 0 seenPlugins.length = 0 const props = { role: 'assistant' as const, isTyping: true, renderMention, NavLinkAnchor, } const view = render() expect(seenOverrides.length).toBeGreaterThan(0) const firstOverrides = seenOverrides[0] const firstPlugins = seenPlugins[0] expect(firstOverrides).toBeTruthy() let tail = 'Tail' for (let i = 0; i < 10; i += 1) { tail += ` token${i}` view.rerender() } // ONE identity for the whole turn. Before the fix this produced a fresh // object on each of the 11 renders. expect(new Set(seenOverrides).size).toBe(1) expect(seenOverrides.every((o) => o === firstOverrides)).toBe(true) expect(new Set(seenPlugins).size).toBe(1) expect(seenPlugins.every((p) => p === firstPlugins)).toBe(true) // Sanity: the tail really did stream through. expect(view.container.textContent).toContain('token9') }) it('surfaces the raw card id when the host renderer resolves nothing', () => { // The marker is the ONLY data channel now: with the host's // `renderEntityCard` returning null (no card type registered / nothing // to hydrate), the `` override must render the dim raw-id // fallback — never a real-looking card, never the literal marker URL. seenOverrides.length = 0 render( null} NavLinkAnchor={NavLinkAnchor} />, ) const overrides = seenOverrides[0] as { a: React.FC<{ href: string; children?: React.ReactNode }> } const out = render(<>{overrides.a({ href: 'card://blog:abc', children: 'x' })}) expect(out.container.textContent).toBe('abc') }) it('mounts the host card from the marker descriptor alone (hydrate-by-id)', () => { // No refs frame exists: the renderer receives the minimal // `{type, id, title: id, url: null}` descriptor built from the marker // and is expected to self-fetch. The rendered node is hoisted as a // block sibling of the text. const seenRefs: Array<{ type: string; id: string }> = [] const view = render( { seenRefs.push({ type: ref.type, id: ref.id }) return
card:{ref.id}
}} NavLinkAnchor={NavLinkAnchor} />, ) expect(seenRefs).toEqual([{ type: 'blog', id: 'abc' }]) expect(view.container.textContent).toContain('card:abc') }) })