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=picturefor the minimal case — three panels, one image, one variable, no packages and no scrolling. The middle panel is black.
-
Real photographs.
tool/download_images.pyfetches 172 JPEGs at 640x480 (~7 MB total, ~1.2 MB each once decoded) from Lorem Picsum intoweb/images/, which is git-ignored — the photos are not redistributed with this project.tool/generate_images.pyproduces synthetic PNGs instead for offline use (?ext=png). -
Four load paths to compare, selectable in the UI or by
?path=:<img> element—HtmlElementImageinlib/main.dart, a ~25 lineImageProvidercallingui_web.createImageCodecFromUrlwith no packages involved. This is the same engine entry pointpackage:cached_network_imageuses on web in its defaultImageRenderMethodForWeb.HtmlImagemode.bytes— plainNetworkImage(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-styleCachedNetworkImagewrapper (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=cardsrenders 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
ImageCachesize / live / pending / decoded bytes, plus a Resource Timing breakdown showing whether each image was fetched with initiatorimg(engine<img>path) orfetch(byte path).
python3 tool/download_images.py # once; ~7 MB of photos, git-ignored
flutter run -d chrome --releaseEvery 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
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.
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.
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.
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.
| 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) |