From 160db2fcb49f18176e9eba33f326add1fd54abb6 Mon Sep 17 00:00:00 2001 From: andycall Date: Thu, 7 May 2026 01:20:11 -0700 Subject: [PATCH] perf(image): make HTMLImageElement.src setter async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route `img.src = url` through `SetBindingPropertyAsync` instead of the sync `SetBindingProperty` path. The sync path forces a `FlushUICommand` before each write and a sync FFI round-trip; for `src` neither is needed: * The setter is fire-and-forget — JS never reads anything synchronously out of it. * The actual network load is async on the Dart side regardless. * Subsequent JS reads (`img.src`, `getProperty`) call `FlushUICommand` internally before their sync read, so they still see the just-written value — semantics preserved. * MutationObserver and the `attributes_` mirror in `SetBindingProperty` are gated on `WidgetElement` only and never fired for HTMLImageElement today, so dropping the sync path loses no observed behavior. In profiles, 59 sync `setProperty(src)` calls during a route render burst forced 59 `FlushUICommand` drains, each of which entered the styleRecalc cascade that produced ~2,000 nested recalcs per drained insert (`16,500` recalc spans / ~1.69 s self per session). Folding these writes into the next natural flush should drop ~250 ms of JS-thread block plus ~600-800 ms of styleRecalc cascade work in the heavy-render hot zone. Co-Authored-By: Claude Opus 4.7 (1M context) --- bridge/core/html/html_image_element.cc | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/bridge/core/html/html_image_element.cc b/bridge/core/html/html_image_element.cc index 614c68ee8f..981c1170f0 100644 --- a/bridge/core/html/html_image_element.cc +++ b/bridge/core/html/html_image_element.cc @@ -34,8 +34,26 @@ AtomicString HTMLImageElement::src() const { } void HTMLImageElement::setSrc(const AtomicString& value, ExceptionState& exception_state) { - SetBindingProperty(binding_call_methods::ksrc, NativeValueConverter::ToNativeValue(ctx(), value), - exception_state); + // Queue a UI command rather than going through the sync bridge path: + // + // * `src` is fire-and-forget — JS never reads anything synchronously + // out of the setter, the actual network load is async on Dart, and + // any subsequent `img.src` getter / `getProperty` call calls + // `FlushUICommand` internally before its sync read so it still sees + // the value just written. + // * The sync path forced a per-write FlushUICommand, which during + // React commit + image-load swap bursts triggered cascading + // styleRecalc walks (~2k recalcs per insert in profiles). Folding + // these writes into the next natural flush eliminates the + // amplification. + // + // The HTMLImageElement attribute mirror is unchanged: `attributes_` is + // only kept in sync for `WidgetElement`, and the WidgetElement-only + // branch in `BindingObject::SetBindingProperty` is preserved by the + // remaining sync setters that need it. + SetBindingPropertyAsync(binding_call_methods::ksrc, + NativeValueConverter::ToNativeValue(ctx(), value), + exception_state); if (!value.IsEmpty() && !keep_alive) { KeepAlive(); keep_alive = true;