Skip to content

Heavy block remount jank

字段
优先级P1
分类renderer / heavy nodes / async components
状态done
创建日期2026-04-30
关联文件src/components/CodeBlockNode/CodeBlockNode.vuesrc/components/CodeBlockNode/monaco.tssrc/components/CodeBlockNode/runtime.tssrc/components/MermaidBlockNode/MermaidBlockNode.vuesrc/components/InfographicBlockNode/InfographicBlockNode.vuesrc/components/NodeRenderer/NodeRenderer.vuesrc/utils/diagramHeight.tssrc/types/component-props.tspackages/markstream-vue2/src/components/NodeRenderer/NodeRenderer.vuepackages/markstream-vue2/src/utils/diagramHeight.tspackages/markstream-react/src/renderers/renderNode.tsxpackages/markstream-angular/src/components/shared/node-outlet-helpers.ts

问题

使用虚拟滚动并恢复已保存 scrollTop 的上层应用,在重新挂载已经加载过的内容时,重型 Markdown block 会出现可见抖动。最明显的是以下几类节点:

  • CodeBlockNode 重新挂载时先显示 PreCodeNode,再切换到 Monaco code block。
  • MermaidBlockNode 在 async component resolve 前可能先出现空白或源码 fallback,再替换成图表。
  • InfographicBlockNode 存在同类问题:async component 没有 loading shell,并且组件先以源码模式初始化,mounted 后才切到 preview。

这些行为对冷启动首次渲染是合理的,但对已加载内容的 remount 不稳定。上层虚拟滚动已经恢复了 scrollTop 后,block 的 DOM 结构和高度又发生变化,就会把下方正文推开,表现为闪烁、内容瞬移,或者落入虚拟列表 stale spacer 造成的空白区域。

这是 markstream-vue 层的问题。上层应用可以保存和恢复滚动位置,但如果组件首帧高度、fallback 模式和最终渲染结果不一致,应用层无法稳定消除这类抖动。

后续排查确认同类风险不只存在于 Vue 3 包。markstream-vue2 的 Mermaid/Infographic 仍然使用异步组件首帧空壳,markstream-reactmarkstream-angular 虽然没有同样的 async component 空壳,但 Mermaid/Infographic 首帧仍缺少稳定的高度估算,切换或刷新时也可能推动后续正文。

根因

  • code block 是否显示 fallback 只依赖当前组件实例是否完成 editor mount,没有感知共享 Monaco runtime 是否已经预热完成。
  • Mermaid 和 Infographic 使用默认 async component loading 行为,首帧没有预留最终图表容器的形态和高度。
  • Mermaid 和 Infographic 的 preview 高度需要等真实 SVG/HTML 渲染后才能测量。
  • Infographic 默认 showSource = true,loader 可用时再在 mounted 后切到 preview,导致 remount 时可能先显示源码或空白。
  • loading shell 和最终 header 的 padding、图标、action button 占位不一致时,即使只有几像素差异,也会让图表下方正文产生可见位移。

修复

CodeBlockNode

  • 增加共享 code block runtime ready 状态和 preloadCodeBlockRuntime()
  • getUseMonaco() 在 Monaco 以及 preload 工作完成后标记 runtime ready。
  • runtime 已 ready 的 warm remount 不再显示可见 PreCodeNode fallback。
  • cold start 仍然保留 fallback,直到 editor mount 和初始高度稳定。
  • editor 创建失败时继续显示 fallback,保证代码内容仍可读。
  • 对齐 fallback 的 font size、line height、padding 和估算内容高度,减少 fallback 到 Monaco 过程中的高度变化。

MermaidBlockNode

  • 增加基于图表源码的 preview 高度估算。
  • NodeRenderer 在调用方没有显式传入时自动注入 estimatedPreviewHeightPx
  • async component resolve 前立即渲染同形态 loading shell,包括 header 和预留 preview 区域。
  • Mermaid loader 已启用时,组件直接以 preview 模式初始化,不再先显示源码。
  • 最终 preview 在真实内容可用前使用估算高度作为下限。

InfographicBlockNode

  • 增加和 Mermaid 同类的 preview 高度估算与 async loading shell。
  • Infographic loader 已启用时,组件直接以 preview 模式初始化。
  • 最终 preview 在真实内容可用前使用估算高度作为下限。
  • loading shell 的 header 尺寸与最终 header 对齐,包括图标和 action button 占位,避免 async component resolve 时推动下方正文。

多框架对齐

  • markstream-vue2 补齐 Mermaid/Infographic 的高度估算、async loading shell 和 estimatedPreviewHeightPx 注入。
  • markstream-react 在 client/server render path 中都为 Mermaid/Infographic 注入稳定高度估算,并让组件首帧使用该高度。
  • markstream-angular 在 NodeOutlet custom inputs 和内置 Mermaid/Infographic 组件中使用同一套高度估算,避免 custom renderer 和 builtin renderer 表现不一致。
  • React/Angular 的 CodeBlock/MarkdownCodeBlock 没有 Vue3 PreCodeNode -> CodeBlockNode 的同一条切换链路,本次只补图表高度稳定性,不改它们的 code block 架构。

为什么这样改

这次修复把责任放在产生不稳定 DOM 的组件内部:

  • 上层应用负责保存滚动位置、恢复滚动位置、维护虚拟列表 row measurement。
  • markstream-vue 负责让重型 block 的首帧 DOM 形态和高度尽量接近最终状态,避免破坏上层滚动恢复。

使用 loading shell 和 estimated height floor 比关闭虚拟滚动、或者要求每个应用分别特判 Mermaid/Infographic/CodeBlock 更收敛。optional peer fallback 行为也被保留:Mermaid、Infographic 或 Monaco 不可用时,仍然回退到可读的 preformatted code。

验证

库侧验证:

bash
pnpm vitest run test/node-renderer-heavy-node-props.test.ts
pnpm vitest run test/vue2-node-renderer-heavy-node-props.test.ts
pnpm vitest run test/react-render-node-heavy-props.test.ts test/react-diagram-block-parity.test.tsx
pnpm vitest run test/markstream-angular-node-outlet.test.ts
pnpm vitest run test/code-block-editor-lock.test.ts
pnpm vitest run test/mermaid-streaming-preview-regression.test.ts
pnpm lint
pnpm typecheck
pnpm build
git diff --check

通过 dimcode-app workspace link 的集成验证:

bash
pnpm test:e2e:scroll
DIMCODE_E2E_REAL_THREAD_IDS=924740a7-8e64-454b-b4bf-7a3b462ce6bf,s_1777337966876_0 pnpm test:e2e:scroll
pnpm --filter @dimcode/renderer typecheck
git diff --check

e2e 覆盖:

  • 已加载 thread 来回切换后,保存的滚动位置不变化。
  • 当前 thread 滚动后刷新,恢复到刷新前位置。
  • code block warm remount 不出现可见 PreCodeNode fallback。
  • Mermaid 和 code block 同时出现在可视区域时,切换和刷新后 row 位置、高度、header 和后续正文稳定。
  • Infographic 和 code block 同时出现在可视区域时,切换和刷新后 row 位置、高度、header 和后续正文稳定。