Skip to content

[web][canvaskit] Images loaded via createImageCodecFromUrl turn black after their ui.Image is disposed (e.g. by ImageCache eviction) — regression in 3.47.0 #191800

Closed

Description

@stanbav

Steps to reproduce

  1. Create a new Flutter project and replace lib/main.dart with the code sample below.
  2. flutter run -d chrome --release

The app loads an image three times, records a ui.Picture from each, then draws those
pictures once — having disposed the ui.Image of two of the three beforehand.

Expected results

All three panels render their image. Disposing a ui.Image is not supposed to invalidate
a ui.Picture that already drew it.

Actual results

The second panel renders solid black.

Panel Image came from ui.Image handle Result
1 ui_web.createImageCodecFromUrl (an <img> element) kept alive renders ✅
2 ui_web.createImageCodecFromUrl (an <img> element) disposed black ❌
3 ui.decodeImageFromPixels (not an <img>) disposed renders ✅

Panel 3 disposes its handle identically and is unaffected, so the problem is specific to
images backed by an <img> element rather than to disposal itself.

This is not limited to code that calls dispose() by hand. Flutter's ImageCache
disposes images on the application's behalf, so any long list of distinct images can hit
this during ordinary scrolling: entries are evicted under the cache's byte budget while
their pictures are still being rasterised. In a production Flutter web app, a list of
~172 photos shows black thumbnails after scrolling down and back up, and suppressing
HTMLImageElement.src = '' at runtime — changing nothing else — eliminates them.

Scope: the framework's own Image.network is not affected, because
webHtmlElementStrategy defaults to WebHtmlElementStrategy.never. Affected callers are
ui_web.createImageCodecFromUrl directly, Image.network with an explicit
webHtmlElementStrategy of prefer/fallback, and package:cached_network_image, whose
default web mode is ImageRenderMethodForWeb.HtmlImage. There is a matching third-party
report on 3.47.1 in a comment on
Baseflow/flutter_cached_network_image#1034
:
"on web they load correctly the first time, but after revisiting they're either black".

Code sample

Needs network access for panels 1 and 2, since createImageCodecFromUrl loads from a URL.
Panel 3 needs neither network nor assets. No packages beyond Flutter.

main.dart
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:ui_web' as ui_web;

import 'package:flutter/material.dart';

// Panels 1 and 2 need a real URL, since createImageCodecFromUrl loads from one.
// Any reachable image works; this one is a public placeholder service.
const String kUrl = 'https://picsum.photos/seed/flutterbug/320/240';

void main() => runApp(const MaterialApp(home: Demo()));

class Demo extends StatefulWidget {
  const Demo({super.key});

  @override
  State<Demo> createState() => _DemoState();
}

class _DemoState extends State<Demo> {
  ui.Image? _kept;
  ui.Picture? _control, _element, _pixels;
  String _status = 'loading…';

  @override
  void initState() {
    super.initState();
    _run();
  }

  /// The `<img>`-element path: what `ui_web.createImageCodecFromUrl` uses, and
  /// what `package:cached_network_image` uses in its default web mode.
  Future<ui.Image> _loadViaImageElement() async {
    final codec = await ui_web.createImageCodecFromUrl(Uri.parse(kUrl));
    return (await codec.getNextFrame()).image;
  }

  /// A control that is not backed by an `<img>` element: an image built from
  /// raw pixels. Needs no network and no assets.
  Future<ui.Image> _imageFromPixels() {
    const int w = 320, h = 240;
    final pixels = Uint8List(w * h * 4);
    for (int y = 0; y < h; y++) {
      for (int x = 0; x < w; x++) {
        final i = (y * w + x) * 4;
        pixels[i] = x * 255 ~/ w;
        pixels[i + 1] = y * 255 ~/ h;
        pixels[i + 2] = 180;
        pixels[i + 3] = 255;
      }
    }
    final completer = Completer<ui.Image>();
    ui.decodeImageFromPixels(pixels, w, h, ui.PixelFormat.rgba8888, completer.complete);
    return completer.future;
  }

  ui.Picture _record(ui.Image image) {
    final recorder = ui.PictureRecorder();
    Canvas(recorder).drawImageRect(
      image,
      Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
      const Rect.fromLTWH(0, 0, 240, 180),
      Paint(),
    );
    return recorder.endRecording();
  }

  Future<void> _run() async {
    // 1. <img> path, handle kept alive.
    final control = await _loadViaImageElement();
    final controlPicture = _record(control);

    // 2. <img> path, handle disposed after the picture was recorded.
    final element = await _loadViaImageElement();
    final elementPicture = _record(element);
    element.dispose();

    // 3. Not an <img>: raw pixels, disposed exactly the same way.
    final pixels = await _imageFromPixels();
    final pixelPicture = _record(pixels);
    pixels.dispose();

    if (!mounted) return;
    setState(() {
      _kept = control;
      _control = controlPicture;
      _element = elementPicture;
      _pixels = pixelPicture;
      _status = 'each panel draws a ui.Picture; panels 2 and 3 disposed their ui.Image first';
    });
  }

  @override
  void dispose() {
    _kept?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: Text(_status)),
    body: Center(
      child: Wrap(
        spacing: 16,
        runSpacing: 16,
        children: [
          _Panel('1. <img> · handle KEPT\n(renders)', _control),
          _Panel('2. <img> · handle DISPOSED\n(BLACK)', _element),
          _Panel('3. pixels · handle DISPOSED\n(renders)', _pixels),
        ],
      ),
    ),
  );
}

class _Panel extends StatelessWidget {
  const _Panel(this.label, this.picture);

  final String label;
  final ui.Picture? picture;

