Просмотр исходного кода

fix(client): isolate JSON copy feedback and guard string measurement

_Kerman 4 недель назад
Родитель
Сommit
e7fc4e8fdc

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: d783a2d4eaaac887c5357fe30efc5e47249066be
-README.zh.md: 33dc17db52e7263aa6aab81549a3b95f7dbfc59d
+README.md: 888662a9e1150b3cf410f2f8202d0f27cba0e961
+README.zh.md: c1d5a4c45ff364925bb66506c0ea1bf8dae0df5d

+ 2 - 0
packages/client/ui-primitives/README.md

@@ -74,6 +74,8 @@ The catalog above lists what each export is for; this section covers the behavio
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
 
 
+`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree.
+
 ### Localizing copy
 
 The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.

+ 2 - 0
packages/client/ui-primitives/README.zh.md

@@ -74,6 +74,8 @@ kind: "package-library"
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
 
 
+`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
+
 ### 本地化文案
 
 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。

+ 139 - 88
packages/client/ui-primitives/src/JsonTree.tsx

@@ -1,5 +1,5 @@
 import clsx from 'clsx'
-import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
 import type {
   CSSProperties,
   KeyboardEvent as ReactKeyboardEvent,
@@ -65,6 +65,105 @@ interface RowTarget {
   value: unknown
 }
 
+type CopyMode = 'json' | 'path' | 'prettyJson' | 'value'
+
+interface CopySnapshot {
+  id: string
+  target: RowTarget
+  state: 'idle' | 'copied' | 'failed'
+  menuOpen: boolean
+}
+
+/** Notify only the old and new row actions; JSON values do not subscribe to hover state. */
+function createCopyStore() {
+  let current: CopySnapshot | undefined
+  const listeners = new Map<string, Set<() => void>>()
+  return {
+    get: () => current,
+    set(next: CopySnapshot | undefined) {
+      const previous = current?.id
+      current = next
+      for (const id of new Set([previous, next?.id])) {
+        if (id === undefined) continue
+        for (const listener of listeners.get(id) ?? []) listener()
+      }
+    },
+    subscribe(id: string, listener: () => void) {
+      let row = listeners.get(id)
+      if (row === undefined) listeners.set(id, row = new Set())
+      row.add(listener)
+      return () => {
+        row.delete(listener)
+        if (row.size === 0) listeners.delete(id)
+      }
+    },
+  }
+}
+
+function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }: {
+  store: ReturnType<typeof createCopyStore>
+  target: RowTarget
+  persistent: boolean
+  labels: JsonTreeLabels
+  onCopy: (target: RowTarget, mode: CopyMode) => Promise<void>
+  onClose: () => void
+}) {
+  const id = pathId(target.path)
+  const subscribe = useCallback((listener: () => void) => store.subscribe(id, listener), [id, store])
+  const getSnapshot = () => {
+    const current = store.get()
+    return current?.id === id ? current : undefined
+  }
+  const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
+  const buttonRef = useRef<HTMLButtonElement>(null)
+  const state = snapshot?.state ?? 'idle'
+  const object = typeof target.value === 'object' && target.value !== null
+  const copyTitle = state === 'copied'
+    ? labels.copied
+    : state === 'failed'
+      ? labels.copyFailed
+      : object ? labels.copyPrettyJson : labels.copyValue
+  return (
+    <span className={css.copySlot}>
+      {(persistent || snapshot !== undefined) && (
+        <Menu
+          open={snapshot?.menuOpen === true}
+          compact
+          portal
+          align="end"
+          anchor={(
+            <button
+              ref={buttonRef}
+              type="button"
+              className={css.actionButton}
+              data-json-copy-button
+              data-state={state}
+              aria-label={copyTitle}
+              title={labels.copyButtonTitle(copyTitle)}
+              onClick={() => void onCopy(target, object ? 'prettyJson' : 'value')}
+              onContextMenu={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+                store.set({ id, target, state, menuOpen: true })
+              }}
+            >
+              {state === 'copied'
+                ? <IconCheckOutline16 size={12} />
+                : <IconCopyOutline16 size={12} />}
+            </button>
+          )}
+          items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
+          onSelect={(mode) => {
+            void onCopy(target, mode as CopyMode)
+          }}
+          onClose={onClose}
+          getAnchorRect={() => (buttonRef.current as HTMLButtonElement).getBoundingClientRect()}
+        />
+      )}
+    </span>
+  )
+}
+
 function isExpandableValue(value: unknown): value is object | unknown[] {
   return typeof value === 'object' && value !== null && !(value instanceof Date)
 }
