`ComponentPool` returns components to the pool while they are still in use, and loses components removed before mounting
Los mantenedores suelen responder en 1 día
@ufrshubham ya está trabajando en esto.
Desde el 1/11/2026.
Evaluación
Este issue todavía no se ha evaluado.
Descripción
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
- Lenguaje dominante
- Dart
- Estrellas
- 10.8k
- Forks
- 1.1k
- Merge medio
- 1 d 21 h
- PR fusionados (30 d)
- 62
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Tiene una plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de flame-engine/flame
-
`Viewfinder.visibleGameSize` is not reapplied when the viewport changes sizePosiblemente ocupada @ufrshubham la tomó hace 1 día. Abiertobug
flame-engine/flame#4204 · 1 asignado ·
Los mantenedores suelen responder en 1 día
-
`Viewfinder.visibleGameSize` zooms in too far with a `FixedResolutionViewport`Posiblemente ocupada @ufrshubham la tomó hace 1 día. Abiertobug
flame-engine/flame#4203 · 1 asignado ·
Los mantenedores suelen responder en 1 día
-
Camera bounds with `considerViewport: true` are not updated when the viewport is resizedPosiblemente ocupada @ufrshubham la tomó hace 1 día. Abiertobug
flame-engine/flame#4202 · 1 asignado ·
Los mantenedores suelen responder en 1 día
-
`CameraComponent.setBounds` with `considerViewport: true` only works for bounds that start at (0, 0), at zoom 1Posiblemente ocupada @ufrshubham la tomó hace 1 día. Abiertobug
flame-engine/flame#4201 · 1 comentario · 1 asignado ·
Los mantenedores suelen responder en 1 día
-
`findPostProcessFromContext` returns `null` for components rendered with `CameraComponent.postProcess`Posiblemente ocupada @ufrshubham la tomó hace 1 día. Abiertobug
flame-engine/flame#4200 · 1 asignado ·
Los mantenedores suelen responder en 1 día
Todos los issues de flame-engine/flame
Issues similares
-
enhancement
Dificultad 2/5 1-3 horas Aptitud para principiantes 66/100
InlitX/GymMane#311 · 1 reacción ·
Los mantenedores suelen responder en 4 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 62/100
zjs81/meshcore-open#634 ·
Los mantenedores suelen responder en 1 día
-
Flutter: _configureLogging passes a log level to rac_configure_logging, which expects an environmentAbiertobinding:flutter bug p2
Dificultad 2/5 1-3 horas Aptitud para principiantes 62/100
RunanywhereAI/runanywhere-sdks#1246 ·
Los mantenedores suelen responder en 1 día
-
Remove Info buttonAbiertoenhancement user request
Dificultad 2/5 1-3 horas Aptitud para principiantes 65/100
mill-road-winter-fair/mill_road_winter_fair_app#646 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
-
[Bug] 悬浮宠物在 App 外、任务进行中也会变淡并停止动画Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
ccch1mneyyy/dsh-Go#426 ·
Los mantenedores suelen responder en 1 día