fix: ensure child node is removed from parent#5011
fix: ensure child node is removed from parent#5011thjxs wants to merge 3 commits intoantvis:masterfrom
Conversation
Summary of ChangesHello @thjxs, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 此拉取请求旨在解决 Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request fixes a bug where a child node is not correctly removed from its parent's data store. The change correctly adjusts the order of operations during cell removal, ensuring the parent-child relationship is updated before the cell is unreferenced. The fix is logical and effectively addresses the issue. The code looks good.
src/model/collection.ts
Outdated
|
|
||
| if (!options.dryrun) { | ||
| if (parent) { | ||
| parent.removeChild(cell) |
There was a problem hiding this comment.
且这里 parent.removeChild(cell) 没有把 options 传进去,如果用户 removeCells({ silent: true }) 会出错。
|
done |
| } | ||
|
|
||
| if (cell.model === this) { | ||
| cell.model = null |
There was a problem hiding this comment.
重新梳理了一下删除逻辑,发现父子节点的删除过程中由于移除了 model 的引用,导致父子节点管理关系异常,甚至出现删除父节点但子节点未被删除的情况,所以增加了一个 afterRemoved 事件来处理这些清理工作。
|
@MonkeyCode-AI review 一下 |
|
MonkeyCode-AI 正在分析任务... |
MonkeyCode-AI
left a comment
There was a problem hiding this comment.
我是 MonkeyCode AI 编程助手,你可以在 GitHub 仓库的 PR 中 at @MonkeyCode-AI 来呼唤我。
任务执行细节请参考: https://monkeycode-ai.com
代码审查结果
通过新增 afterRemoved 事件并延后清理 cell.model,修复了 collection removed 回调过早置空导致的父子关系残留问题,但目前事件触发时机与静默/ dryrun 语义仍有改进空间,且测试覆盖不足。
| 🚨 Critical | 💡 Suggestion | |
|---|---|---|
| 0 | 2 | 2 |
src/model/collection.ts
Outdated
| for (let i = 0; i < removed.length; i += 1) { | ||
| const cell = removed[i] | ||
| this.trigger('afterRemoved', { cell, options }) | ||
| } |
There was a problem hiding this comment.
Warning
afterRemoved 在 silent: true 时仍会触发,可能违反既有事件语义
当前 removed 事件会受 options.silent 控制,但新增的 afterRemoved 循环不受 silent 控制:即使调用方显式 silent: true,仍会触发 afterRemoved。这会让新增事件的语义与其它事件不一致,并可能导致依赖 silent 的调用方出现副作用(例如批量/重置/内部清理流程中不希望触发任何事件)。尤其是本 PR 在 Model.setup 中订阅了 afterRemoved 来做 cell.model=null,这意味着即便 silent 删除也会改变 cell 状态。
建议: 将 afterRemoved 的触发也置于 !options.silent 条件下;或者至少在事件命名/文档中明确它是一个“内部生命周期钩子”,不受 silent 影响(但这通常更难维护)。更推荐与 removed 保持一致。
| for (let i = 0; i < removed.length; i += 1) { | |
| const cell = removed[i] | |
| this.trigger('afterRemoved', { cell, options }) | |
| } | |
| for (let i = 0; i < removed.length; i += 1) { | |
| const cell = removed[i] | |
| if (!options.silent) { | |
| this.trigger('afterRemoved', { cell, options }) | |
| } | |
| } |
src/model/collection.ts
Outdated
| for (let i = 0; i < removed.length; i += 1) { | ||
| const cell = removed[i] | ||
| this.trigger('afterRemoved', { cell, options }) | ||
| } |
There was a problem hiding this comment.
Warning
afterRemoved 在 dryrun: true 时也会触发,可能引入额外副作用
dryrun 的语义通常是“不执行实际移除动作/副作用”,当前实现虽然不会调用 cell.remove(),但仍会触发 removed(在非 silent 情况下)以及 afterRemoved。这会导致 dryrun 也可能把 cell.model 置空(Model 订阅了 afterRemoved),与 dryrun 预期相悖,进而影响调用方用 dryrun 做探测/预演的场景。
建议: 让 afterRemoved 与真正的移除副作用保持一致:仅在 !options.dryrun 时触发(或至少在 Model 的处理逻辑中判断 !args.options.dryrun)。
| for (let i = 0; i < removed.length; i += 1) { | |
| const cell = removed[i] | |
| this.trigger('afterRemoved', { cell, options }) | |
| } | |
| for (let i = 0; i < removed.length; i += 1) { | |
| const cell = removed[i] | |
| if (!options.dryrun) { | |
| this.trigger('afterRemoved', { cell, options }) | |
| } | |
| } |
| collection.on('afterRemoved', (args) => { | ||
| const cell = args.cell | ||
| if (cell.model === this) { | ||
| cell.model = null | ||
| } | ||
| }) |
There was a problem hiding this comment.
Tip
💡 建议在 afterRemoved 回调中同时检查 dryrun,避免预演删除修改 cell 状态
即便 collection 侧不调整事件触发条件,Model 这里也可以增加一层保护,保证 dryrun 不会改变 cell.model(属于更安全的防御式编程)。当前实现会在 dryrun 时把 cell.model 置空,可能影响外部逻辑(例如调用方 dryrun 后继续使用 cell 并假设仍挂在 model 上)。
建议: 在回调中增加 !args.options.dryrun 判断。
| collection.on('afterRemoved', (args) => { | |
| const cell = args.cell | |
| if (cell.model === this) { | |
| cell.model = null | |
| } | |
| }) | |
| collection.on('afterRemoved', (args) => { | |
| const cell = args.cell | |
| if (!args.options.dryrun && cell.model === this) { | |
| cell.model = null | |
| } | |
| }) |
| private parent?: FakeCell | ||
|
|
||
| constructor( | ||
| id: string, | ||
| opts: { zIndex?: number; type?: 'node' | 'edge' | null } = {}, | ||
| parent?: FakeCell | ||
| ) { | ||
| this.id = id | ||
| this.zIndex = opts.zIndex ?? 0 | ||
| this.type = opts.type ?? null | ||
| this.props = {} | ||
| this.listeners = {} | ||
| this.parent = parent | ||
| } | ||
|
|
||
| getParent() { | ||
| if (this.parent) { | ||
| return this.parent | ||
| } | ||
| return null |
There was a problem hiding this comment.
Tip
💡 测试用 FakeCell 新增 parent/getParent 但未新增对应断言用例,未覆盖该 PR 的核心场景
本次修复的核心是“删除子节点后父节点 children/store 不应残留,以及删除父节点应级联删除子节点”。但当前测试文件仅新增了 parent/getParent,并未新增关于删除父子关系的一条回归用例断言(且 FakeCell.toJSON 也不包含 children 信息)。这会导致 PR 描述中的 bug 没有自动化覆盖,后续容易回归。
建议: 补充一条最小回归测试:构造 parent/child 关系(需要 FakeCell 至少能表达 children 或能模拟 store 行为),执行 remove(child) 后断言 parent 的 children 变更;执行 remove(parent) 后断言 child.remove 被调用/child 被从 collection 移除。若测试难以在 Collection 层完成,可在更接近真实 Cell/Model 的测试层补充。
| private parent?: FakeCell | |
| constructor( | |
| id: string, | |
| opts: { zIndex?: number; type?: 'node' | 'edge' | null } = {}, | |
| parent?: FakeCell | |
| ) { | |
| this.id = id | |
| this.zIndex = opts.zIndex ?? 0 | |
| this.type = opts.type ?? null | |
| this.props = {} | |
| this.listeners = {} | |
| this.parent = parent | |
| } | |
| getParent() { | |
| if (this.parent) { | |
| return this.parent | |
| } | |
| return null |
📝 Description
Fix #5009
collection 的 removed 事件回调中存在将 cell.model 置空的逻辑,且该事件在调用 cell.remove 之前触发,导致删除流程异常,具体表现为:
本次修改新增 afterRemoved 事件,并将 cell.model 置空的逻辑放在该事件中处理。
🖼️ Screenshot
Before

After

💡 Motivation and Context
🧩 Types of changes
🔍 Self Check before Merge