fix(app): 模式切换文本闪烁——epoch 重挂统一弃旧窗格回写,源码模式禁富文本窗格回写(docs/24)

This commit is contained in:
Muse
2026-11-14 16:13:55 +08:00
parent c0f03e48dc
commit 02090f7bf4
2 changed files with 56 additions and 8 deletions

View File

@@ -0,0 +1,40 @@
# docs/24 源码/即时渲染来回切换文本「有→无→无→有」修复
## 现象(用户真机报回)
空白文档 → 源码模式输入文本 → 切即时渲染:文本在 ✓ → 切回源码:文本没了 ✗
→ 切即时渲染:还是没了 ✗ → 再切源码:又出现了。文本随切换闪烁。
## 根因(无头浏览器按原步骤复现,与报回现象逐拍吻合)
退出源码模式时 `bumpPaneEpoch` 强制富文本窗格重挂:新窗格按状态里的
新文本(源码里输的)初始化 ✓;但**旧窗格卸载时的最终回写**
(onFinalMarkdown → syncEditorContent)随后把旧窗格自己的旧文本写回状态,
盖掉源码文本。此后每次切换,新挂载的一方读状态(已被盖成旧文本),而
卸载的一方又把自己显示的文本写回——于是文本在两个版本间来回翻面:
状态与画面永远差一拍。
审计期已有同类修复(外部重载路径的 suppressFinalWrite 闸门,专防
「卸载回写覆盖」),但退出源码模式这条重挂路径漏加了闸门。
## 修复(App.tsx)
1. **闸门集中进 bumpPaneEpoch**:凡 epoch 重挂(源码退出重建、外部
重载),旧窗格的卸载回写必然过期,重挂前先上闸门丢弃,1.5 秒自清;
外部重载处原有的本地闸门删除,统一走这一处。
2. **源码模式下禁富文本窗格回写**:活动标签处于源码模式时,其隐藏的
富文本窗格的 onMarkdown / onSyncInitial 一律丢弃——它的文本是进源码
前的旧稿,迟到的防抖回写同样会盖掉源码输入。
保活淘汰、关标签等正常卸载不经 epoch 重挂,回写照旧,不受影响。
## 验证
- 无头浏览器按用户原步骤五连切:修复前 step3 ✗ / step4 ✗ / step5 ✓
(与报回完全一致),修复后五步全部 ✓。
- tsc 0 错误;vitest 全量通过;vite 构建通过。
## 待真机复验
- 反复切换源码/即时渲染,文本与光标位置稳定;
- 外部修改文件触发的重载仍正常(闸门合并后回归)。

View File

@@ -82,6 +82,12 @@ export default function App() {
const [sourceMode, setSourceMode] = useState(false) const [sourceMode, setSourceMode] = useState(false)
const [paneEpochs, setPaneEpochs] = useState<Record<string, number>>({}) const [paneEpochs, setPaneEpochs] = useState<Record<string, number>>({})
const bumpPaneEpoch = useCallback((id: string) => { const bumpPaneEpoch = useCallback((id: string) => {
// 凡是 epoch 重挂(源码退出重建、外部重载),旧窗格卸载时的最终回写必然
// 是过期文本(新内容已在状态里),先上闸门丢弃,免得它盖掉新内容——
// 源码模式下输的文本曾被空窗格的回写反复盖掉(docs/24)。闸门 1.5 秒
// 自清,防止没有卸载发生时长期挂着。
suppressFinalWriteRef.current.add(id)
window.setTimeout(() => suppressFinalWriteRef.current.delete(id), 1500)
setPaneEpochs((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })) setPaneEpochs((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + 1 }))
}, []) }, [])
// 标签编辑器保活:最近用过的标签保持挂载(隐藏而非销毁),切回免去整篇重解析 // 标签编辑器保活:最近用过的标签保持挂载(隐藏而非销毁),切回免去整篇重解析
@@ -433,11 +439,7 @@ export default function App() {
if (expectedContent !== undefined && current.content !== expectedContent) return if (expectedContent !== undefined && current.content !== expectedContent) return
if (isDirty(current)) return if (isDirty(current)) return
} }
// 审计修复(卸载回写覆盖):标签重挂时,旧 EditorPane 的卸载回写会把旧正文 // 卸载回写覆盖的闸门已集中在 bumpPaneEpoch 内(重挂必弃旧回写)。
// 写回状态、盖掉刚载入的内容——重载前先给该标签的这次最终回写上闸门。
// 保活后非活动标签也可能正挂载着,闸门与重挂对所有标签一律生效。
suppressFinalWriteRef.current.add(target.id)
window.setTimeout(() => suppressFinalWriteRef.current.delete(target.id), 1500)
dispatch({ dispatch({
type: 'loadContent', id: target.id, content: file.content, type: 'loadContent', id: target.id, content: file.content,
encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom, encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom,
@@ -1299,12 +1301,18 @@ export default function App() {
key={`${t.id}:${paneEpochs[t.id] ?? 0}`} key={`${t.id}:${paneEpochs[t.id] ?? 0}`}
tabId={t.id} tabId={t.id}
initialMarkdown={t.content} initialMarkdown={t.content}
onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })} onMarkdown={(id, md) => {
if (sourceMode && stateRef.current.activeId === id) return // 源码模式中:富文本窗格是旧稿,禁回写
dispatch({ type: 'updateContent', id, content: md })
}}
onFinalMarkdown={(id, md) => { onFinalMarkdown={(id, md) => {
if (suppressFinalWriteRef.current.delete(id)) return // 外部重载的卸载回写,丢弃 if (suppressFinalWriteRef.current.delete(id)) return // 重挂时的卸载回写已过期,丢弃
dispatch({ type: 'syncEditorContent', id, content: md })
}}
onSyncInitial={(id, md) => {
if (sourceMode && stateRef.current.activeId === id) return // 同上
dispatch({ type: 'syncEditorContent', id, content: md }) dispatch({ type: 'syncEditorContent', id, content: md })
}} }}
onSyncInitial={(id, md) => dispatch({ type: 'syncEditorContent', id, content: md })}
onCursor={(c) => { if (stateRef.current.activeId === t.id) setCursor(c) }} onCursor={(c) => { if (stateRef.current.activeId === t.id) setCursor(c) }}
focusMode={ui.focusMode} focusMode={ui.focusMode}
typewriter={ui.typewriter} typewriter={ui.typewriter}