Skip to content

fix(ui): correct root-canvas demotion state and listener bookkeeping#3070

Open
cptbtptpbcptdtptp wants to merge 1 commit into
galacean:dev/2.0from
cptbtptpbcptdtptp:fix/ui-canvas-demote-2.0
Open

fix(ui): correct root-canvas demotion state and listener bookkeeping#3070
cptbtptpbcptdtptp wants to merge 1 commit into
galacean:dev/2.0from
cptbtptpbcptdtptp:fix/ui-canvas-demote-2.0

Conversation

@cptbtptpbcptdtptp

@cptbtptpbcptdtptp cptbtptpbcptdtptp commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ports three coupled packages/ui fixes from #3068 (fix/shaderlab) to dev/2.0 — all three bug sites are byte-identical here.

  1. Demote loop dirtied the wrong object: UICanvas._setIsRootCanvas(false) called Utils.setRootCanvasDirty(this) instead of (element), so demoted elements kept a stale _rootCanvas/_indexInRootCanvas and a clean dirty flag — but the new root's walk re-assignment is gated on the element's dirty flag, so they kept rendering into the demoted canvas's dead queue and never re-homed.
  2. Listener leak on chain shrink: Utils._registerListener truncated a shrinking listening chain (listeningEntities.length = count) without unregistering the dropped tail, permanently leaking listeners on ex-ancestor entities.
  3. Nested-canvas demotion relied on that leak: UpdateFlagManager.dispatch iterates a backward snapshot (a listener appended mid-dispatch is never invoked by that dispatch), and the enabling canvas claims root status only after its CanvasEnableInScene dispatch returns — so the demote cascade's searchRootCanvasInParents could not find it and promoted nested canvases to root. Convergence previously depended on a leaked listener happening to sit at a low index of the backward iteration. The cascade now receives the enabling canvas as an explicit successor (_setIsRootCanvas(false, successor)).

(2) and (3) must land together: fixing the leak alone breaks nested-canvas re-rooting.

Tests

Two regression tests added to tests/src/ui/UICanvas.test.ts:

  • re-homes elements to the new root canvas when theirs loses root status — pins (1)
  • re-roots nested canvases to the enabling ancestor canvas — pins (3), exercised through (2)

Negative validation: on unfixed dev/2.0 (rebuilt dist without the source fixes) both new tests fail and all 6 existing tests pass; with the fixes the full ui suite is green (66/66, node22 b:module + b:types, vitest from tests/).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed UI canvas transitions when enabling ancestor canvases.
    • Ensured nested UI elements and canvases are correctly reassigned to the new root canvas during rendering.
    • Prevented stale event listeners from remaining active after UI hierarchy changes.
  • Tests

    • Added coverage for canvas re-rooting and nested canvas behavior.

Three coupled fixes ported from galacean#3068 (fix/shaderlab):

- The demote loop dirtied the canvas itself instead of each element
  (setRootCanvasDirty(this) -> (element)), so demoted elements kept stale
  _rootCanvas/_indexInRootCanvas and never re-homed: the new root's walk
  re-assignment is gated on the element's dirty flag.
- _registerListener truncated a shrinking listening chain without
  unregistering the dropped tail, leaking listeners on ex-ancestors.
- Releasing that leak exposed that nested-canvas demotion relied on it:
  UpdateFlagManager.dispatch iterates a backward snapshot, so a listener
  appended mid-dispatch is never invoked by that dispatch, and the enabling
  canvas claims root status only after its dispatch returns — the cascade's
  search could not find it and promoted nested canvases to root. The demote
  cascade now receives the enabling canvas as an explicit successor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Listener cleanup

Layer / File(s) Summary
Tail listener cleanup
packages/ui/src/Utils.ts
_registerListener unregisters listeners from entities left beyond the final traversal count before truncating its tracking list.

Root canvas transitions

Layer / File(s) Summary
Successor-aware root transitions and validation
packages/ui/src/component/UICanvas.ts, tests/src/ui/UICanvas.test.ts
Canvas enable events propagate the enabling canvas through nested root demotion, use it as a fallback root, reset affected-element dirtiness, and test element re-homing and nested canvas re-rooting.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CanvasEnableInScene
  participant UICanvas
  participant Utils
  CanvasEnableInScene->>UICanvas: Pass enabling canvas as successor
  UICanvas->>Utils: Search parent root canvas
  Utils-->>UICanvas: Return parent root or successor fallback
  UICanvas->>UICanvas: Propagate root status through nested elements
