diff --git a/packages/flame/lib/src/components/text_box_component.dart b/packages/flame/lib/src/components/text_box_component.dart index 2e1f09264e2..0450f48df49 100644 --- a/packages/flame/lib/src/components/text_box_component.dart +++ b/packages/flame/lib/src/components/text_box_component.dart @@ -416,15 +416,36 @@ class TextBoxComponent extends TextComponent { // See issue #1618 for details. Future.delayed(const Duration(milliseconds: 100), () { cachedToRemove.remove(cachedImage); - if (isMounted) { - cachedImage.dispose(); - } + _safeDispose(cachedImage); }); } cache = await _fullRenderAsImage(newSize); size = newSize; } + /// Dispose of [image] best-effort, swallowing the two errors that mean + /// "the resource is already gone, there is nothing left to free". + /// + /// In **release** mode the production crash from issue #3724 is a + /// `StateError` ("Bad state: A Dart object attempted to access a native + /// peer, but the native peer has been collected (nullptr)") thrown when + /// `Image.dispose` accesses the native peer after the engine has dropped + /// it under memory pressure. + /// + /// In **debug** mode `Image.dispose` is guarded by `assert(!_disposed)`, + /// so a redundant dispose throws an `AssertionError`. Test environments + /// are debug-mode, hence we also catch that flavour to keep the safety + /// net consistent across modes. + static void _safeDispose(Image image) { + try { + image.dispose(); + } on StateError { + // Release-mode: native peer already collected; nothing left to free. + } on AssertionError { + // Debug-mode: image already disposed by a parallel path. + } + } + @override void update(double dt) { _lifeTime += dt; @@ -448,7 +469,10 @@ class TextBoxComponent extends TextComponent { @mustCallSuper void onRemove() { super.onRemove(); - cache?.dispose(); + final cachedImage = cache; + if (cachedImage != null) { + _safeDispose(cachedImage); + } cache = null; } diff --git a/packages/flame/test/components/text_box_component_test.dart b/packages/flame/test/components/text_box_component_test.dart index f8b710619b5..b4de6881e2b 100644 --- a/packages/flame/test/components/text_box_component_test.dart +++ b/packages/flame/test/components/text_box_component_test.dart @@ -234,6 +234,31 @@ void main() { }, ); + testWithFlameGame( + 'does not throw if the cached image was already disposed ' + '(regression #3724)', + (game) async { + final c = TextBoxComponent(text: 'foo bar'); + + await game.ensureAdd(c); + final imageCache = c.cache; + expect(imageCache, isNotNull); + + // Simulate the native peer being collected before our delayed + // dispose / onRemove runs by disposing the cached image first. + imageCache!.dispose(); + expect(imageCache.debugDisposed, isTrue); + + // Removing the component must not crash even though dispose() on the + // already-disposed image would normally throw a StateError. + expect(() { + game.remove(c); + game.update(0); + }, returnsNormally); + expect(c.cache, null); + }, + ); + testWithFlameGame( 'internal image is redrawn when component is re-added', (game) async {