@@ -273,6 +372,8 @@ function JsonString({
   useLayoutEffect(() => {
     if (!expanded) return
     const raw = rawRef.current as HTMLPreElement
+    // Keep raw text within the window and clipping ancestors outside the tree.
+    // Capture scrolling because an ancestor can move the string without resizing it.
     const clips: HTMLElement[] = []
     const tree = raw.closest<HTMLElement>(`.${css.root}`) as HTMLElement
     for (let parent = tree.parentElement; parent !== null; parent = parent.parentElement) {
@@ -292,13 +393,13 @@ function JsonString({
       raw.style.maxHeight = `${Math.max(16, available - 4)}px`
     }
     measure()
-    const observer = new ResizeObserver(measure)
-    observer.observe(raw)
-    for (const clip of clips) observer.observe(clip)
+    const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
+    observer?.observe(raw)
+    for (const clip of clips) observer?.observe(clip)
     window.addEventListener('resize', measure)
     window.addEventListener('scroll', measure, true)
     return () => {
-      observer.disconnect()
+      observer?.disconnect()
       window.removeEventListener('resize', measure)
       window.removeEventListener('scroll', measure, true)
     }
@@ -319,6 +420,7 @@ function JsonString({
         >
           {value}
         </pre>
+        {!lastElement && <span className={css.punctuation}>,</span>}
         <div className={css.stringActions}>
           {stringWrapping !== undefined && (
             <button
@@ -539,7 +641,7 @@ function formattedPath(path: JsonPath): string {
   }, '$')
 }
 
-function copyText(target: RowTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
+function copyText(target: RowTarget, mode: CopyMode): string {
   if (mode === 'path') return formattedPath(target.path)
   if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
   if (mode === 'json') return JSON.stringify(target.value)
@@ -604,12 +706,9 @@ export function JsonTree({
       : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
     : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
   const activeRowRef = useRef<HTMLElement>()
-  const copyButtonRef = useRef<HTMLButtonElement | null>(null)
-  const copyMenuOpenRef = useRef(false)
   const resetTimer = useRef<ReturnType<typeof setTimeout>>()
-  const [copyTarget, setCopyTarget] = useState<RowTarget>()
-  const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
-  const [copyMenuOpen, setCopyMenuOpen] = useState(false)
+  const copySequence = useRef(0)
+  const [copyStore] = useState(createCopyStore)
   const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
 
   const setActiveRow = (row: HTMLElement | undefined) => {
@@ -619,113 +718,65 @@ export function JsonTree({
   }
 
   const clearCopyTarget = () => {
+    copySequence.current += 1
+    if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
     setActiveRow(undefined)
-    setCopyTarget(undefined)
-    setCopyState('idle')
-    copyMenuOpenRef.current = false
-    setCopyMenuOpen(false)
+    copyStore.set(undefined)
   }
 
   useEffect(() => () => {
+    copySequence.current += 1
     if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
     activeRowRef.current?.removeAttribute('data-json-copy-active')
   }, [])
 
   useEffect(() => {
-    activeRowRef.current?.removeAttribute('data-json-copy-active')
-    activeRowRef.current = undefined
-    copyMenuOpenRef.current = false
-    setCopyTarget(undefined)
-    setCopyState('idle')
-    setCopyMenuOpen(false)
+    clearCopyTarget()
     setTabStopId(initialTabStopId)
   }, [data, expandTopLevel, initialTabStopId])
 
   const handleRowHover = (row: HTMLElement, target: RowTarget) => {
-    if (!copyable || copyMenuOpenRef.current) return
+    if (!copyable || copyStore.get()?.menuOpen) return
     if (activeRowRef.current === row) return
     setActiveRow(row)
-    setCopyState('idle')
-    copyMenuOpenRef.current = false
-    setCopyMenuOpen(false)
-    setCopyTarget(target)
+    copyStore.set({ id: pathId(target.path), target, state: 'idle', menuOpen: false })
   }
 
   const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
-    if (!copyable || copyMenuOpenRef.current) return
+    if (!copyable || copyStore.get()?.menuOpen) return
     /* v8 ignore next -- browser mouse events delivered through React target an Element. */
     if (!(event.target instanceof Element)) return
     if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
   }
 
-  const copy = async (target: RowTarget, mode: 'json' | 'path' | 'prettyJson' | 'value') => {
-    setCopyTarget(target)
+  const copy = async (target: RowTarget, mode: CopyMode) => {
+    const sequence = ++copySequence.current
+    const snapshot: CopySnapshot = {
+      id: pathId(target.path), target, state: 'idle', menuOpen: false,
+    }
+    copyStore.set(snapshot)
+    let state: CopySnapshot['state']
     try {
       await navigator.clipboard.writeText(copyText(target, mode))
-      setCopyState('copied')
+      state = 'copied'
     } catch {
-      setCopyState('failed')
+      state = 'failed'
     }
+    const current = copyStore.get()
+    if (sequence !== copySequence.current || current?.target !== target) return
+    copyStore.set({ ...current, state })
     if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
-    resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500)
+    resetTimer.current = setTimeout(() => {
+      const current = copyStore.get()
+      if (current?.target === target) copyStore.set({ ...current, state: 'idle' })
+    }, 1_500)
   }
 
   const [rootOpen, rootClose] = bracketOf(data)
-  const renderCopy = copyable ? (target: RowTarget, persistent = false) => {
-    const active = copyTarget !== undefined && pathId(copyTarget.path) === pathId(target.path)
-    const state = active ? copyState : 'idle'
-    const object = typeof target.value === 'object' && target.value !== null
-    const copyTitle = state === 'copied'
-      ? labels.copied
-      : state === 'failed'
-        ? labels.copyFailed
-        : object ? labels.copyPrettyJson : labels.copyValue
-    return (
-      <span className={css.copySlot}>
-        {(persistent || active) && (
-          <Menu
-            open={active && copyMenuOpen}
-            compact
-            portal
-            align="end"
-            anchor={(
-              <button
-                type="button"
-                className={css.actionButton}
-                data-json-copy-button
-                data-state={state}
-                aria-label={copyTitle}
-                title={labels.copyButtonTitle(copyTitle)}
-                onClick={() => void copy(target, object ? 'prettyJson' : 'value')}
-                onContextMenu={(event) => {
-                  event.preventDefault()
-                  event.stopPropagation()
-                  copyButtonRef.current = event.currentTarget
-                  setCopyTarget(target)
-                  copyMenuOpenRef.current = true
-                  setCopyMenuOpen(true)
-                }}
-              >
-                {state === 'copied'
-                  ? <IconCheckOutline16 size={12} />
-                  : <IconCopyOutline16 size={12} />}
-              </button>
-            )}
-            items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
-            onSelect={(id) => {
-              void copy(target, id as 'json' | 'path' | 'prettyJson' | 'value')
-              copyMenuOpenRef.current = false
-              setCopyMenuOpen(false)
-            }}
-            onClose={clearCopyTarget}
-            getAnchorRect={() => (
-              copyButtonRef.current as HTMLButtonElement
-            ).getBoundingClientRect()}
-          />
-        )}
-      </span>
-    )
-  } : undefined
+  const renderCopy = copyable ? (target: RowTarget, persistent = false) => (
+    <JsonCopyAction store={copyStore} target={target} persistent={persistent} labels={labels}
+      onCopy={copy} onClose={clearCopyTarget} />
+  ) : undefined
 
   return (
     <div
@@ -733,7 +784,7 @@ export function JsonTree({
       style={{ '--json-tree-collapsed-lines': collapsedStringLines } as CSSProperties}
       onMouseOver={handleRootMouseOver}
       onMouseLeave={() => {
-        if (!copyMenuOpenRef.current) clearCopyTarget()
+        if (!copyStore.get()?.menuOpen) clearCopyTarget()
       }}
     >
       {expandTopLevel

+ 109 - 9
packages/client/ui-primitives/tests/json-tree.client.spec.tsx

@@ -12,12 +12,12 @@ function JsonTree(props: Omit<ComponentProps<typeof LocalizedJsonTree>, 'label'
   return <LocalizedJsonTree label="JSON" {...props} labels={jsonTreeLabels} />
 }
 
-let writeText: ReturnType<typeof vi.fn>
+let writeText: ReturnType<typeof vi.fn<Clipboard['writeText']>>
 let originalClipboard: PropertyDescriptor | undefined
 
 beforeEach(() => {
   originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
-  writeText = vi.fn().mockResolvedValue(undefined)
+  writeText = vi.fn<Clipboard['writeText']>().mockResolvedValue(undefined)
   Object.defineProperty(navigator, 'clipboard', {
     configurable: true,
     value: { writeText },
@@ -33,15 +33,115 @@ afterEach(() => {
   else Object.defineProperty(navigator, 'clipboard', originalClipboard)
 })
 
+function stubStringLayout(scrollHeight = 200): void {
+  vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(scrollHeight)
+  const computedStyle = window.getComputedStyle.bind(window)
+  vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
+    const style = computedStyle(element)
+    style.lineHeight = '16px'
+    if (style.paddingBottom === '') style.paddingBottom = '0px'
+    return style
+  })
+}
+
 describe('JsonTree', () => {
-  it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => {
-    vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(200)
-    const computedStyle = window.getComputedStyle.bind(window)
-    vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
-      const style = computedStyle(element)
-      style.lineHeight = '16px'
-      return style
+  it('ignores clipboard settlement after the row changes or the tree unmounts', async () => {
+    vi.useFakeTimers()
+    const pending: (() => void)[] = []
+    writeText.mockImplementation(() => new Promise<void>((resolve) => { pending.push(resolve) }))
+    const view = render(<JsonTree data={{ first: 1, second: 2 }} />)
+    const rows = screen.getAllByRole('treeitem')
+    fireEvent.mouseOver(rows[0] as HTMLElement)
+    fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
+    fireEvent.mouseOver(rows[1] as HTMLElement)
+    await act(async () => { pending[0]!() })
+    expect(screen.queryByRole('button', { name: 'Copied' })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
+    view.unmount()
+    const timerCount = vi.getTimerCount()
+    await act(async () => { pending[1]!() })
+    expect(vi.getTimerCount()).toBe(timerCount)
+  })
+
+  it('updates copy actions without rereading JSON properties on hover', () => {
+    const readValue = vi.fn(() => 'payload '.repeat(100))
+    const data = Object.fromEntries(Array.from({ length: 500 }, (_, index) => [
+      `field${index}`,
+      { get value() { return readValue() } },
+    ]))
+    render(<JsonTree data={data} />)
+    const rows = within(screen.getByRole('tree')).getAllByRole('treeitem')
+    readValue.mockClear()
+
+    fireEvent.mouseOver(rows[0] as HTMLElement)
+    expect(within(rows[0] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy()
+    fireEvent.mouseOver(rows[1] as HTMLElement)
+    expect(within(rows[0] as HTMLElement).queryByRole('button', { name: 'Copy pretty JSON' })).toBeNull()
+    expect(within(rows[1] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy()
+    expect(readValue).not.toHaveBeenCalled()
+  })
+
+  it('expands raw strings without ResizeObserver and keeps the visible viewport limit', () => {
+    stubStringLayout()
+    vi.stubGlobal('ResizeObserver', undefined)
+    let rawTop = 150
+    const view = render(
+      <div style={{ overflowY: 'auto', paddingBottom: '10px' }}>
+        <JsonTree data={{ first: 'raw\ntext', last: 'last\ntext' }} />
+      </div>,
+    )
+    const clip = view.container.firstElementChild as HTMLElement
+    vi.spyOn(clip, 'clientTop', 'get').mockReturnValue(2)
+    vi.spyOn(clip, 'clientHeight', 'get').mockReturnValue(140)
+    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      return new DOMRect(0, this === clip ? 100 : rawTop, 200, 140)
     })
+    const rows = within(screen.getByRole('tree')).getAllByRole('treeitem')
+    fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Expand JSON node' }))
+    const raw = rows[0]?.querySelector('pre') as HTMLPreElement
+    expect(raw.textContent).toBe('raw\ntext')
+    expect(raw.style.maxHeight).toBe('78px')
+    expect(raw.nextElementSibling?.textContent).toBe(',')
+
+    rawTop = 80
+    fireEvent.scroll(clip)
+    expect(raw.style.maxHeight).toBe('126px')
+    rawTop = 300
+    fireEvent.resize(window)
+    expect(raw.style.maxHeight).toBe('16px')
+    fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Collapse JSON node' }))
+    expect(raw.isConnected).toBe(false)
+    rawTop = 100
+    fireEvent.scroll(clip)
+    expect(raw.style.maxHeight).toBe('16px')
+
+    fireEvent.click(within(rows[1] as HTMLElement).getByRole('button', { name: 'Expand JSON node' }))
+    expect(rows[1]?.querySelector('pre')?.nextElementSibling?.textContent).not.toBe(',')
+  })
+
+  it('shows the string expander only beyond the configured collapsed line count', () => {
+    stubStringLayout(48)
+    let resize: (() => void) | undefined
+    const disconnect = vi.fn()
+    vi.stubGlobal('ResizeObserver', class {
+      constructor(callback: () => void) { resize = callback }
+      observe() {}
+      disconnect = disconnect
+    })
+    const data = { text: 'three lines of text' }
+    const view = render(<JsonTree data={data} />)
+    expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull()
+    view.rerender(<JsonTree data={data} collapsedStringLines={2} />)
+    expect(screen.getByRole('button', { name: 'Expand JSON node' })).toBeTruthy()
+    vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(16)
+    act(() => { resize?.() })
+    expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull()
+    view.unmount()
+    expect(disconnect).toHaveBeenCalledTimes(2)
+  })
+
+  it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => {
+    stubStringLayout()
     vi.stubGlobal('ResizeObserver', class {
       observe() {}
       disconnect() {}