Loading

Suggested reviewers: guolei1990

Poem

I’m a rabbit with roots in a canvas bright,
Re-homing sprites by lantern light.
Stale ears unhook, old trails grow thin,
Nested canvases settle in.
Hop, hop—the render walk begins!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main UI fix: root-canvas demotion behavior and listener cleanup.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.82%. Comparing base (9fe48c6) to head (3c010cb).

Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #3070      +/-   ##
===========================================
- Coverage    79.82%   79.82%   -0.01%     
===========================================
  Files          904      904              
  Lines       101297   101309      +12     
  Branches     11423    11421       -2     
===========================================
+ Hits         80865    80867       +2     
- Misses       20250    20260      +10     
  Partials       182      182              
Flag Coverage Δ
unittests 79.82% <100.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/src/ui/UICanvas.test.ts (1)

354-389: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a second element to prevent hiding concurrent modification bugs.

Testing the demote cascade with only a single nested element (image) masks concurrent modification bugs (such as removing elements from an array while looping over it, which succeeds for length 1 but skips elements for length 2+). Adding a second element ensures the logic robustly processes all children and would successfully catch the forEach iteration bug in UICanvas.ts.

♻️ Proposed test update
   it("re-homes elements to the new root canvas when theirs loses root status", () => {
     const outerEntity = root.createChild("outer");
     const innerEntity = outerEntity.createChild("inner");
     const innerCanvas = innerEntity.addComponent(UICanvas);
     const imageEntity = innerEntity.createChild("image");
     const image = imageEntity.addComponent(Image);
+    const image2Entity = innerEntity.createChild("image2");
+    const image2 = image2Entity.addComponent(Image);
 
     // Collected by the inner canvas while it is the root.
     // `@ts-ignore`
     innerCanvas._getRenderers();
     // `@ts-ignore`
     expect(image._getRootCanvas()).to.eq(innerCanvas);
+    // `@ts-ignore`
+    expect(image2._getRootCanvas()).to.eq(innerCanvas);
     // `@ts-ignore`
     expect(innerCanvas._disorderedElements.length).to.be.greaterThan(0);
 
     // Enabling a canvas on an ancestor demotes the inner one.
     const outerCanvas = outerEntity.addComponent(UICanvas);
     // `@ts-ignore`
     expect(innerCanvas._isRootCanvas).to.be.false;
     // `@ts-ignore`
     expect(outerCanvas._isRootCanvas).to.be.true;
     // The element's canvas state must be reset (the walk's re-assignment is gated on the
     // dirty flag), otherwise it keeps rendering into the demoted canvas's dead queue.
     // `@ts-ignore`
     expect(image._isRootCanvasDirty).to.be.true;
     // `@ts-ignore`
     expect(image._rootCanvas).to.be.null;
+    // `@ts-ignore`
+    expect(image2._isRootCanvasDirty).to.be.true;
+    // `@ts-ignore`
+    expect(image2._rootCanvas).to.be.null;
 
     // The new root adopts it on its next walk.
     // `@ts-ignore`
     outerCanvas._getRenderers();
     // `@ts-ignore`
     expect(image._getRootCanvas()).to.eq(outerCanvas);
+    // `@ts-ignore`
+    expect(image2._getRootCanvas()).to.eq(outerCanvas);
 
     outerEntity.destroy();
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/ui/UICanvas.test.ts` around lines 354 - 389, Add a second child UI
element with an Image component in the test “re-homes elements to the new root
canvas when theirs loses root status,” then assert both images are marked dirty,
have null root canvases after demotion, and are reassigned to outerCanvas after
its renderer walk. Keep the existing assertions and cleanup behavior intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/src/ui/UICanvas.test.ts`:
- Around line 354-389: Add a second child UI element with an Image component in
the test “re-homes elements to the new root canvas when theirs loses root
status,” then assert both images are marked dirty, have null root canvases after
demotion, and are reassigned to outerCanvas after its renderer walk. Keep the
existing assertions and cleanup behavior intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fdadae4d-fb3c-4d95-a316-e04f616e96b7

📥 Commits

Reviewing files that changed from the base of the PR and between 9fe48c6 and 3c010cb.

📒 Files selected for processing (3)
  • packages/ui/src/Utils.ts
  • packages/ui/src/component/UICanvas.ts
  • tests/src/ui/UICanvas.test.ts

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总结

#3068 的三处 packages/ui 修复原样 backport 到 dev/2.0。我在 #3068 已逐轮审过这三处(round-3 setRootCanvasDirty(this)(element)#2375-class bug、round-6 listener tail-release 泄漏、round-7 hosting 状态机 successor 线程化),本轮核心是校验:前像 bug 真在 dev/2.0 上、修复与 #3068 逐字等价、类型适配到 mainline ui 接口名正确、两个新测试可反向证伪。全部通过,无 P0/P1/P2/P3,可合。

核验结论(逐条)

1. Utils.ts — listener 链缩短的 tail 泄漏

  • dev/2.0 前像确认:_registerListener 走完 while 后直接 listeningEntities.length = count,链变短时 [count, oldLength) 的尾部实体仍挂着 _registerModifyListener → 永久泄漏。
  • 修复补的 for (i=count; i<n) listeningEntities[i]._unRegisterModifyListener(listener)#3068 tip 3c6c88c0 逐字节相同(含注释)。✓

2. UICanvas.ts:414CanvasEnableInScene 分支缺 successor

  • 前像 _setIsRootCanvas(false) 无 successor → 嵌套 canvas demote cascade 里 searchRootCanvasInParents(element) 看不到「刚 enable、尚未 claim root」的祖先 canvas → 嵌套 canvas 被误提升为 root。
  • 修复 _setIsRootCanvas(false, <UICanvas>param) + _setIsRootCanvas 新增 successor 形参、cascade 里 searchRootCanvasInParents(element) ?? successor,逻辑与 #3068 等价(#3068IUIElement/IUIGroupAble 是它 round-5 把接口上移 core 后的名字,dev/2.0 无此重构,backport 正确沿用 mainline 的 IElement/IGroupAble行为一致)。✓

3. UICanvas.ts:656 — 非 canvas 元素 setRootCanvasDirty(this)(element)

  • 前像 dirty 的是「正在 demote 的 canvas 自己」,而紧接着 disorderedElements.length = 0 把这张 canvas 的列表清空 → 对元素 re-home 是 no-op;元素保留 stale _rootCanvas/_indexInRootCanvas + 干净的 dirty flag,新 root 的 walk(以元素 dirty flag 为门)跳过它 → 渲染进已死队列、永不 re-home。
  • 修复改为 dirty element 本身(setRootCanvasDirty 内部置 _isRootCanvasDirty=true + _registerRootCanvas(element,null)_rootCanvas 清 null),正解。✓

测试(两个新增均可反向证伪)

  • re-homes elements...:断言 image._isRootCanvasDirty===true + image._rootCanvas===null。前像走 setRootCanvasDirty(this) → image 状态不动 → _isRootCanvasDirty 仍 false → 前像 fail。✓
  • re-roots nested canvases...:断言 inner._isRootCanvas===false + inner._getRootCanvas()===outerCanvas。前像无 successor → searchRootCanvasInParents(inner) 返 null → _setIsRootCanvas(!null) 把 inner 误提升为 root → 断言 fail。✓
  • Imagecomponent/index.ts:6index.ts export * from "./component" 导出,IElementdev/2.0 声明了 _rootCanvas/_indexInRootCanvas/_isRootCanvasDirty/_getRootCanvas(),测试引用符号全部存在。✓

补充说明

  • #3068 里的「ui/Utils + spine 双源同步」债在此不适用dev/2.0 无 spine 包(spine-in-UICanvas 是 #3068 stacked 在 fix/shaderlab 上的工作),backport 只需改 ui/Utils.ts 一处,已完整。
  • 新增注释与 #3068 已接受版本逐字相同(多句解释性 // 块,非 JSDoc),风格随 ported 代码一致,不另提。

LGTM。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants