Skip to content

About

Minimal reproduction: on Flutter web (CanvasKit), an image loaded via ui_web.createImageCodecFromUrl renders black after its ui.Image is disposed — regression in Flutter 3.47

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

Flutter web — images render black after image-cache disposal

A self-contained harness for investigating black image tiles on Flutter web, where images loaded through the engine's <img>-element path repaint as solid black after the image cache disposes their entry.

Status: reproduced. Open ?demo=picture for the minimal case — three panels, one image, one variable, no packages and no scrolling. The middle panel is black.

What this project contains

  • Real photographs. tool/download_images.py fetches 172 JPEGs at 640x480 (~7 MB total, ~1.2 MB each once decoded) from Lorem Picsum into web/images/, which is git-ignored — the photos are not redistributed with this project. tool/generate_images.py produces synthetic PNGs instead for offline use (?ext=png).

  • Four load paths to compare, selectable in the UI or by ?path=:

    • <img> element — HtmlElementImage in lib/main.dart, a ~25 line ImageProvider calling ui_web.createImageCodecFromUrl with no packages involved. This is the same engine entry point package:cached_network_image uses on web in its default ImageRenderMethodForWeb.HtmlImage mode.
    • bytes — plain NetworkImage (fetch + decode), the control case.
    • cached_network_image — the package with its own wrappers (octo_image, ResizeImage, fade transition) in play.
    • app widget — a production-style CachedNetworkImage wrapper (lib/app_network_image.dart): query-stripped cache key, single-axis decode bounds, its own cache manager and an eviction-on-failure path.

    The first two paths pull in no packages at all; the last two use cached_network_image, the project's only third-party dependency.

  • A list layout. ?layout=cards renders cards shaped like the lists where this shows up in practice — a short fixed-height thumbnail above a caption strip — over generated placeholder records (lib/card_list.dart).

  • Instrumentation. A live readout of ImageCache size / live / pending / decoded bytes, plus a Resource Timing breakdown showing whether each image was fetched with initiator img (engine <img> path) or fetch (byte path).

Running it

python3 tool/download_images.py   # once; ~7 MB of photos, git-ignored
flutter run -d chrome --release

Every control is also settable from the query string, so a run is reproducible by URL:

Parameter Meaning
auto=1 run the scroll sequence on load
loops=N repeat that sequence N times (default 1)
max=N imageCache.maximumSize (default 40)
path=bytes | package | app load path (default: bare <img>)
layout=cards render the card layout instead of a plain grid
share=1 two adjacent tiles share one image source
churn=1 append a changing query parameter, imitating re-signed URLs
ext=png use the synthetic offline images

The closest approximation of the affected screen attempted so far:

?path=app&layout=cards&auto=1&loops=3&max=40&churn=1

The hypothesis under test

On CanvasKit, createImageCodecFromUrl builds the image from a detached <img> element and hands Skia a lazy texture that re-reads that element when it uploads. ImageElementImageSource._doClose() in the engine (lib/web_ui/lib/src/engine/primitives/image_source.dart) clears that element:

void _doClose() {
  // Clear the src attribute of the image element to release associated memory.
  imageElement.src = '';
}

If that runs while a live image still paints from the element, the next upload produces an empty texture and the tile draws opaque black. The byte path instead produces a VideoFrameImageSource, whose _doClose() is deliberately a no-op ("Skia will close the VideoFrame when the SkImage is disposed"), so it cannot be destroyed underneath a live image.

Relevant engine history: an earlier change (flutter/flutter#186032, 2026-05-07) added this src-clearing on disposal but guarded it with an _imageHandedOut flag, whose own doc comment warns that clearing src "could disrupt the browser's internal image state" once a ui.Image has been handed out. The ImageSource layer introduced by flutter/flutter#187873 → #188356 (reland, 2026-06-23) and #188573 (2026-08-03) clears it with no such guard.

Reproduction status

Reproduced, minimally and deterministically, at ?demo=picture (lib/picture_demo.dart, ~200 lines, no packages, no list, no scrolling).

Each panel draws a ui.Picture that was recorded from a ui.Image. A ui.Picture remains valid after its ui.Image is disposed — Skia retains the SkImage the picture references — so all three should render identically:

Panel Load path ui.Image handle Result
1 ui_web.createImageCodecFromUrl (<img> element) kept alive renders
2 ui_web.createImageCodecFromUrl (<img> element) disposed solid black
3 decoded from bytes disposed renders

Panel 3 is the control that makes this a bug rather than misuse: the same disposal on a byte-decoded image is harmless, so ui.Picture genuinely does outlive ui.Image disposal. Only the <img>-backed path breaks, because the engine ties the element's lifetime to the Dart handles (ImageElementImageSource._doClose() sets imageElement.src = '' when the last EngineImage is released) while Skia's lazy texture goes on re-reading that element on every upload.

In a real application the disposal is triggered by ordinary ImageCache eviction rather than an explicit dispose(), which is why a long scrolling list of distinct photos shows it: entries are evicted under the cache's byte budget while their pictures are still being rasterised.

Reproducing the list-level symptom

The scrolling harness (?path=app&layout=cards) reaches the same engine state — it drives 222-267 src clearings with 172 images at 640x480 against Flutter's default ImageCache budget (1000 objects / 100 MB) — but its tiles reload rather than repaint the stale texture, so the black does not become visible there. Matching the cache arithmetic is what makes the engine dispose at all; every earlier configuration, which forced small count-based limits with very large images, recorded exactly 0 clearings.

Verified against the real failure

The same mechanism was confirmed in a real deployed app on this engine, by neutralising only HTMLImageElement.src = '' in the live page: 33 clearings and many black tiles became 49 blocked clearings and zero black tiles, with nothing else changed.

Files

Path Purpose
lib/main.dart the whole app: providers, grid, controls, instrumentation
tool/download_images.py downloads the real multi-megapixel photos
lib/app_network_image.dart a production-style CachedNetworkImage wrapper
lib/card_list.dart card layout + generated placeholder records
tool/generate_images.py synthetic offline fallback images (?ext=png)
lib/picture_demo.dart the minimal reproduction (?demo=picture)
web/images/ image files (not checked in)

About

Minimal reproduction: on Flutter web (CanvasKit), an image loaded via ui_web.createImageCodecFromUrl renders black after its ui.Image is disposed — regression in Flutter 3.47

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages