A multi-MB message (logged bundle, huge tool dump) froze the renderer before any paint: Streamdown runs `preprocess` + `marked` lex over the whole string synchronously in a useMemo, an uninterruptible long task that no try/catch or content-visibility can help (our JS runs before the browser ever skips layout). Tiered fix: - Message gate: past 200KB, bypass markdown entirely and render the raw text in `content-visibility:auto` line-chunks — synchronous work is bounded to a string split, the browser virtualizes layout natively, and every line stays in the DOM (selectable, find-in-page). - Code-block budget: past 3k lines / 150KB, skip Shiki (which emits a span per token) and render plain, chunked the same way. - Collapse/expand: a reusable ExpandableBlock clamps code blocks and the huge-text fallback to a 120px preview with a gradient + chevron, expanding to 300px. The inner element is always a scroll container so the content-visibility chunks stay lazily laid out in both states. No content is ever dropped; the copy button (card header) always yields the full block.
53 lines
1.5 KiB
TypeScript
53 lines
1.5 KiB
TypeScript
'use client'
|
|
|
|
import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'
|
|
|
|
import { ChevronDown } from '@/lib/icons'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface ExpandableBlockProps {
|
|
children: ReactNode
|
|
className?: string
|
|
}
|
|
|
|
export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
|
|
const innerRef = useRef<HTMLDivElement>(null)
|
|
const [expanded, setExpanded] = useState(false)
|
|
const [overflowing, setOverflowing] = useState(false)
|
|
|
|
useLayoutEffect(() => {
|
|
const el = innerRef.current
|
|
|
|
if (!el) {return}
|
|
|
|
const measure = () => setOverflowing(el.scrollHeight > 121)
|
|
measure()
|
|
const observer = new ResizeObserver(measure)
|
|
observer.observe(el)
|
|
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div
|
|
className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
|
|
ref={innerRef}
|
|
>
|
|
{children}
|
|
</div>
|
|
{overflowing && (
|
|
<button
|
|
aria-expanded={expanded}
|
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
|
|
onClick={() => setExpanded(v => !v)}
|
|
type="button"
|
|
>
|
|
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|