From 02090f7bf4b836819aca8dcee363e1229a3285cf Mon Sep 17 00:00:00 2001 From: Muse Date: Sat, 14 Nov 2026 16:13:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E6=A8=A1=E5=BC=8F=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=96=87=E6=9C=AC=E9=97=AA=E7=83=81=E2=80=94=E2=80=94?= =?UTF-8?q?epoch=20=E9=87=8D=E6=8C=82=E7=BB=9F=E4=B8=80=E5=BC=83=E6=97=A7?= =?UTF-8?q?=E7=AA=97=E6=A0=BC=E5=9B=9E=E5=86=99=EF=BC=8C=E6=BA=90=E7=A0=81?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E7=A6=81=E5=AF=8C=E6=96=87=E6=9C=AC=E7=AA=97?= =?UTF-8?q?=E6=A0=BC=E5=9B=9E=E5=86=99=EF=BC=88docs/24=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/24-模式切换文本闪烁修复.md | 40 +++++++++++++++++++++++++++++++++ src/App.tsx | 24 +++++++++++++------- 2 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 docs/24-模式切换文本闪烁修复.md diff --git a/docs/24-模式切换文本闪烁修复.md b/docs/24-模式切换文本闪烁修复.md new file mode 100644 index 0000000..b93f72c --- /dev/null +++ b/docs/24-模式切换文本闪烁修复.md @@ -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 构建通过。 + +## 待真机复验 + +- 反复切换源码/即时渲染,文本与光标位置稳定; +- 外部修改文件触发的重载仍正常(闸门合并后回归)。 diff --git a/src/App.tsx b/src/App.tsx index 2670f78..5b0a42e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -82,6 +82,12 @@ export default function App() { const [sourceMode, setSourceMode] = useState(false) const [paneEpochs, setPaneEpochs] = useState>({}) 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 })) }, []) // 标签编辑器保活:最近用过的标签保持挂载(隐藏而非销毁),切回免去整篇重解析 @@ -433,11 +439,7 @@ export default function App() { if (expectedContent !== undefined && current.content !== expectedContent) return if (isDirty(current)) return } - // 审计修复(卸载回写覆盖):标签重挂时,旧 EditorPane 的卸载回写会把旧正文 - // 写回状态、盖掉刚载入的内容——重载前先给该标签的这次最终回写上闸门。 - // 保活后非活动标签也可能正挂载着,闸门与重挂对所有标签一律生效。 - suppressFinalWriteRef.current.add(target.id) - window.setTimeout(() => suppressFinalWriteRef.current.delete(target.id), 1500) + // 卸载回写覆盖的闸门已集中在 bumpPaneEpoch 内(重挂必弃旧回写)。 dispatch({ type: 'loadContent', id: target.id, content: file.content, encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom, @@ -1299,12 +1301,18 @@ export default function App() { key={`${t.id}:${paneEpochs[t.id] ?? 0}`} tabId={t.id} 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) => { - 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 }) }} - onSyncInitial={(id, md) => dispatch({ type: 'syncEditorContent', id, content: md })} onCursor={(c) => { if (stateRef.current.activeId === t.id) setCursor(c) }} focusMode={ui.focusMode} typewriter={ui.typewriter}