Skip to content

[vector_graphics] ui.Picture is not disposed when decoding completes after VectorGraphic is unmounted #190185

Closed
flutter/packages
#13435

Description

@jiechic

What package does this bug report belong to?

vector_graphics

What target platforms are you seeing this bug on?

macOS (the issue is in platform-independent Dart lifecycle code and may affect other platforms as well)

Have you already upgraded your packages?

Yes

Dependency versions

vector_graphics: 1.2.2
vector_graphics_codec: 1.1.13

The same problematic control flow is still present on the current flutter/packages main branch.

Steps to reproduce

  1. Create a Flutter project and add vector_graphics: 1.2.2 and
    vector_graphics_codec: 1.1.13.
  2. Add the widget test below as test/vector_graphics_leak_test.dart.
  3. Run flutter test test/vector_graphics_leak_test.dart.
  4. Observe that a _NativePicture is created but no matching dispose event is
    emitted after the VectorGraphic is removed before decoding completes.

Expected results

When asynchronous decoding finishes after the VectorGraphic has been removed,
the decoded PictureInfo.picture should be disposed immediately. Every native
picture created by this test should have a matching disposal event.

Actual results

_VectorGraphicWidgetState._loadAssetBytes increments data.count, detects that
the widget is no longer mounted, and calls _maybeReleasePicture(data).

However, _maybeReleasePicture only disposes the picture when
_livePictureCache.containsKey(data.key) is true. At this point the newly decoded
picture has never been inserted into _livePictureCache; insertion happens after
the mounted check. The count returns to zero, but the native picture is not
disposed. Leak tracking reports a non-disposed _NativePicture, with creation
through FlutterVectorGraphicsListener.toPicture and
_VectorGraphicWidgetState._loadPicture.

Relevant current implementation:

data.count += 1;
if (!mounted || loader != widget.loader) {
  _maybeReleasePicture(data);
  return;
}

if (data.count == 1) {
  _livePictureCache[key] = data;
}
void _maybeReleasePicture(_PictureData? data) {
  if (data == null) {
    return;
  }
  data.count -= 1;
  if (data.count == 0 && _livePictureCache.containsKey(data.key)) {
    _livePictureCache.remove(data.key);
    data.pictureInfo.picture.dispose();
  }
}

Code sample

import 'dart:async';
import 'dart:typed_data';

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:vector_graphics/vector_graphics.dart';
import 'package:vector_graphics_codec/vector_graphics_codec.dart';

const VectorGraphicsCodec codec = VectorGraphicsCodec();

void main() {
  testWidgets('disposes a picture decoded after unmount', (
    WidgetTester tester,
  ) async {
    final loader = DelayedBytesLoader();
    final createdPictures = <Object>{};
    final disposedPictures = <Object>{};

    void trackPictures(ObjectEvent event) {
      if (event.object.runtimeType.toString() != '_NativePicture') {
        return;
      }
      if (event is ObjectCreated) {
        createdPictures.add(event.object);
      } else if (event is ObjectDisposed) {
        disposedPictures.add(event.object);
      }
    }

    FlutterMemoryAllocations.instance.addListener(trackPictures);
    addTearDown(
      () => FlutterMemoryAllocations.instance.removeListener(trackPictures),
    );

    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: VectorGraphic(loader: loader),
      ),
    );

    // Unmount before asynchronous decoding finishes.
    await tester.pumpWidget(const SizedBox.shrink());

    final buffer = VectorGraphicsBuffer();
    codec.writeSize(buffer, 10, 10);
    loader.complete(buffer.done());
    await tester.pumpAndSettle();

    expect(createdPictures, hasLength(1));
    expect(disposedPictures, containsAll(createdPictures)); // Fails on 1.2.2.
  });
}

class DelayedBytesLoader extends BytesLoader {
  final Completer<ByteData> _completer = Completer<ByteData>();

  void complete(ByteData data) => _completer.complete(data);

  @override
  Future<ByteData> loadBytes(BuildContext? context) => _completer.future;
}

Suggested fix

Dispose any picture whose reference count reaches zero, regardless of whether it
was inserted into the live cache. Only cache removal needs to be conditional:

void _maybeReleasePicture(_PictureData? data) {
  if (data == null) {
    return;
  }
  data.count -= 1;
  if (data.count != 0) {
    return;
  }
  if (identical(_livePictureCache[data.key], data)) {
    _livePictureCache.remove(data.key);
  }
  data.pictureInfo.picture.dispose();
}

