`ComponentPool` returns components to the pool while they are still in use, and loses components removed before mounting
メンテナーはふだん 1 日以内に返信
@ufrshubham がすでに取り組んでいます。
2026年11月1日 から。
評価
この issue はまだ評価されていません。
説明
What happened?
ComponentPool decides a component can be reused when its removed future completes (mounted.then → removed.then → _release). But removed doesn't mean the component has left the tree, so the pool gets four things wrong:
- Moving a pooled component returns it to the pool while it's still mounted. A move goes through
_handleLifecycleEventMove, which calls_remove(oldParent), completingremoved, and then mounts the component under the new parent straight away._releaseruns a microtask later and puts the still-mounted component in the pool, so the nextacquire()hands out a component that's already in use. - Removing an ancestor returns its pooled descendants to the pool while they're still attached.
_removecompletesremovedfor every component in the subtree, but only the top one gets its_parentcleared. The descendants go back to the pool while still being children of the removed ancestor. Acquiring one of them and adding it again goes through the move path, which calls itsonRemovea second time. - A component removed before it's mounted is never returned. Removing a component before it's mounted (for example in the same tick it was added) cancels its pending add, so
mountednever completes and the release listener never fires. A component that's acquired and never added is lost too, sinceComponentPoolhas norelease(). - A released component can't be reused in the same tick. The release runs in a microtask after the removal. A game that removes and spawns in the same frame, the typical bullet pattern, keeps calling the factory, and
availableCountis too low until the microtasks run.
What do you expect?
A component should go back to the pool exactly when it is detached from the component tree, and synchronously at that moment:
- A move to a new parent doesn't return it.
- Removing an ancestor doesn't return it either. The component stays attached to that ancestor, and goes back to the pool only when it is itself removed.
- A component removed before mounting is returned.
- After
removeFromParent()and the next lifecycle pass, the component can be acquired again in the same tick.
How can we reproduce this?
Each test below fails at the line marked // Fails:, with the value shown in the comment.
import 'package:flame/components.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
class _Pooled() extends PositionComponent {
int removeCount = 0;
@override
void onRemove() {
removeCount++;
super.onRemove();
}
}
ComponentPool<_Pooled> _createPool() =>
ComponentPool<_Pooled>(factory: _Pooled.new);
void main() {
testWithFlameGame('moving a component releases it', (game) async {
final pool = _createPool();
final a = Component();
final b = Component();
await game.ensureAddAll([a, b]);
final component = pool.acquire();
await a.ensureAdd(component);
component.parent = b;
await game.ready();
expect(component.isMounted, isTrue);
expect(component.parent, b);
expect(pool.availableCount, 0); // Fails: 1
});
testWithFlameGame('acquire returns a mounted component', (game) async {
final pool = _createPool();
final a = Component();
final b = Component();
await game.ensureAddAll([a, b]);
final component = pool.acquire();
await a.ensureAdd(component);
component.parent = b;
await game.ready();
expect(pool.acquire().isMounted, isFalse); // Fails: true
});
testWithFlameGame('removing an ancestor releases a child', (game) async {
final pool = _createPool();
final container = Component();
await game.ensureAdd(container);
final component = pool.acquire();
await container.ensureAdd(component);
container.removeFromParent();
await game.ready();
expect(component.parent, container);
expect(pool.availableCount, 0); // Fails: 1
});
testWithFlameGame('re-adding that child calls onRemove again', (
game,
) async {
final pool = _createPool();
final container = Component();
await game.ensureAdd(container);
final component = pool.acquire();
await container.ensureAdd(component);
container.removeFromParent();
await game.ready();
expect(component.removeCount, 1);
final reacquired = pool.acquire();
expect(identical(reacquired, component), isTrue);
game.add(reacquired);
await game.ready();
expect(reacquired.parent, game);
expect(reacquired.removeCount, 1); // Fails: 2
});
testWithFlameGame('removed before mounting is lost', (game) async {
final pool = _createPool();
final component = pool.acquire();
game.add(component);
component.removeFromParent();
await game.ready();
expect(component.isMounted, isFalse);
expect(pool.availableCount, 1); // Fails: 0
});
testWithFlameGame('not available within the same tick', (game) async {
final pool = _createPool();
final component = pool.acquire();
await game.ensureAdd(component);
component.removeFromParent();
game.update(0);
expect(component.isMounted, isFalse);
expect(pool.availableCount, 1); // Fails: 0
});
}
What steps should take to fix this?
This can't be fixed from inside the pool. Listening for removed misses detaches that complete no future at all (such as a cancelled pending add), and futures can never complete in the same tick. A check on whether the component is still attached, followed by listening again, would fix the move case only.
The fix will add a private, synchronous detach notification to Component. It fires when a component is removed, or removed before it could be mounted, and not on a move or for the descendants of a removed component. ComponentPool takes components back through it.
How a component is linked to its pool is still to be decided:
- A
PooledComponentmixin.ComponentPool<T extends PooledComponent>stores the pool reference on the component and providesonAcquire/onReleasehooks to override. This is a breaking change. - An
Expandoin the component library, with nothing else changing for users.ComponentPool<T extends Component>stays as it is, components that aren't pooled use no extra memory, and the reset hooks become optionalonAcquire/onReleasecallbacks on the pool.
Along with the fix:
ComponentPool.release(T)for components that were acquired but never added to the tree.onAcquire, so that reset logic stops depending ononMount.- Asserts against negative sizes, a double release, a release from a different pool, and a component that's still in the pool getting added to the tree.
- Drop the O(n)
_available.containscheck. - Regression tests for all four cases.
Do have an example of where the bug occurs?
The tests above. The ComponentPool example in examples/ doesn't hit these cases, because its balls are only ever added to and removed from the world.
Relevant log output
00:00 +0 -1: moving a component releases it [E]
Expected: <0>
Actual: <1>
00:00 +0 -2: acquire returns a mounted component [E]
Expected: false
Actual: <true>
00:00 +0 -3: removing an ancestor releases a child [E]
Expected: <0>
Actual: <1>
00:00 +0 -4: re-adding that child calls onRemove again [E]
Expected: <1>
Actual: <2>
00:00 +0 -5: removed before mounting is lost [E]
Expected: <1>
Actual: <0>
00:00 +0 -6: not available within the same tick [E]
Expected: <1>
Actual: <0>
00:00 +0 -6: Some tests failed.
Execute in a terminal and put output into the code block below
N/A, not platform specific. Reproduced on main at 31398234e.
Affected platforms
All
Are you interested in working on a PR for this?
- I want to work on this
- 主要言語
- Dart
- スター
- 10.8k
- フォーク
- 1.1k
- 平均マージ
- 1日 21時間
- マージ済み PR(30日)
- 62
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
flame-engine/flame のほかの issue
-
`Viewfinder.visibleGameSize` is not reapplied when the viewport changes size対応中かも @ufrshubham が今日担当しました。 オープンbug
flame-engine/flame#4204 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
`Viewfinder.visibleGameSize` zooms in too far with a `FixedResolutionViewport`対応中かも @ufrshubham が今日担当しました。 オープンbug
flame-engine/flame#4203 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
Camera bounds with `considerViewport: true` are not updated when the viewport is resized対応中かも @ufrshubham が今日担当しました。 オープンbug
flame-engine/flame#4202 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
`CameraComponent.setBounds` with `considerViewport: true` only works for bounds that start at (0, 0), at zoom 1対応中かも @ufrshubham が今日担当しました。 オープンbug
flame-engine/flame#4201 · コメント 1 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
`findPostProcessFromContext` returns `null` for components rendered with `CameraComponent.postProcess`対応中かも @ufrshubham が今日担当しました。 オープンbug
flame-engine/flame#4200 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
flame-engine/flame の issue をすべて見る
似ている issue
-
p: material_ui package platform-android platform-ios platform-linux platform-macos platform-web team-design
難易度 2/5 1〜3時間 初心者へのやさしさ 62/100
flutter/flutter#195878 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
[genkit_google_genai] Gemini tool call with no arguments fails with "requires a non-null input"対応中かも @kenzieschmoll が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 80/100
genkit-ai/genkit-dart#692 ·
メンテナーはふだん 11 日以内に返信
-
難易度 1/5 1時間未満 初心者へのやさしさ 82/100
-
bug
難易度 1/5 1時間未満 初心者へのやさしさ 85/100
lollipopkit/flutter_server_box#1851 · コメント 1 件 · リアクション 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1時間未満 初心者へのやさしさ 88/100
メンテナーはふだん 1 日以内に返信