fix(app): 模式切换文本闪烁——epoch 重挂统一弃旧窗格回写,源码模式禁富文本窗格回写(docs/24)
This commit is contained in:
40
docs/24-模式切换文本闪烁修复.md
Normal file
40
docs/24-模式切换文本闪烁修复.md
Normal 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 构建通过。
|
||||||
|
|
||||||
|
## 待真机复验
|
||||||
|
|
||||||
|
- 反复切换源码/即时渲染,文本与光标位置稳定;
|
||||||
|
- 外部修改文件触发的重载仍正常(闸门合并后回归)。
|
||||||
24
src/App.tsx
24
src/App.tsx
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user