Skip to content

fix(core): fix crash when removeChild during deactivation callbacks#2948

Open
GuoLei1990 wants to merge 1 commit intogalacean:dev/2.0from
GuoLei1990:fix/entity-deactivation-callback-order
Open

fix(core): fix crash when removeChild during deactivation callbacks#2948
GuoLei1990 wants to merge 1 commit intogalacean:dev/2.0from
GuoLei1990:fix/entity-deactivation-callback-order

Conversation

@GuoLei1990
Copy link
Copy Markdown
Member

@GuoLei1990 GuoLei1990 commented Mar 27, 2026

Summary

Fixes #2947

  • Children-first deactivation order: _setInActiveInHierarchy now recurses children (reverse traversal) before processing the current node, aligning with Unity's deactivation order. This ensures child callbacks complete before parent callbacks fire
  • Scene guard: _setActiveComponents skips components whose entity's _scene was cleared by an earlier callback's removeChild, preventing null access crashes in sibling removal scenarios

Root cause

The two-phase deferred callback mechanism (Phase 1: collect components, Phase 2: fire callbacks) caused a timing issue:

  • Phase 1 sets all nodes' _isActiveInScene = false upfront
  • A callback in Phase 2 calls removeChild(B)_processInActive is skipped (flag already false) → but _traverseSetOwnerScene(B, null) still runs, clearing B._scene
  • Phase 2 continues to B's _onDisableInScenethis.scene._componentsManager → crash (scene is null)

Known limitation / future improvement

The _scene null check and _phasedActiveInScene guard handle deferred callback safety at different layers:

  • _scene check in _setActiveComponents: prevents crash from removeChild clearing scene
  • _phasedActiveInScene in Component._setActive: prevents duplicate callback dispatch

A unified mechanism (e.g. _deactivateCallbackPending flag set during Phase 1, cleared after Phase 2) could replace both checks at the source, providing consistent protection for all cases (removeChild, isActive = false, etc.) in a single layer. This is deferred for a future PR.

Test plan

  • removeChild during parent's onDisable — original crash scenario
  • sibling removes another sibling during onDisable — scene guard scenario
  • child tries to removeChild its deactivating parent — reentrant protection
  • setting sibling isActive=false during onDisable — no duplicate dispatch
  • deactivation callbacks in children-first order — order verification (C, B, A)
  • Existing InActiveAndActive and Script lifecycle tests pass

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved entity deactivation safety when removing child entities during callback execution, preventing potential null reference errors in complex entity removal scenarios.
  • Tests

    • Added comprehensive test coverage for entity removal during deactivation callbacks to ensure stability in edge cases.

…alacean#2947)

Children-first deactivation order + scene guard for deferred callback safety.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

Walkthrough

The PR fixes a bug where removing child entities during a parent's onDisable callback causes the child's scene reference to become null prematurely. Changes include safeguarding component deactivation to skip already-cleared entities and processing children in reverse order during hierarchy inactivation.

Changes

Cohort / File(s) Summary
Entity Deactivation Safety
packages/core/src/Entity.ts
Modified _setActiveComponents to assign looped components to local variables and skip _setActive calls when the component's entity scene is already cleared. Updated _setInActiveInHierarchy to process children in reverse order before mutating hierarchy/scene flags, preventing null-reference issues during mid-callback entity removal.
Deactivation/Removal Test Suite
tests/src/core/Entity.test.ts
Added comprehensive test coverage for removeChild scenarios executed during onDisable callbacks, including parent removing children, sibling-sibling removal, hierarchy ordering verification, and null-safety assertions.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A child removed mid-farewell's call,
Once left the scene in bitter thrall—
But now we traverse back-to-front with care,
And check if scenes have vanished in the air.
Safe deactivation flows so clean,
When order matters most unseen! 🌿

🚥 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 fix: preventing crashes when removeChild is called during deactivation callbacks, which is the core objective of this pull request.
Linked Issues check ✅ Passed The pull request fully addresses #2947 by implementing children-first deactivation order and adding scene guards to prevent null-access crashes during removeChild in deactivation callbacks.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the identified bug: modified private deactivation methods in Entity.ts and added comprehensive test coverage for the fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 27, 2026

Codecov Report

❌ Patch coverage is 70.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.44%. Comparing base (c3d2160) to head (86a54e1).

Files with missing lines Patch % Lines
packages/core/src/Entity.ts 70.00% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #2948      +/-   ##
===========================================
+ Coverage    77.07%   77.44%   +0.36%     
===========================================
  Files          899      899              
  Lines        98224    98296      +72     
  Branches      9678     9671       -7     
===========================================
+ Hits         75705    76122     +417     
+ Misses       22351    22008     -343     
+ Partials       168      166       -2     
Flag Coverage Δ
unittests 77.44% <70.00%> (+0.36%) ⬆️

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

☔ View full report in Codecov by Sentry.
📢 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.

@GuoLei1990 GuoLei1990 added the bug Something isn't working label Mar 27, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/Entity.ts`:
- Around line 696-699: The current loop skips calling component._setActive when
a component was detached (if !isActive && !component._entity._scene), which
prevents phased flags (_phasedActive / _phasedActiveInScene) from being cleared;
instead remove the early continue and ensure component._setActive(isActive,
activeChangeFlag) is always invoked for that component so the phased inactive
transition runs even when _scene is null (the callback invocation itself can
still be gated inside _setActive/_processInActive if needed). Target the loop
handling activeChangedComponents and update the logic around
component._setActive to always call it for deactivate transitions.
- Around line 693-702: The loop in Entity._setActiveComponents can throw if a
child onDisable clears component._entity._scene, leaking
this._activeChangedComponents; capture the current scene (e.g., const scene =
this._scene) before the loop, then wrap the for-loop and component._setActive
call in a try/finally where in finally you call
scene._componentsManager.putActiveChangedTempList(activeChangedComponents) and
set this._activeChangedComponents = null so the temp list is always returned and
the field cleared even if reentrant mutations throw; keep the existing skip
check (if (!isActive && !component._entity._scene) continue) inside the try.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 64654315-da1f-4baf-895a-51e10f65ce4f

📥 Commits

Reviewing files that changed from the base of the PR and between c3d2160 and 86a54e1.

📒 Files selected for processing (2)
  • packages/core/src/Entity.ts
  • tests/src/core/Entity.test.ts

Comment on lines 693 to 702
private _setActiveComponents(isActive: boolean, activeChangeFlag: ActiveChangeFlag): void {
const activeChangedComponents = this._activeChangedComponents;
for (let i = 0, length = activeChangedComponents.length; i < length; ++i) {
activeChangedComponents[i]._setActive(isActive, activeChangeFlag);
const component = activeChangedComponents[i];
// Skip components whose scene was already cleared by an earlier callback's removeChild
if (!isActive && !component._entity._scene) continue;
component._setActive(isActive, activeChangeFlag);
}
this._scene._componentsManager.putActiveChangedTempList(activeChangedComponents);
this._activeChangedComponents = null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Always release _activeChangedComponents from the original scene.

A child onDisable can still clear this._scene on the entity currently running _setActiveComponents. Then Line 701 throws before Line 702 runs, so the temp list never goes back to the pool and the entity stays stuck with _activeChangedComponents !== null. Capture the scene up front and do the release/reset in finally, even if the reentrant mutation is supposed to throw.

Possible fix
 private _setActiveComponents(isActive: boolean, activeChangeFlag: ActiveChangeFlag): void {
+  const scene = this._scene;
   const activeChangedComponents = this._activeChangedComponents;
-  for (let i = 0, length = activeChangedComponents.length; i < length; ++i) {
-    const component = activeChangedComponents[i];
-    // Skip components whose scene was already cleared by an earlier callback's removeChild
-    if (!isActive && !component._entity._scene) continue;
-    component._setActive(isActive, activeChangeFlag);
-  }
-  this._scene._componentsManager.putActiveChangedTempList(activeChangedComponents);
-  this._activeChangedComponents = null;
+  try {
+    for (let i = 0, length = activeChangedComponents.length; i < length; ++i) {
+      const component = activeChangedComponents[i];
+      // Skip components whose scene was already cleared by an earlier callback's removeChild
+      if (!isActive && !component._entity._scene) continue;
+      component._setActive(isActive, activeChangeFlag);
+    }
+  } finally {
+    scene._componentsManager.putActiveChangedTempList(activeChangedComponents);
+    this._activeChangedComponents = null;
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private _setActiveComponents(isActive: boolean, activeChangeFlag: ActiveChangeFlag): void {
const activeChangedComponents = this._activeChangedComponents;
for (let i = 0, length = activeChangedComponents.length; i < length; ++i) {
activeChangedComponents[i]._setActive(isActive, activeChangeFlag);
const component = activeChangedComponents[i];
// Skip components whose scene was already cleared by an earlier callback's removeChild
if (!isActive && !component._entity._scene) continue;
component._setActive(isActive, activeChangeFlag);
}
this._scene._componentsManager.putActiveChangedTempList(activeChangedComponents);
this._activeChangedComponents = null;
private _setActiveComponents(isActive: boolean, activeChangeFlag: ActiveChangeFlag): void {
const scene = this._scene;
const activeChangedComponents = this._activeChangedComponents;
try {
for (let i = 0, length = activeChangedComponents.length; i < length; ++i) {
const component = activeChangedComponents[i];
// Skip components whose scene was already cleared by an earlier callback's removeChild
if (!isActive && !component._entity._scene) continue;
component._setActive(isActive, activeChangeFlag);
}
} finally {
scene._componentsManager.putActiveChangedTempList(activeChangedComponents);
this._activeChangedComponents = null;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/Entity.ts` around lines 693 - 702, The loop in
Entity._setActiveComponents can throw if a child onDisable clears
component._entity._scene, leaking this._activeChangedComponents; capture the
current scene (e.g., const scene = this._scene) before the loop, then wrap the
for-loop and component._setActive call in a try/finally where in finally you
call scene._componentsManager.putActiveChangedTempList(activeChangedComponents)
and set this._activeChangedComponents = null so the temp list is always returned
and the field cleared even if reentrant mutations throw; keep the existing skip
check (if (!isActive && !component._entity._scene) continue) inside the try.

Comment on lines +696 to +699
const component = activeChangedComponents[i];
// Skip components whose scene was already cleared by an earlier callback's removeChild
if (!isActive && !component._entity._scene) continue;
component._setActive(isActive, activeChangeFlag);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't skip the deactivate state transition for detached components.

When a sibling callback removes C, C._setParent(null) clears _scene after C._isActiveInHierarchy / C._isActiveInScene were already flipped, so there is no second _processInActive(). This continue then skips the Component._setActive(false) path that clears _phasedActive / _phasedActiveInScene, which means reattaching C later can miss onEnable / onEnableInScene. If the callback itself must be skipped once _scene is gone, the phased state still needs to transition to inactive here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/Entity.ts` around lines 696 - 699, The current loop skips
calling component._setActive when a component was detached (if !isActive &&
!component._entity._scene), which prevents phased flags (_phasedActive /
_phasedActiveInScene) from being cleared; instead remove the early continue and
ensure component._setActive(isActive, activeChangeFlag) is always invoked for
that component so the phased inactive transition runs even when _scene is null
(the callback invocation itself can still be gated inside
_setActive/_processInActive if needed). Target the loop handling
activeChangedComponents and update the logic around component._setActive to
always call it for deactivate transitions.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant