From 821e5c22a590654b701fdbf03f0d8fdbc7d9acf4 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 15 May 2026 12:40:46 +0530 Subject: [PATCH 1/2] [vector_graphics] Add precacheVectorGraphic utility function Adds a top-level precacheVectorGraphic function analogous to precacheImage that warms both the byte-loader cache and the internal _livePictureCache for a given BytesLoader and BuildContext. The context is used to resolve Locale and TextDirection so the computed _PictureKey matches what VectorGraphic will produce at render time, preventing the cache misses that occur when preloading with a null or mismatched context. A reference count of 1 is held in _livePictureCache so the decoded picture survives route transitions where all VectorGraphic widgets temporarily unmount (count would otherwise drop to zero and the picture would be disposed and re-decoded on the next mount). _loadPicture is made static to allow access from the new top-level function while sharing the existing _pendingPictures deduplication map. Fixes flutter/flutter#186481 --- .../lib/src/vector_graphics.dart | 64 +++++++- .../vector_graphics/lib/vector_graphics.dart | 1 + .../vector_graphics/test/caching_test.dart | 152 ++++++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) diff --git a/packages/vector_graphics/lib/src/vector_graphics.dart b/packages/vector_graphics/lib/src/vector_graphics.dart index f909d59f102..4ee7d571b69 100644 --- a/packages/vector_graphics/lib/src/vector_graphics.dart +++ b/packages/vector_graphics/lib/src/vector_graphics.dart @@ -370,8 +370,8 @@ class _VectorGraphicWidgetState extends State { } } - Future<_PictureData> _loadPicture( - BuildContext context, + static Future<_PictureData> _loadPicture( + BuildContext? context, _PictureKey key, BytesLoader loader, ) { @@ -671,6 +671,66 @@ class _RawPictureVectorGraphicWidget extends SingleChildRenderObjectWidget { } } +/// Warms the [VectorGraphic] picture cache for [loader] using [context] to +/// resolve [Locale] and [TextDirection], so that a subsequent [VectorGraphic] +/// widget with the same loader renders synchronously from cache instead of +/// triggering a decode on the first frame. +/// +/// This is analogous to [precacheImage] for raster images. Call it in +/// [State.didChangeDependencies] before navigating to a route that contains a +/// [VectorGraphic]: +/// +/// ```dart +/// @override +/// void didChangeDependencies() { +/// super.didChangeDependencies(); +/// precacheVectorGraphic(AssetBytesLoader('assets/icon.vg'), context); +/// } +/// ``` +/// +/// The [context] is used to derive the [Locale] and [TextDirection] that +/// [VectorGraphic] uses as part of its picture cache key. Providing the same +/// context (or one with identical inherited locale and directionality) avoids +/// a cache miss when the widget is first built. +/// +/// The decoded picture is kept alive in the internal picture cache with a +/// reference count of 1. When a [VectorGraphic] widget mounts with the same +/// key it increments the count; when it disposes the count is decremented. +/// The precache reference ensures the picture survives route transitions where +/// all [VectorGraphic] widgets temporarily unmount (count would otherwise drop +/// to zero and the picture would be disposed). +/// +/// If [onError] is provided it is called with the error and stack trace +/// instead of propagating the exception. +Future precacheVectorGraphic( + BytesLoader loader, + BuildContext context, { + bool clipViewbox = true, + VectorGraphicsErrorListener? onError, +}) async { + final Locale? locale = Localizations.maybeLocaleOf(context); + final TextDirection? textDirection = Directionality.maybeOf(context); + final Object loaderKey = loader.cacheKey(context); + final _PictureKey key = _PictureKey(loaderKey, locale, textDirection, clipViewbox); + + // Already live in the cache; nothing to do. + if (_VectorGraphicWidgetState._livePictureCache.containsKey(key)) { + return; + } + + try { + final _PictureData data = + await _VectorGraphicWidgetState._loadPicture(context, key, loader); + // A widget may have populated the cache while we were awaiting the decode. + if (!_VectorGraphicWidgetState._livePictureCache.containsKey(key)) { + data.count += 1; + _VectorGraphicWidgetState._livePictureCache[key] = data; + } + } catch (error, stackTrace) { + onError?.call(error, stackTrace); + } +} + /// Utility functionality for interaction with vector graphic assets. class VectorGraphicUtilities { const VectorGraphicUtilities._(); diff --git a/packages/vector_graphics/lib/vector_graphics.dart b/packages/vector_graphics/lib/vector_graphics.dart index 6eecdc6d3c1..3c36b46f031 100644 --- a/packages/vector_graphics/lib/vector_graphics.dart +++ b/packages/vector_graphics/lib/vector_graphics.dart @@ -10,4 +10,5 @@ export 'src/vector_graphics.dart' PictureInfo, VectorGraphic, VectorGraphicUtilities, + precacheVectorGraphic, vg; diff --git a/packages/vector_graphics/test/caching_test.dart b/packages/vector_graphics/test/caching_test.dart index 33b0a8af418..fe347e26542 100644 --- a/packages/vector_graphics/test/caching_test.dart +++ b/packages/vector_graphics/test/caching_test.dart @@ -313,6 +313,146 @@ void main() { expect(testBundle.loadKeys, ['bar.svg', 'foo.svg', 'foo.svg']); }); + + testWidgets( + 'precacheVectorGraphic warms live cache so VectorGraphic renders without reloading', + (WidgetTester tester) async { + final testBundle = TestAssetBundle(); + + // Precache with the same context that VectorGraphic will inherit. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: DefaultAssetBundle( + bundle: testBundle, + child: Builder( + builder: (BuildContext context) { + precacheVectorGraphic( + const AssetBytesLoader('foo.svg'), + context, + ); + return const SizedBox(); + }, + ), + ), + ), + ); + await tester.runAsync( + () => Future.delayed(Duration.zero), + ); + + expect(testBundle.loadKeys.single, 'foo.svg'); + + // Now mount a VectorGraphic with the same key -- it should hit the cache. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: DefaultAssetBundle( + bundle: testBundle, + child: VectorGraphic( + loader: const AssetBytesLoader('foo.svg'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The asset was already decoded; no second load should occur. + expect(testBundle.loadKeys.single, 'foo.svg'); + }, + ); + + testWidgets( + 'precacheVectorGraphic keeps picture alive across widget unmount/remount', + (WidgetTester tester) async { + final testBundle = TestAssetBundle(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: DefaultAssetBundle( + bundle: testBundle, + child: Builder( + builder: (BuildContext context) { + precacheVectorGraphic( + const AssetBytesLoader('foo.svg'), + context, + ); + return const SizedBox(); + }, + ), + ), + ), + ); + await tester.runAsync( + () => Future.delayed(Duration.zero), + ); + + expect(testBundle.loadKeys.single, 'foo.svg'); + + // Mount then unmount -- simulates a route transition. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: DefaultAssetBundle( + bundle: testBundle, + child: VectorGraphic( + loader: const AssetBytesLoader('foo.svg'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox()); + + // Remount -- the precache reference should keep the picture alive. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: DefaultAssetBundle( + bundle: testBundle, + child: VectorGraphic( + loader: const AssetBytesLoader('foo.svg'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Only a single load should have happened across all mounts. + expect(testBundle.loadKeys.single, 'foo.svg'); + }, + ); + + testWidgets( + 'precacheVectorGraphic calls onError and does not throw on loader failure', + (WidgetTester tester) async { + Object? caughtError; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (BuildContext context) { + precacheVectorGraphic( + const _FailingBytesLoader(), + context, + onError: (Object error, StackTrace? _) { + caughtError = error; + }, + ); + return const SizedBox(); + }, + ), + ), + ); + await tester.runAsync( + () => Future.delayed(Duration.zero), + ); + + expect(caughtError, isNotNull); + }, + ); } class TestAssetBundle extends Fake implements AssetBundle { @@ -361,3 +501,15 @@ class TestWidgetsLocalizations extends DefaultWidgetsLocalizations { @override TextDirection get textDirection => TextDirection.ltr; } + +class _FailingBytesLoader extends BytesLoader { + const _FailingBytesLoader(); + + @override + Future loadBytes(BuildContext? context) { + return Future.error(Exception('load failed')); + } + + @override + Object cacheKey(BuildContext? context) => '_FailingBytesLoader'; +} From 05203e276212e18bf05920319d42cc1c8933d11a Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 15 May 2026 13:39:47 +0530 Subject: [PATCH 2/2] [vector_graphics] Fix precacheVectorGraphic reference counting and error handling - Hold a reference even when the graphic is already live in the cache, using a `precached` flag on `_PictureData` to avoid double-counting on repeated calls for the same key. - Rethrow exceptions when no `onError` callback is provided, matching the behaviour of `precacheImage`. - Add tests for double-precache safety and no-onError rethrow. --- .../lib/src/vector_graphics.dart | 42 ++++++-- .../vector_graphics/test/caching_test.dart | 98 +++++++++++++++++++ 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/packages/vector_graphics/lib/src/vector_graphics.dart b/packages/vector_graphics/lib/src/vector_graphics.dart index 4ee7d571b69..d585d0a2cae 100644 --- a/packages/vector_graphics/lib/src/vector_graphics.dart +++ b/packages/vector_graphics/lib/src/vector_graphics.dart @@ -296,6 +296,11 @@ class _PictureData { final PictureInfo pictureInfo; _PictureKey key; int count = 0; + + /// True when [precacheVectorGraphic] holds a reference to this entry. + /// Prevents double-counting if [precacheVectorGraphic] is called more than + /// once for the same key. + bool precached = false; } @immutable @@ -698,10 +703,11 @@ class _RawPictureVectorGraphicWidget extends SingleChildRenderObjectWidget { /// key it increments the count; when it disposes the count is decremented. /// The precache reference ensures the picture survives route transitions where /// all [VectorGraphic] widgets temporarily unmount (count would otherwise drop -/// to zero and the picture would be disposed). +/// to zero and the picture would be disposed). Calling this function multiple +/// times for the same key is safe; only one reference is held regardless. /// /// If [onError] is provided it is called with the error and stack trace -/// instead of propagating the exception. +/// instead of propagating the exception; otherwise the error is rethrown. Future precacheVectorGraphic( BytesLoader loader, BuildContext context, { @@ -713,21 +719,43 @@ Future precacheVectorGraphic( final Object loaderKey = loader.cacheKey(context); final _PictureKey key = _PictureKey(loaderKey, locale, textDirection, clipViewbox); - // Already live in the cache; nothing to do. - if (_VectorGraphicWidgetState._livePictureCache.containsKey(key)) { + // If there is already a live entry, hold our own reference so the picture + // survives route transitions (counts would otherwise drop to zero while all + // VectorGraphic widgets are temporarily unmounted). Guard with `precached` + // so repeated calls for the same key don't leak extra references. + final _PictureData? existing = + _VectorGraphicWidgetState._livePictureCache[key]; + if (existing != null) { + if (!existing.precached) { + existing.precached = true; + existing.count += 1; + } return; } try { final _PictureData data = await _VectorGraphicWidgetState._loadPicture(context, key, loader); - // A widget may have populated the cache while we were awaiting the decode. - if (!_VectorGraphicWidgetState._livePictureCache.containsKey(key)) { + // A widget (or a concurrent precache call) may have populated the cache + // while we were awaiting the decode. + final _PictureData? inCache = + _VectorGraphicWidgetState._livePictureCache[key]; + if (inCache != null) { + if (!inCache.precached) { + inCache.precached = true; + inCache.count += 1; + } + } else { data.count += 1; + data.precached = true; _VectorGraphicWidgetState._livePictureCache[key] = data; } } catch (error, stackTrace) { - onError?.call(error, stackTrace); + if (onError != null) { + onError.call(error, stackTrace); + } else { + rethrow; + } } } diff --git a/packages/vector_graphics/test/caching_test.dart b/packages/vector_graphics/test/caching_test.dart index fe347e26542..e858c4fe75c 100644 --- a/packages/vector_graphics/test/caching_test.dart +++ b/packages/vector_graphics/test/caching_test.dart @@ -453,6 +453,104 @@ void main() { expect(caughtError, isNotNull); }, ); + + testWidgets( + 'precacheVectorGraphic called twice for the same key does not double-count', + (WidgetTester tester) async { + final testBundle = TestAssetBundle(); + + late BuildContext capturedContext; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: DefaultAssetBundle( + bundle: testBundle, + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + + // Call precache twice; only one asset load should happen and the picture + // should survive an unmount/remount without a second decode. + await tester.runAsync(() async { + await precacheVectorGraphic( + const AssetBytesLoader('foo.svg'), + capturedContext, + ); + await precacheVectorGraphic( + const AssetBytesLoader('foo.svg'), + capturedContext, + ); + }); + + expect(testBundle.loadKeys.single, 'foo.svg'); + + // Mount then fully unmount. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: DefaultAssetBundle( + bundle: testBundle, + child: VectorGraphic( + loader: const AssetBytesLoader('foo.svg'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox()); + + // Remount — double-precache must not have leaked a phantom reference that + // keeps the count above what _maybeReleasePicture expects, causing the + // picture to be disposed prematurely or kept alive one extra decrement too + // many. A single load across all pumps confirms correct reference balance. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: DefaultAssetBundle( + bundle: testBundle, + child: VectorGraphic( + loader: const AssetBytesLoader('foo.svg'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(testBundle.loadKeys.single, 'foo.svg'); + }, + ); + + testWidgets( + 'precacheVectorGraphic rethrows when no onError is provided', + (WidgetTester tester) async { + late Future precacheFuture; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (BuildContext context) { + precacheFuture = precacheVectorGraphic( + const _FailingBytesLoader(), + context, + ); + return const SizedBox(); + }, + ), + ), + ); + + await tester.runAsync(() async { + await expectLater(precacheFuture, throwsException); + }); + }, + ); } class TestAssetBundle extends Fake implements AssetBundle {