Actual 26.8.1 热修复深度解读:交易列表性能、应用冻结与右键菜单问题的源码级解析
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
Actual Budget(本地优先的个人理财应用)于 2026 年 8 月发布了 26.8.1 热修复版本,集中解决 26.8.0 中用户反馈的若干性能问题与细节缺陷。本文以该版本发布说明为核心,结合仓库中右键菜单、交易列表与计划(Schedules)页面的真实实现源码,逐项拆解每个修复背后的技术成因、底层机制与验证方式,帮助使用者理解"为什么要修、怎么修的",并为自建部署者提供明确的升级指引。
版本概览与升级方式
26.8.1 属于 26.8.0 之后的补丁版本(hotfix),发布说明明确指出其目标是:
解决 26.8.0 中报告的若干性能问题,以及其他一些次要 bug。
对于自托管用户,官方提供的 Docker 镜像标签为26.8.1,升级时直接拉取该标签的镜像即可:
docker pull actualbudget/actual-server:26.8.1使用 docker-compose 部署的用户,将image字段中的版本号更新为26.8.1后重新创建容器即可完成升级。本次修复共包含 4 项 Bugfix 与 1 项维护性改动,涉及"应用冻结/CPU 飙升""右键菜单异常显示""交易列表随预算增长变慢""计划页菜单选项缺失"等典型问题,下文逐一展开。
修复一:间歇性应用冻结与 100% CPU 占用(#8628)
症状:应用在使用过程中不定期出现界面无响应(freeze),进程 CPU 占用飙升至 100%。
这类"间歇性冻结"在 React 应用中往往并非业务计算量突增,而是与事件监听器的重复绑定/解绑引发的连锁反应相关。同一版本中的维护项 #8606 提供了直接线索:
防止右键菜单监听器在每次渲染时重新绑定(Prevent context menu listeners from rebinding every render)。
从源码可以确认,26.8.0 前后的右键菜单事件绑定正是"每次渲染都会重建监听器"的高危模式。先看修复后的关键实现 useRefEventListener.ts,其设计充分体现了对"重复绑定"的防御:
// Keep the latest callback in a ref so the effect below doesn't need to // depend on it. Callers routinely pass a new inline function every render, // which would otherwise tear down and re-add the native listener on every // render instead of only when the target element or `event` change. const callbackRef = useRef(callback); callbackRef.current = callback;这里存在两个关键机制:
- 回调以 ref 保存而非直接作为 effect 依赖:调用方(组件)每次渲染都会传入新的内联函数,若将其直接放入 effect 依赖数组,会导致 effect 在每次渲染后都执行"移除旧监听器 + 添加新监听器"。当组件渲染频率高(如交易列表滚动、输入过滤时),监听器在短时间内被反复创建销毁,形成大量 DOM 操作与事件系统开销,正是 100% CPU 与冻结的温床。
- 目标元素镜像进 state 以触发正确的绑定时机:由于
ref.current的变更不会触发 React 重渲染,源码将解析出的目标元素放入useState,每次渲染后重新解析并借助setTarget的"值未变则 bail out"特性,确保监听器只在"目标元素真正变化"时重新绑定。
从源码结构看,#8628 的冻结修复与 #8606 的监听器优化同批发布,二者存在强关联:右键菜单监听器的无节制重绑,在菜单频繁开关、列表频繁渲染的场景下会造成明显的 CPU 峰值,进而表现为界面冻结。
修复二:右键菜单在多账户视图上错误显示(#8662)
症状:在"多账户视图"(multi-account views)下,本不该出现的右键菜单被弹出。
要理解该 bug,需要先理解 Actual 当前右键菜单的全局架构。与早期"菜单逻辑内嵌于单个组件"的实现不同,现在的右键菜单是全局单例式的,由三部分协作:
- 状态层contextMenuSlice.ts:基于 Redux Toolkit 的 slice,维护
isOpen、position、items三个字段。addItems会过滤hidden项并按order排序,且仅当items非空时才打开菜单。 - 触发层useContextMenu.ts:挂在任意需要右键菜单的行/元素上,监听
contextmenu事件后 dispatchaddItems与setContextMenuPosition。 - 渲染层ContextMenu.tsx:全局渲染一个 0×0 的不可见锚点(
pointerEvents: none),配合Popover在鼠标位置弹出Menu组件。
关键的失效机制在于useContextMenu中:
useRefEventListener(triggerRef, 'contextmenu', (e: MouseEvent) => { if (enabled) { e.preventDefault(); dispatch(addItems(processedItems)); dispatch(setContextMenuPosition({ x: e.clientX, y: e.clientY })); } });而useContextMenu内部对 items 的处理:
const processedItems = items.filter( item => item && (typeof item === 'symbol' || !item.hidden), ) as ContextMenuItem[];从这两段实现可以推断 #8662 的成因方向:在多账户视图这类由多个子行/子区域组合成的视图中,如果某一行传入的items为空或全部被hidden过滤,但enabled仍为 true,事件冒泡会命中错误的触发源,或使全局菜单以残留的旧 items 弹出。addItems中的state.isOpen = !!state.items.length逻辑也意味着:一旦 items 数组残留了上一次的条目,菜单就会在预期之外的位置持续打开。修复方向(结合 PR 描述)是在多账户视图的场景下收紧enabled判定与 items 传递,避免空菜单/错位菜单弹出。同时,ContextMenu.tsx中的pointerdown全局监听会在点击 Popover 外部时closeContextMenu,这是确保菜单能正常关闭的另一重保障。
修复三:交易列表随预算增长越来越慢(#8663)
症状:随着预算中交易数据量增长,交易列表的交互(新增交易、清除标记、删除交易以及对账 reconcile)逐渐变慢。
这是 26.8.1 中最具普遍性的一项性能修复。从实现层面看,交易列表渲染位于 TransactionsTable.tsx,其行级右键操作由 useTransactionRowContextActions.ts 统一提供。该文件中暴露了一个此前容易成为性能瓶颈的典型模式——在行渲染期间进行多路数据查询与推导:
const scheduleQuery = useMemo(() => { if (scheduleIds.length === 0) return undefined; return q('schedules') .filter({ id: { $oneof: scheduleIds } }) .select('*'); }, [scheduleIds]); const { schedules: selectedSchedules } = useSchedules({ query: scheduleQuery });每次行交互(添加、清除、删除、对账)都会触发选择集(useSelectedItems)变化,进而引发selectedIds、scheduleIds、linked、canBeSkipped、canUnsplitTransactions等一连串useMemo重算与useSchedules数据查询。当预算数据量增长后:
- 交易表本身需要渲染更多行;
- 每一行在操作时都要对"选中集合"做遍历(
selectedItems.map(...)、getTransaction查表); - 计划查询、类型判定、可跳过/可完成判定等推导在更大数据集上变慢。
26.8.1 的修复重点是削减这些高频路径中的重复计算与不必要的查询触发,使"新增/清除/删除/对账"等操作的时间复杂度不再随预算总交易量线性恶化。从仓库现状看,useTransactionRowContextActions.ts已通过useMemo对查询与判定做了缓存,且仅在选中集变化时才重建——这正是在该修复后应保持的稳态实现。
修复四:恢复 Schedules 页计划行菜单中的 "Delete" 选项(#8616)
症状:26.8.0 中,Schedules(计划)页面的行右键菜单里"Delete"(删除计划)选项消失,用户无法再通过右键直接删除计划。
这是典型的回归(regression)问题。查看当前实现 SchedulesTable.tsx 中ScheduleRow的菜单定义,修复后的完整菜单项共 6 项:
| 菜单项 | 行为 | 显示条件 |
|---|---|---|
| Post transaction | 立即过账一笔交易 | 始终显示 |
| Post transaction today | 按今日日期过账 | 始终显示 |
| Restart | 重新开始已完成的计划 | 仅status === 'completed'时显示(hidden控制) |
| Skip next scheduled date | 跳过下一计划日期 | status === 'completed'时隐藏 |
| Complete | 标记计划完成 | status === 'completed'时隐藏 |
| Delete | 删除该计划 | 始终显示 |
对应源码片段:
{ name: 'delete', text: t('Delete'), onClick: () => onAction('delete', schedule.id), },hidden字段是这次回归的关键:Actual 的右键菜单支持通过hidden按行状态动态隐藏菜单项(见 types.ts 中hidden?: boolean与order?: number扩展),而addItems会过滤掉hidden的项。26.8.0 中对计划行菜单做过一轮重构(如将Restart/Skip/Complete按status动态显隐),在调整过程中Delete项被误加(或误继承了)隐藏条件,导致所有计划行的删除入口消失。26.8.1 通过恢复Delete项的默认显示(无hidden条件)修复该问题。
值得注意的是,该菜单同时支持两种触发方式:右键点击行本身,或点击行尾的"三点"按钮(SvgDotsHorizontalTriple,通过dispatchEvent(new MouseEvent('contextmenu', ...))模拟事件)。两种方式共用同一份 items 定义,因此删除选项恢复后,两条路径同时生效。
附:全局右键菜单的工作机制(理解上述修复的基础)
由于 26.8.1 的多项修复(#8606、#8662、#8616)都落在右键菜单体系上,有必要整体梳理其运作流程,便于自建/二次开发时排查同类问题:
- 行组件注册菜单:任意行组件调用
useContextMenu({ triggerRef, enabled, items }),声明自己的右键菜单内容; - 事件触发与状态写入:触发
contextmenu事件后,useContextMenu调用addItems(过滤hidden、按order排序、置isOpen)与setContextMenuPosition(记录鼠标坐标),全部写入 Redux; - 全局渲染:位于应用根部的
ContextMenu组件订阅该 state,在position处渲染 0×0 锚点与Popover + Menu; - 关闭机制:点击菜单项(
handleMenuSelect触发对应onClick后关闭)或点击 Popover 外部(pointerdown全局监听判定popoverRef.contains)都会 dispatchcloseContextMenu,清空 items 并关闭。
这套"事件 → 全局状态 → 统一渲染"的架构,使得菜单行为与触发行解耦,但也要求:触发方必须确保items与enabled状态正确(否则出现 #8662 的错位菜单);监听器不得每次渲染重建(否则出现 #8628 的 CPU 冻结);菜单项显隐条件必须精确(否则出现 #8616 的选项丢失)。26.8.1 恰好对这三个维度各做了一次修补,是理解该体系极佳的案例版本。
升级建议与验证
- 自托管用户将 Docker 标签更新为
26.8.1,重启容器后可在设置页确认版本号; - 验证重点:在预算较大的账本中连续执行"新增交易 → 清除标记 → 删除交易 → 对账"操作,观察是否仍出现卡顿或 CPU 飙升;进入多账户视图右键点击,确认菜单不再错误弹出;打开 Schedules 页对任意计划行右键,确认Delete选项已恢复且可正常删除计划。
需要说明的是,本文对修复原理的分析基于当前仓库源码(useContextMenu.ts、useRefEventListener.ts、ContextMenu.tsx、contextMenuSlice.ts、SchedulesTable.tsx 等)与官方发布说明相互印证得出,可作为排查同类问题时的参考依据。
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考