  @override
  Widget build(BuildContext context) => Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Text(label, textAlign: TextAlign.center),
      const SizedBox(height: 8),
      Container(
        width: 240,
        height: 180,
        decoration: BoxDecoration(border: Border.all()),
        child: picture == null ? null : CustomPaint(painter: _PicturePainter(picture!)),
      ),
    ],
  );
}

class _PicturePainter extends CustomPainter {
  _PicturePainter(this.picture);

  final ui.Picture picture;

  @override
  void paint(Canvas canvas, Size size) => canvas.drawPicture(picture);

  @override
  bool shouldRepaint(_PicturePainter oldDelegate) => oldDelegate.picture != picture;
}

Screenshots or Video

three panels: the first renders, the second is solid black, the third renders

Left to right: panel 1 (<img>, handle kept) renders, panel 2 (<img>, handle disposed) is solid black, panel 3 (raw pixels, handle disposed) renders.

Logs

No exception is thrown and nothing is logged; the image simply rasterises black.

Flutter Doctor output

flutter doctor -v
[✓] Flutter (Channel stable, 3.47.1, on macOS 26.5.2 25F84 darwin-arm64, locale en-KR) [108ms]
    • Flutter version 3.47.1 on channel stable at /Users/stan/develop/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 6655482ec0 (7 days ago), 2026-08-19 10:07:23 -0700
    • Engine revision 5d53178869
    • Dart version 3.13.1
    • DevTools version 2.60.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration

[✗] Android toolchain - develop for Android devices [77ms]
    ✗ Unable to locate Android SDK.
      Install Android Studio from: https://developer.android.com/studio/index.html
      On first launch it will assist you in installing the Android SDK components.
      (or visit https://flutter.dev/to/macos-android-setup for detailed instructions).
      If the Android SDK has been installed to a custom location, please use
      `flutter config --android-sdk` to update to that location.


[✓] Xcode - develop for iOS and macOS (Xcode 26.6) [382ms]
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 17F113
    • CocoaPods version 1.16.2

[✓] Chrome - develop for the web [3ms]
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Connected device (2 available) [5.7s]
    • macOS (desktop) • macos  • darwin-arm64   • macOS 26.5.2 25F84 darwin-arm64
    • Chrome (web)    • chrome • web-javascript • Google Chrome 151.0.7922.174

[✓] Network resources [498ms]
    • All expected network resources are available.

! Doctor found issues in 1 category.

Reproduced in release and profile builds on Chrome 151.0.7922.174 / macOS 26.5.2.
Not tested with --wasm, and not tested on Firefox or Safari.


Cause

ImageElementImageSource._doClose() clears the backing element:

// engine/src/flutter/lib/web_ui/lib/src/engine/primitives/image_source.dart
class ImageElementImageSource extends ImageSource {
  @override
  void _doClose() {
    // Clear the src attribute of the image element to release associated memory.
    imageElement.src = '';
  }
}

ImageSource.release() calls this as soon as the reference count of EngineImage
instances reaches zero. The SkImage that CanvasKit creates from an <img> element
appears to be a lazy texture that re-reads the element whenever it is uploaded, and Skia
keeps that SkImage alive independently of Dart's handles — for instance inside a
recorded ui.Picture. A later upload then reads an element whose src is empty.

There are three places that clear src. The two in HtmlImageElementCodec are guarded by
a flag introduced in #186032:

// engine/src/flutter/lib/web_ui/lib/src/engine/html_image_element_codec.dart
/// This is used during [dispose] to determine if it is safe to clear the
/// `src` attribute of [imgElement]. If the image has been handed out, the
/// [ui.Image] might still be using the element for rendering, and clearing
/// the `src` could disrupt the browser's internal image state.
bool _imageHandedOut = false;

ImageElementImageSource has no equivalent guard, and it is constructed exactly at
hand-out time — canvaskit/image.dart, in createCkImageFromImageElement, called from
createImageFromHTMLImageElement immediately before _imageHandedOut = true. So the
unguarded site fires precisely in the case the guard exists to prevent.

Regression range

  • 3.44.9 (engine 5a2a6a42cc) — unaffected; primitives/image_source.dart does not exist.
  • 3.47.0 (engine 5f77625673) — affected.
  • 3.47.1 (engine 5d53178869) — affected.
  • master — the unguarded _doClose() is still present.

The ImageSource layer arrived in #187873, relanded as #188356, and was extended in #188573.

Reproduction repository

The sample above is self-contained and sufficient. For anyone who wants the
ImageCache-driven version — a scrolling list where the same disposal is triggered by
ordinary cache eviction rather than an explicit dispose() — there is a fuller harness at
https://github.com/stanbav/flutter-web-image-black-repro (?demo=picture is the minimal
case above; ?path=app&layout=cards is the list).

Activity

  1. cerealexx commented on Aug 26, 2026

    @cerealexx

    This bug essentially breaks both cached_network_image and cached_network_image_ce for web which are heavily used by the community 😓

  2. added
    c: regressionIt was better in the past than it is now
    platform-webWeb applications specifically
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    team-webOwned by Web platform team
    on Aug 26, 2026
  3. mbcorona commented on Aug 26, 2026

    @mbcorona
    Member

    Hi @stanbav, thanks for the report and code sample.

    Confirmed on master: ImageElementImageSource._doClose() in web_ui still clears imageElement.src = '' unconditionally, with no _imageHandedOut guard like the one HtmlImageElementCodec added in #186032 — so a recorded ui.Picture that still references the -backed SkImage rasterises black once the source is released.

    Routing to the web team.

  4. github-actions commented on Sep 25, 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 Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

assigned for triageissue is assigned to a domain expert for further triagec: regressionIt was better in the past than it is nowfound in release: 3.47Found to occur in 3.47has reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-webWeb applications specificallyteam-webOwned by Web platform team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions