fix: v0.5.2 稳健性收尾与偏好记忆 - diff worker 崩溃 onerror 兜底(终止实例回退主线程同步计算、effect 已取消/jobId 已推进的迟到错误不回填)、主进程 will-navigate 导航白名单加固、nativeTheme 暗色标题栏、比较选项与仅看差异开关 localStorage 持久化(损坏数据防御性回退默认)、字符级降级报告选项构造与引擎降级分支对齐

This commit is contained in:
2026-08-18 11:39:26 +08:00
parent bac885a4a0
commit 77dff98425
9 changed files with 215 additions and 17 deletions
+3 -1
View File
@@ -20,6 +20,8 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮,超 20 万字符自动降级行级对比并提示 - **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮,超 20 万字符自动降级行级对比并提示
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,下次启动自动恢复(损坏数据自动回退默认)
- **原生暗色适配**:系统标题栏等原生控件跟随应用暗色科幻主题
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿 - **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数
@@ -97,7 +99,7 @@ src/
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.5.1 - 版本:0.5.2
--- ---
+3 -2
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。 > 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.5.1`** 当前基线版本:**`0.5.2`**
--- ---
@@ -71,7 +71,8 @@ z = 补丁版本号(Patch
0.4.2 ← 导出菜单溢出修复与报告可读性重设计(已发布):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论 0.4.2 ← 导出菜单溢出修复与报告可读性重设计(已发布):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论
0.4.3 ← 忽略所有空白比较选项与报告选项说明(已发布):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标) 0.4.3 ← 忽略所有空白比较选项与报告选项说明(已发布):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标)
0.5.0 ← 字符级对比模式(已发布):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含) 0.5.0 ← 字符级对比模式(已发布):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含)
0.5.1 ← 字符级同行多块变更重复输出修复(当前):字符换位专项测试暴露并修复两处回映射缺陷——同行内多处变更(ne-eq-ne 同行,如换位、同行两处替换)不再重复输出多个 modified · 回拉跨界判定与对齐行判定锚定同一 eq 块,多行换位不再把全 common 行误判为 added · 新增字符换位专项测试组(对齐任意性下的不变量固化) 0.5.1 ← 字符级同行多块变更重复输出修复(已发布):字符换位专项测试暴露并修复两处回映射缺陷——同行内多处变更(ne-eq-ne 同行,如换位、同行两处替换)不再重复输出多个 modified · 回拉跨界判定与对齐行判定锚定同一 eq 块,多行换位不再把全 common 行误判为 added · 新增字符换位专项测试组(对齐任意性下的不变量固化)
0.5.2 ← 稳健性收尾与偏好记忆(当前):diff worker 运行崩溃 onerror 兜底(终止实例回退主线程同步计算,effect 取消/jobId 已推进的迟到错误不回填) · 主进程 will-navigate 导航白名单加固(仅放行回到应用首页) · nativeTheme 暗色主题(Windows 标题栏等系统控件跟随应用风格) · 比较选项与仅看差异开关持久化(localStorage 记忆用户偏好,损坏数据防御性回退默认) · 字符级降级报告选项构造与引擎降级分支对齐(补 trimWhitespace 置否)
0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比) 0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
... ...
0.y.z ← 长期停留,永不进入 1.x 0.y.z ← 长期停留,永不进入 1.x
+2
View File
@@ -109,6 +109,7 @@ src/renderer/src/
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 - 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 - F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
- 交换左右侧后面板文件互换 - 交换左右侧后面板文件互换
- 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰)
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项禁用;内容超限自动降级并 toast 提示;导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项禁用;内容超限自动降级并 toast 提示;导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
@@ -119,6 +120,7 @@ src/renderer/src/
- 输入变更:已派发的旧 worker 终止重建(等价取消);去抖期内变更则旧任务未创建即取消(零创建与计算开销) - 输入变更:已派发的旧 worker 终止重建(等价取消);去抖期内变更则旧任务未创建即取消(零创建与计算开销)
- worker 不可用(环境缺失/工厂返回 null):去抖到期后主线程同步回退 - worker 不可用(环境缺失/工厂返回 null):去抖到期后主线程同步回退
- 卸载:去抖期内卸载则 worker 从未创建;去抖到期后卸载则终止 worker,去抖定时器均被清理 - 卸载:去抖期内卸载则 worker 从未创建;去抖到期后卸载则终止 worker,去抖定时器均被清理
- worker 运行崩溃(onerror 兜底):终止实例并回退主线程同步计算(computing 不再永久卡死);迟到错误(effect 已取消 / jobId 已推进)只终止不回填,新任务结果正常生效
- 工厂:无 Worker 环境返回 null;可用环境返回实例 - 工厂:无 Worker 环境返回 null;可用环境返回实例
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.5.1", "version": "0.5.2",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+10 -1
View File
@@ -1,5 +1,5 @@
import { join } from 'path' import { join } from 'path'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard } from 'electron' import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import iconv from 'iconv-lite' import iconv from 'iconv-lite'
import fs from 'fs' import fs from 'fs'
@@ -33,6 +33,12 @@ function createWindow(): void {
return { action: 'deny' } return { action: 'deny' }
}) })
// 窗口内导航白名单:仅放行回到当前应用地址(dev 首页 / 打包产物首页,等价于刷新),
// 阻断其余一切页面内跳转(新窗口已在上方拦截,此处补齐页面内导航防线)
mainWindow.webContents.on('will-navigate', (e, url) => {
if (url !== mainWindow.webContents.getURL()) e.preventDefault()
})
// 开发模式加载 dev server,生产加载打包后的 html // 开发模式加载 dev server,生产加载打包后的 html
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
@@ -154,6 +160,9 @@ ipcMain.handle('clipboard:write', (_event, text: string) => {
app.whenReady().then(() => { app.whenReady().then(() => {
electronApp.setAppUserModelId('com.metonateam.difflens') electronApp.setAppUserModelId('com.metonateam.difflens')
// 强制暗色系统主题:Windows 标题栏/滚动条等系统控件跟随应用暗色科幻风格
nativeTheme.themeSource = 'dark'
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window)
}) })
+55 -10
View File
@@ -31,6 +31,47 @@ function stamp(): string {
/** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */ /** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */
const MAX_FILE_SIZE = 10 * 1024 * 1024 const MAX_FILE_SIZE = 10 * 1024 * 1024
/** 用户偏好(比较选项 + 视图开关)持久化键 */
const PREFS_KEY = 'difflens.prefs.v1'
/** 全部比较选项的默认值(严格对比) */
const DEFAULT_OPTIONS: DiffOptions = {
trimWhitespace: false,
ignoreCase: false,
ignoreBlankLines: false,
ignoreAllWhitespace: false,
charMode: false
}
/**
* 读取持久化偏好:只接受已知字段的严格 boolean 值;
* 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。
*/
function loadPrefs(): { options: DiffOptions; onlyDiff: boolean } {
const fallback = { options: { ...DEFAULT_OPTIONS }, onlyDiff: false }
let raw: string | null = null
try {
raw = window.localStorage.getItem(PREFS_KEY)
} catch {
return fallback
}
if (!raw) return fallback
try {
const obj = JSON.parse(raw) as {
options?: Partial<Record<keyof DiffOptions, unknown>>
onlyDiff?: unknown
}
const bool = (v: unknown): boolean => v === true
const options: DiffOptions = { ...DEFAULT_OPTIONS }
for (const k of Object.keys(DEFAULT_OPTIONS) as (keyof DiffOptions)[]) {
options[k] = bool(obj.options?.[k])
}
return { options, onlyDiff: bool(obj.onlyDiff) }
} catch {
return fallback
}
}
/** 计算中/无内容时的空统计(保持状态栏与徽章渲染稳定) */ /** 计算中/无内容时的空统计(保持状态栏与徽章渲染稳定) */
const EMPTY_SUMMARY: DiffSummary = { changedLines: 0, inserted: 0, deleted: 0, modified: 0 } const EMPTY_SUMMARY: DiffSummary = { changedLines: 0, inserted: 0, deleted: 0, modified: 0 }
@@ -91,15 +132,10 @@ function EmptyPane({
export default function App(): ReactElement { export default function App(): ReactElement {
const [paneL, setPaneL] = useState<PaneState | null>(null) const [paneL, setPaneL] = useState<PaneState | null>(null)
const [paneR, setPaneR] = useState<PaneState | null>(null) const [paneR, setPaneR] = useState<PaneState | null>(null)
const [options, setOptions] = useState<DiffOptions>({ // 比较选项与视图开关:初始值从持久化偏好恢复(惰性求值,每次挂载重新读取)
trimWhitespace: false, const [options, setOptions] = useState<DiffOptions>(() => loadPrefs().options)
ignoreCase: false,
ignoreBlankLines: false,
ignoreAllWhitespace: false,
charMode: false
})
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行) // 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
const [onlyDiff, setOnlyDiff] = useState(false) const [onlyDiff, setOnlyDiff] = useState<boolean>(() => loadPrefs().onlyDiff)
const [activeRowId, setActiveRowId] = useState<string | null>(null) const [activeRowId, setActiveRowId] = useState<string | null>(null)
const [navIndex, setNavIndex] = useState(0) const [navIndex, setNavIndex] = useState(0)
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
@@ -130,6 +166,15 @@ export default function App(): ReactElement {
} }
}, []) }, [])
// 偏好写回:比较选项与视图开关变更即持久化(存储不可用时静默忽略,不影响本次会话)
useEffect(() => {
try {
window.localStorage.setItem(PREFS_KEY, JSON.stringify({ options, onlyDiff }))
} catch {
// 忽略写入失败(隐私模式/配额满等)
}
}, [options, onlyDiff])
// 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面) // 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面)
const warnHeavyLines = useCallback( const warnHeavyLines = useCallback(
(text: string): void => { (text: string): void => {
@@ -322,9 +367,9 @@ export default function App(): ReactElement {
async (fmt: ReportFormat) => { async (fmt: ReportFormat) => {
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御 // 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
if (!diff) return if (!diff) return
// 字符级对比降级时,报告头部按实际生效的行级语义描述(全部空白忽略 + 空行忽略 // 字符级对比降级时,报告头部按实际生效的行级语义描述(与引擎降级分支的选项构造完全一致
const reportOptions: DiffOptions = diff.charModeDowngraded const reportOptions: DiffOptions = diff.charModeDowngraded
? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true } ? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true, trimWhitespace: false }
: options : options
const ctx: ReportContext = { const ctx: ReportContext = {
leftName: paneL?.meta.name ?? '', leftName: paneL?.meta.name ?? '',
+75
View File
@@ -26,6 +26,8 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
window.api = mockApi() window.api = mockApi()
// 清除偏好持久化(比较选项 + 视图开关),避免用例间互相污染初始状态
window.localStorage.clear()
// jsdom 未实现滚动定位,导航定位(虚拟化受控 scrollTop)需要打桩 // jsdom 未实现滚动定位,导航定位(虚拟化受控 scrollTop)需要打桩
Element.prototype.scrollIntoView = vi.fn() Element.prototype.scrollIntoView = vi.fn()
Element.prototype.scrollTo = vi.fn() Element.prototype.scrollTo = vi.fn()
@@ -694,3 +696,76 @@ describe('App - 交换左右侧', () => {
expect(files()).toEqual(['b.txt', 'a.txt']) expect(files()).toEqual(['b.txt', 'a.txt'])
}) })
}) })
describe('App - 偏好持久化(比较选项 + 仅看差异)', () => {
const KEY = 'difflens.prefs.v1'
it('启动时恢复上次保存的比较选项与仅看差异开关', async () => {
window.localStorage.setItem(
KEY,
JSON.stringify({
options: {
trimWhitespace: false,
ignoreCase: false,
ignoreBlankLines: false,
ignoreAllWhitespace: true,
charMode: false
},
onlyDiff: true
})
)
window.api = mockApi({
openFile: async (side) =>
side === 'left'
? { path: '/tmp/a.txt', name: 'a.txt', text: 'same\ndiffL', encoding: 'UTF-8', binary: false }
: { path: '/tmp/b.txt', name: 'b.txt', text: 'same\ndiffR', encoding: 'UTF-8', binary: false }
})
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('打开右侧'))
await screen.findAllByText('b.txt')
// 比较选项恢复:忽略所有空白已勾选
expect(screen.getByLabelText('忽略所有空白')).toBeChecked()
// 仅看差异恢复:未更改行被折叠(same 不再展示)
expect(screen.queryAllByText('same')).toHaveLength(0)
// 关闭开关后恢复全量视图
fireEvent.click(screen.getByLabelText('仅看差异'))
expect(screen.getAllByText('same').length).toBeGreaterThan(0)
})
it('损坏的持久化数据回退默认选项且不崩溃', async () => {
window.localStorage.setItem(KEY, '{broken json!!')
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
expect(await screen.findByText('导出报告')).toBeInTheDocument()
expect(screen.getByLabelText('忽略所有空白')).not.toBeChecked()
expect(screen.getByLabelText('字符级对比')).not.toBeChecked()
expect(screen.getByLabelText('仅看差异')).not.toBeChecked()
})
it('非布尔字段被防御性忽略(回退关闭)', async () => {
window.localStorage.setItem(
KEY,
JSON.stringify({ options: { ignoreCase: 'yes', charMode: 1 }, onlyDiff: 'true' })
)
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
expect(await screen.findByText('导出报告')).toBeInTheDocument()
expect(screen.getByLabelText('忽略大小写')).not.toBeChecked()
expect(screen.getByLabelText('字符级对比')).not.toBeChecked()
expect(screen.getByLabelText('仅看差异')).not.toBeChecked()
})
it('切换选项后写回持久化存储', async () => {
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByLabelText('忽略大小写'))
const saved1 = JSON.parse(window.localStorage.getItem(KEY) ?? '{}')
expect(saved1.options.ignoreCase).toBe(true)
fireEvent.click(screen.getByLabelText('仅看差异'))
const saved2 = JSON.parse(window.localStorage.getItem(KEY) ?? '{}')
expect(saved2.onlyDiff).toBe(true)
})
})
+54 -1
View File
@@ -4,9 +4,10 @@ import { useDiff } from './useDiff'
import { computeDiff, type DiffResult, type DiffOptions } from '../diff/diffEngine' import { computeDiff, type DiffResult, type DiffOptions } from '../diff/diffEngine'
import type { DiffWorkerFactory } from '../diff/createDiffWorker' import type { DiffWorkerFactory } from '../diff/createDiffWorker'
/** 模拟 Worker:记录派发请求,可手动回传结果;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */ /** 模拟 Worker:记录派发请求,可手动回传结果或触发崩溃terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */
class FakeWorker { class FakeWorker {
onmessage: ((e: { data: { jobId: number; result: DiffResult } }) => void) | null = null onmessage: ((e: { data: { jobId: number; result: DiffResult } }) => void) | null = null
onerror: (() => void) | null = null
terminated = false terminated = false
posted: { jobId: number }[] = [] posted: { jobId: number }[] = []
postMessage(data: { jobId: number }): void { postMessage(data: { jobId: number }): void {
@@ -19,6 +20,11 @@ class FakeWorker {
if (this.terminated) return if (this.terminated) return
this.onmessage?.({ data: { jobId, result } }) this.onmessage?.({ data: { jobId, result } })
} }
/** 模拟运行中崩溃:触发 onerror(终止后不再触发,对齐真实 worker) */
fail(): void {
if (this.terminated) return
this.onerror?.()
}
} }
/** 每次调用产出新 FakeWorker 的工厂,并记录全部实例 */ /** 每次调用产出新 FakeWorker 的工厂,并记录全部实例 */
@@ -188,4 +194,51 @@ describe('useDiff - worker 后台计算', () => {
hook.unmount() hook.unmount()
expect(w.terminated).toBe(true) expect(w.terminated).toBe(true)
}) })
it('worker 运行中崩溃:终止实例并回退主线程同步计算', () => {
const factory = makeFactory()
const left = heavyText(2100)
const { result } = renderHook(() => useDiff(left, 'x', NO_OPTIONS, factory))
act(() => {
vi.advanceTimersByTime(250)
})
const w = factory.workers[0]
expect(result.current.computing).toBe(true)
act(() => {
w.fail()
})
expect(w.terminated).toBe(true)
expect(result.current.diff).toEqual(computeDiff(left, 'x', {}))
expect(result.current.computing).toBe(false)
})
it('迟到错误(输入已变化)只终止不回填,新任务结果正常生效', () => {
const factory = makeFactory()
const hook = renderHook((p: string) => useDiff(p, 'x', NO_OPTIONS, factory), {
initialProps: heavyText(2100)
})
act(() => {
vi.advanceTimersByTime(250)
})
const w1 = factory.workers[0]
hook.rerender(heavyText(2300))
// 模拟 terminate 前已排入事件队列的迟到 error(绕过 terminated 守卫直接触发)
act(() => {
w1.onerror?.()
})
// 过期错误不回填:仍处于 computing 且无结果
expect(hook.result.current.diff).toBeNull()
expect(hook.result.current.computing).toBe(true)
// 新任务正常完成
act(() => {
vi.advanceTimersByTime(250)
})
const w2 = factory.workers[1]
const res = computeDiff('m', 'n', {})
act(() => {
w2.respond(w2.posted[0].jobId, res)
})
expect(hook.result.current.diff).toEqual(res)
expect(hook.result.current.computing).toBe(false)
})
}) })
+12 -1
View File
@@ -51,6 +51,8 @@ export function useDiff(
// 重输入先清空旧结果:避免交换左右等场景下展示与新面板不对应的过期差异 // 重输入先清空旧结果:避免交换左右等场景下展示与新面板不对应的过期差异
setAsyncDiff(null) setAsyncDiff(null)
setComputing(true) setComputing(true)
// effect 世代标志:cleanup 后置位,迟到回调(含去抖期内输入已变、jobId 尚未推进的窗口)一律丢弃
let cancelled = false
let w: Worker | null = null let w: Worker | null = null
// worker 延迟到去抖到期才创建:内联 worker 实例化含 base64 解码与脚本编译, // worker 延迟到去抖到期才创建:内联 worker 实例化含 base64 解码与脚本编译,
// 去抖期内输入再变时旧任务未创建即取消,零创建与计算开销 // 去抖期内输入再变时旧任务未创建即取消,零创建与计算开销
@@ -65,13 +67,22 @@ export function useDiff(
const jobId = ++jobIdRef.current const jobId = ++jobIdRef.current
w.onmessage = (e: MessageEvent<DiffWorkerResponse>): void => { w.onmessage = (e: MessageEvent<DiffWorkerResponse>): void => {
// 丢弃过期任务:输入已变化时旧计算结果不再生效 // 丢弃过期任务:输入已变化时旧计算结果不再生效
if (e.data.jobId !== jobId) return if (e.data.jobId !== jobId || cancelled) return
setAsyncDiff(e.data.result) setAsyncDiff(e.data.result)
setComputing(false) setComputing(false)
} }
// worker 运行中崩溃(未捕获异常/极端输入):终止实例后回退主线程同步计算,
// 避免 computing 永久卡死;迟到错误(effect 已取消或 jobId 已推进)只终止不回填
w.onerror = (): void => {
w?.terminate()
if (cancelled || jobIdRef.current !== jobId) return
setAsyncDiff(computeDiff(left, right, options))
setComputing(false)
}
w.postMessage({ jobId, leftText: left, rightText: right, options }) w.postMessage({ jobId, leftText: left, rightText: right, options })
}, DEBOUNCE_MS) }, DEBOUNCE_MS)
return () => { return () => {
cancelled = true
window.clearTimeout(timer) window.clearTimeout(timer)
w?.terminate() w?.terminate()
} }