`ComponentPool` returns components to the pool while they are still in use, and loses components removed before mounting
I maintainer di solito rispondono entro 1 giorno
@ufrshubham ci sta già lavorando.
Dal 1/11/2026.
Valutazione
Questa issue non è ancora stata valutata.
Descrizione
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
- Lingua principale
- Dart
- Stelle
- 10.8k
- Fork
- 1.1k
- Merge medio
- 1g 21h
- PR unite (30g)
- 62
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di flame-engine/flame
-
`Viewfinder.visibleGameSize` is not reapplied when the viewport changes sizeForse già presa @ufrshubham l’ha presa oggi. Apertabug
flame-engine/flame#4204 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
`Viewfinder.visibleGameSize` zooms in too far with a `FixedResolutionViewport`Forse già presa @ufrshubham l’ha presa oggi. Apertabug
flame-engine/flame#4203 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
Camera bounds with `considerViewport: true` are not updated when the viewport is resizedForse già presa @ufrshubham l’ha presa oggi. Apertabug
flame-engine/flame#4202 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
`CameraComponent.setBounds` with `considerViewport: true` only works for bounds that start at (0, 0), at zoom 1Forse già presa @ufrshubham l’ha presa oggi. Apertabug
flame-engine/flame#4201 · 1 commento · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
`findPostProcessFromContext` returns `null` for components rendered with `CameraComponent.postProcess`Forse già presa @ufrshubham l’ha presa oggi. Apertabug
flame-engine/flame#4200 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di flame-engine/flame
Issue simili
-
p: material_ui package platform-android platform-ios platform-linux platform-macos platform-web team-design
Difficoltà 2/5 1-3 ore Idoneità per principianti 62/100
flutter/flutter#195878 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
[genkit_google_genai] Gemini tool call with no arguments fails with "requires a non-null input"Forse già presa @kenzieschmoll l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 80/100
genkit-ai/genkit-dart#692 ·
I maintainer di solito rispondono entro 11 giorni
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 82/100
-
bug
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 85/100
lollipopkit/flutter_server_box#1851 · 1 commento · 1 reazione ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
I maintainer di solito rispondono entro 1 giorno