Using identical avoids removing a newer cache entry that happens to use the same
key. The widget test above passes with this change.

Logs

leak_tracker: notDisposed: _NativePicture
  FlutterMemoryAllocations._pictureOnCreate
  _NativePictureRecorder.endRecording
  FlutterVectorGraphicsListener.toPicture
  decodeVectorGraphics
  _VectorGraphicWidgetState._loadPicture
  _VectorGraphicWidgetState._loadAssetBytes

Flutter Doctor output

The issue was reproduced with:

Flutter 3.41.9, stable, framework revision 00b0c91f06
Dart 3.11.5
DevTools 2.54.2
macOS 26.5.2, darwin-arm64

I can provide the complete flutter doctor -v output if required.

Activity

  1. suryaKommana2662 commented on Jul 29, 2026

    @suryaKommana2662
    Contributor

    @jiechic Thanks for the report. I was able to reproduce this issue locally using the provided test sample code. The test fails with the following assertion:

    Expected: contains all of Set:[_NativePicture:Picture]
    Actual: Set:[_NativePicture:Picture]
    Which: has no match for _NativePicture:<Picture>

    This indicates that a _NativePicture was created, but the corresponding picture was not disposed after the VectorGraphic was unmounted before asynchronous decoding completed, matching the behavior described in the report.

    Tested with the provided reproduction test and vector_graphics: 1.2.2 / vector_graphics_codec: 1.1.13.

    flutter Doctor Output

    [√] Flutter (Channel stable, 3.44.8, on Microsoft Windows [Version 10.0.26200.8875], locale en-IN) [508ms]
        • Flutter version 3.44.8 on channel stable at C:\flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision 058e0af2c2 (6 days ago), 2026-07-23 10:56:21 -0700
        • Engine revision 0cd610717b
        • Dart version 3.12.2
        • DevTools version 2.57.0
        • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios,
          cli-animations, enable-native-assets, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging,
          enable-uiscene-migration
    
    [√] Windows Version (Windows 11 or higher, 25H2, 2009) [1,281ms]
    
    [√] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [3.9s]
        • Android SDK at C:\Android\Sdk
        • Emulator version 36.5.11.0 (build_id 15261927) (CL:N/A)
        • Platform android-36.1, build-tools 36.1.0
        • Java binary at: C:\Program Files\Android\Android Studio\jbr\bin\java
          This is the JDK bundled with the latest Android Studio installation on this machine.
          To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
        • Java version OpenJDK Runtime Environment (build 21.0.10+-14961533-b1163.108)
        • All Android licenses accepted.
    
    [√] Chrome - develop for the web [155ms]
        • Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
    
    [√] Visual Studio - develop Windows apps (Visual Studio Community 2026 18.6.1) [153ms]
        • Visual Studio at C:\Program Files\Microsoft Visual Studio\18\Community
        • Visual Studio Community 2026 version 18.6.11819.183
        • Windows 10 SDK version 10.0.26100.0
    
    [√] Connected device (3 available) [357ms]
        • Windows (desktop) • windows • windows-x64    • Microsoft Windows [Version 10.0.26200.8875]
        • Chrome (web)      • chrome  • web-javascript • Google Chrome 150.0.7871.187
        • Edge (web)        • edge    • web-javascript • Microsoft Edge 150.0.4078.83
    
    [√] Network resources [592ms]
        • All expected network resources are available.
    
    • No issues found!
  2. added
    engineflutter/engine related. See also e: labels.
    packageflutter/packages repository. See also p: labels.
    and removed
    in triagePresently being triaged by the triage team
    on Jul 29, 2026
  3. mackings commented on Oct 26, 2026

    @mackings

    Hi, I'd like to work on this. I could reproduce it with your test on main, thanks for the clear report!

    One thing I noticed: the suggested fix breaks when two widgets share the same load and only one of them is removed. The other one then tries to draw a picture that's already been disposed. I have a fix that handles both cases and will open a PR soon.

  4. github-actions commented on Nov 12, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  5. locked as resolved and limited conversation to collaborators on Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    engineflutter/engine related. See also e: labels.p: vector_graphicsThe vector_graphics package setpackageflutter/packages repository. See also p: labels.team-engineOwned by Engine team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions