ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

修复 Actual Budget 中重命名后右键菜单失效问题:React 事件监听与 DOM 节点生命周期的实战剖析

修复 Actual Budget 中重命名后右键菜单失效问题:React 事件监听与 DOM 节点生命周期的实战剖析 修复 Actual Budget 中重命名后右键菜单失效问题React 事件监听与 DOM 节点生命周期的实战剖析【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual导读本文以 Actual Budget本地优先的个人财务管理应用中的一个真实 Bugfix 为切入点深入剖析「预算侧边栏中分类Category与分类组Group被重命名后右键菜单Context Menu无法再次打开直到刷新页面才恢复」这一问题的根因、修复思路与回归测试保障。文章将沿着修复说明文件 fix-context-menu-after-rename.md 的主线结合仓库中的组件实现、上下文菜单状态管理与配套测试用例还原事件监听器与 DOM 节点生命周期之间的耦合问题并给出可在本地复现验证的完整路径。读者读完后将掌握 React 中「事件监听器绑定在可被卸载/重建的 DOM 节点上」这一经典陷阱的识别与修复方法以及 Actual Budget 中右键菜单从组件触发、Redux 状态分发到菜单渲染的完整调用链。修复背景一个需要刷新页面才能恢复的菜单 Bug修复说明文档 fix-context-menu-after-rename.md 的内容非常简短核心信息只有一句Fix the budget category and group menus not opening after a rename until the page is reloaded即修复预算分类和分类组在重命名之后、页面刷新之前右键菜单无法打开的问题。该改动以category: Bugfix归类由作者 MannXo 提交收录于upcoming-release-notes/目录属于发布前待合并的发布说明条目。这句说明看似简单背后却隐藏着一个典型的 React 前端缺陷用户在预算侧边栏中对分类或分类组执行「重命名」后行内名称会变为可编辑输入框编辑结束失焦/回车后该行重新渲染为纯展示形态。此时用户再次点击行尾的「∨」按钮或右键该行菜单却毫无反应必须整页刷新才能恢复。由于该问题直接影响预算管理的高频操作流重命名后紧接着做显示/隐藏、删除或排序因而被作为 Bugfix 处理。下文将沿三条线索展开右键菜单的触发链路如何工作、重命名如何让触发链路失效、以及测试如何保证该场景不再回归。一、右键菜单的触发链路从组件到 Redux 状态1.1 菜单项定义在组件内部预算侧边栏中的每个分类行与分类组行都通过useContextMenuHook 声明自己的右键菜单。以分类为例在 SidebarCategory.tsx 中const { handleContextMenu } useContextMenu({ triggerRef, items: [ { name: rename, text: t(Rename), onClick: () onEditName(category.id), }, !categoryGroup?.hidden { name: toggle-visibility, text: category.hidden ? t(Show) : t(Hide), onClick: () onSave({ ...category, hidden: !category.hidden }), }, { name: delete, text: t(Delete), onClick: () onDelete(category.id), }, ], });triggerRef是绑定在行内容上的ref用于定位菜单触发点items数组描述了菜单内容Rename重命名、Show/Hide显示/隐藏、Delete删除每个菜单项带一个name标识供测试断言与内部逻辑使用与text经过i18n本地化的显示文本。分类组行在 SidebarGroup.tsx 中的菜单更为丰富除 Rename / Show-Hide / Delete 外还包含Sort A to Z / Sort Z to A仅当组内分类数大于 1 时才出现由canSortCategories控制且排序项之前通过Menu.line插入一条分隔线Overwrite with templates仅在功能开关goalTemplatesEnabled打开且传入了onApplyBudgetTemplatesInGroup回调时出现用于把组内所有非隐藏分类一键应用预算模板。注意菜单项数组中的成员可以是false/null等假值如!categoryGroup?.hidden {...}它们会在 Hook 内部被过滤这种「条件式菜单项」写法是 Actual Budget 中菜单配置的常见模式。1.2 useContextMenu监听、分发与手动触发useContextMenu.ts 是整个菜单机制的枢纽它做了两件事注册原生 contextmenu 监听器通过useRefEventListener在triggerRef.current上监听contextmenu事件。事件发生时e.preventDefault()阻止浏览器默认菜单弹出dispatch(addItems(processedItems))把过滤掉隐藏项后的菜单项写入 Reduxdispatch(setContextMenuPosition({ x: e.clientX, y: e.clientY }))记录鼠标位置供菜单在对应坐标渲染。暴露手动触发方法handleContextMenu分类行尾的「∨」按钮Button onPress{handleContextMenu}就是通过它触发的。它会取triggerRef.current.getBoundingClientRect()然后在触发节点上dispatchEvent一个bubbles: true的合成contextmenu事件让事件沿 DOM 树冒泡从而复用同一套监听与分发逻辑。源码注释特别说明「prefer MouseEvent bubbling over dispatching events to allow nesting context menu actions」即通过事件冒泡来支持嵌套菜单等更复杂的交互场景。1.3 Redux 状态contextMenuSlice菜单状态由 contextMenuSlice.ts 管理属于标准的 Redux Toolkit slicetype ContextMenuState { isOpen: boolean; position: { x: number; y: number }; items: ContextMenuItem[]; };三个 reducer 的行为分别是addItems追加菜单项并按order字段排序只要 items 非空就将isOpen置为truesetContextMenuPosition更新菜单弹出位置closeContextMenu关闭菜单并清空 items。从组件层面看菜单能否打开完全取决于contextmenu事件能否被useRefEventListener注册的监听器捕获——这正是 Bug 的根源所在。二、根因分析重命名导致监听器随 DOM 节点一起被销毁2.1 重命名的组件形态切换分类行的渲染非常特殊见 SidebarCategory.tsxInputCell value{category.name} formatter{() displayed} widthflex exposed{editing || temporary} onUpdate{value { if (value ! category.name) { onSave({ ...category, name: value }); } }} onBlur{() onEditName(null)} ... /平时editing为 falseInputCell通过formatter渲染displayed—— 即包含triggerRef所在 View 的展示形态重命名时editing为 trueexposed变为 true行内渲染为输入框原展示节点被卸载编辑结束onBlur触发onEditName(null)或按 Enter 键触发onEditName(null)后editing回到 false一个全新的展示节点被挂载回来。分类组行 SidebarGroup.tsx 的InputCell用法一致exposed{editing}且onKeyDown中 Enter 触发onEdit(null)重命名流程完全相同。2.2 监听器为何「丢失」useContextMenu内部用useRefEventListener(triggerRef, contextmenu, ...)注册监听。查看 useRefEventListener.ts 的实现可以发现两个关键设计// Mutating ref.current doesnt re-run effects, so the element cant go in // a dependency array. Re-resolve it after every render and mirror it into // state; setTarget bails out when the element hasnt moved. const [target, setTarget] useStateEventTarget | null(null); useEffect(() { setTarget( ref instanceof Document || ref instanceof Window ? ref : ref.current, ); });监听器绑定在triggerRef.current指向的那个具体 DOM 元素上target.addEventListener(event, listener)而不是绑定在某个稳定的父容器上重命名导致的展示节点卸载会把那个承载监听器的旧元素销毁编辑结束后重新挂载的展示节点是一个全新的 DOM 元素其上是没有监听器的。按常理setTarget会在每次渲染后重新解析ref.current拿到新元素并重新addEventListener。但这里有一个容易被忽略的 React 细节展示节点是「卸载后再挂载一个全新的节点」ref.current会经历「旧元素 - 新元素」的替换而useRefEventListener中监听器的重新注册依赖target状态的更新。测试代码中的注释一针见血地指出了根因Renaming exposes the cells input, which unmounts the rows display node, then puts a brand new one back when editing ends.即重命名暴露了单元格的输入框这卸载了行内的展示节点编辑结束时又挂回一个全新节点——监听器随旧节点一起消失而新挂载的节点上没有监听器。这就是「重命名后菜单打不开刷新页面才恢复」的完整机理不刷新页面contextmenu事件无人处理isOpen永远保持 false。2.3 同类问题的通用修复思路这个 Bug 的通用解法通常有三类事件委托把contextmenu监听器注册到稳定的祖先节点或 document 上用e.target.closest(...)判断是否命中触发区。优点是不受节点重建影响缺点是需要在回调里自行做命中判定、区分多个触发区确保 ref 在重渲染后指向新节点并重新绑定即保证「重新解析 ref - 重新 addEventListener」的链路在节点替换后确实执行让监听器跟随最新节点不在节点上做持久监听改为纯受控触发所有菜单打开都走handleContextMenu这类程序化触发路径避免原生监听与节点生命周期的耦合。从useRefEventListener的实现与配套测试来看Actual Budget 采用的是让 ref 变化及时反映到监听器重新注册的链路修复并用两个针对性测试用例锁死该回归场景下文详述。三、回归测试重命名后菜单必须立即恢复可用修复是否真正生效不能只看代码还要看测试。仓库在两个测试文件中为「重命名后菜单仍能打开」这一场景专门编写了用例与发布说明的表述一一对应。3.1 分类SidebarCategory.test.tsxSidebarCategory.test.tsx 中的用例opens after the category has been renamed完整复现了 Bug 现场以editing{false}渲染分类「Groceries」对其触发fireEvent.contextMenu断言 Redux 状态contextMenu.isOpen true且菜单项为[rename, toggle-visibility, delete]派发contextMenu/closeContextMenu关闭菜单切换到编辑态editing为 true模拟重命名此时展示节点被卸载、输入框出现修改输入值为Food并触发fireEvent.blur(input)断言onSave收到{ name: Food }以新名称Food重新渲染回展示态模拟编辑结束、全新展示节点挂载再次fireEvent.contextMenu(screen.getByText(Food))断言菜单依然能打开菜单项集合不变。测试通过rerender精确模拟了「展示节点卸载 - 输入框 - 编辑结束 - 新展示节点挂载」的完整生命周期若监听器未在新节点上恢复第 6 步的isOpen断言就会失败——这正是 Bug 未修复时的表现。测试还利用configureTestAppStorecreateTestQueryClient构造了真实的 Redux store 与 React Query 客户端并 mock 了actual-app/core/platform/client/connection保证测试不依赖真实后端。3.2 分类组SidebarGroup.test.tsxSidebarGroup.test.tsx 中的opens after the group has been renamed用例逻辑完全对称分类组「Usual Expenses」重命名为「Renamed Group」后再次对新名称文本触发右键断言isOpen true且菜单项为[rename, toggle-visibility, delete]。两个测试分别覆盖分类与分类组两个入口说明修复对两类行都生效而非只修了其中一个分支。四、数据链路重命名动作如何落到核心层理解 Bug 只需要前端链路但为了让「重命名」这段操作在整体架构中定位清晰这里补充其后的数据链路菜单项的onClick: () onEditName(category.id)只是进入行内编辑态真正持久化发生在InputCell的onUpdate中调用onSave({ ...category, name: value })。onSave对应预算模块的变更mutation层 mutations.ts分类更新走useUpdateCategoryMutationL181-L200mutationFn调用send(category-update, category)通过 Actual 的 RPC 通道把更新发往核心loot-core成功后invalidateQueries(queryClient)使分类列表查询失效并重新拉取失败则通过addNotification弹出错误通知分类组更新走useUpdateCategoryGroupMutationL401-L442调用send(category-group-update, groupNoCategories)其中特意剥离了categories字段——源码注释说明该字段只是客户端附加数据、并非真实数据库字段成功后同样使查询失效两者在提交前都会做重名校验分类层面通过categoryQueries.list()的ensureQueryData获取分组数据检测同组内是否已存在忽略大小写后重名的分类分类组层面则在所有组中检测重名。命中重复时会弹出错误通知如Category {{name}} already exists in group (it may be hidden)并直接 return、不发起服务端请求。查询侧对应 queries.ts 中的categoryQueries.list()通过send(get-categories)获取{ grouped, list }结构并把名为starting balances的分类本地化为「Starting Balances」展示。该查询显式设置staleTime: Infinity并有注释「Manually invalidated when categories change」即查询结果长期复用、只靠 mutation 成功后手动失效刷新——这进一步说明若菜单 Bug 迫使开发者刷新页面虽然页面刷新会重新发起查询但这并非数据链路的预期刷新方式而是对 UI 状态的无奈重置。五、修复带来的影响与本地验证路径修复发布说明归类为Bugfix意味着该改动仅修正既有行为不引入新特性、不改变菜单结构。用户可感知的变化是重命名分类或分类组后无需刷新页面即可立即再次通过右键或行尾「∨」按钮打开菜单继续执行显示/隐藏、删除、排序组等操作。在本地验证与跟进可以按以下路径进行目的仓库相对路径修复说明原文upcoming-release-notes/fix-context-menu-after-rename.md分类行组件与菜单定义packages/desktop-client/src/components/budget/SidebarCategory.tsx分类组行组件与菜单定义packages/desktop-client/src/components/budget/SidebarGroup.tsx菜单触发 Hookpackages/desktop-client/src/hooks/useContextMenu.ts事件监听生命周期 Hookpackages/desktop-client/src/hooks/useRefEventListener.ts菜单 Redux 状态packages/desktop-client/src/contextmenu/contextMenuSlice.ts分类重命名回归测试packages/desktop-client/src/components/budget/SidebarCategory.test.tsx分类组重命名回归测试packages/desktop-client/src/components/budget/SidebarGroup.test.tsx重命名数据变更与重名校验packages/desktop-client/src/budget/mutations.ts分类查询与失效策略packages/desktop-client/src/budget/queries.ts若需要运行相关测试可在仓库根目录基于 Yarn workspace 结构进入桌面客户端包后执行对应的 Vitest 测试例如yarn workspace actual-desktop-client test SidebarCategory具体脚本名以 packages/desktop-client/package.json 中声明的测试命令为准。运行时应观察到SidebarCategory context menu与SidebarGroup context menu两个 describe 块中的重命名用例全部通过。总结这个看似「一行字」的 Bugfix完整覆盖了 Actual Budget 桌面客户端中右键菜单从组件声明SidebarCategory.tsx / SidebarGroup.tsx、Hook 触发useContextMenu.ts、原生监听useRefEventListener.ts、Redux 状态contextMenuSlice.ts到数据持久化mutations.ts的整条链路。其根因是 React 组件在重命名过程中对展示节点进行「卸载-重建」而contextmenu监听器绑定在旧节点上随之丢失修复确保节点替换后监听器重新挂载并以两个与发布说明严格对应的回归测试opens after the category has been renamed与opens after the group has been renamed锁死该场景。对开发者而言本文的价值在于两点一是当你在 React 应用中遇到「某个交互在 DOM 节点被重建后失效、刷新页面即恢复」的问题时应优先检查事件监听器是否绑定在被替换的节点上并考虑事件委托或将监听上移到稳定节点二是 Actual Budget 的测试写法提供了一套可复用的模板——用rerender精确编排「卸载 - 输入 - 重挂载」的生命周期再断言交互可用性从而在 CI 中持续守护这类 UI 状态陷阱。【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表