From a99955889195f87860fefda4b412c965955b1041 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Tue, 29 Sep 2026 12:06:27 -0700 Subject: [PATCH 1/2] macOS: a density change during a resize no longer freezes the view Switching the device frame in FlutterFlow's Test Mode changes the preview's size and render scale at once. On a page slow to relayout the view could stop painting for good: JS still answered, hide/show and reload didn't help. CEF's OSR view keeps one resize in flight and queues a change made meanwhile. A queued density change is applied through UpdateScreenInfo, which sends the renderer the new scale under the surface id it already has. viz rejects the renderer's next frame (surface invariants violation) and drops its frame sink. On macOS the view then never draws again: CEF 144 never embeds the surface id the renderer allocates for its new sink (DidUpdateVisualProperties is compiled out for Mac), or the external begin-frame source waits forever on the dropped sink. cef_host now hands CEF geometry through cef_width/cef_height/cef_dpr and changes them only when no resize is in flight (a paint at the new pixel size ends one). A density change goes through NotifyScreenInfoChanged, which retires the surface id first. A resize with no paint at its size gets the renderer a fresh surface id, 1, 2, then 4 s apart. Probe: device_frame (slow page, busy UI thread, plain/blip/anim switches). Before: froze at round 2 in 3 of 3 runs on the shipping framework. After: 30/30 in 3 of 3 runs, no invariants violation in a 40 s trace. Co-Authored-By: Claude Opus 5.5 (1M context) --- example/lib/device_frame_probe.dart | 355 ++++++++++++++++++ packages/flutter_cef_macos/CHANGELOG.md | 10 + .../native/cef_host/browser_ops.h | 7 + .../native/cef_host/browser_ops.mm | 174 +++++++-- .../native/cef_host/host_state.h | 24 +- .../native/cef_host/render_handler.mm | 20 +- tool/run_probes.sh | 1 + 7 files changed, 536 insertions(+), 55 deletions(-) create mode 100644 example/lib/device_frame_probe.dart diff --git a/example/lib/device_frame_probe.dart b/example/lib/device_frame_probe.dart new file mode 100644 index 0000000..cace074 --- /dev/null +++ b/example/lib/device_frame_probe.dart @@ -0,0 +1,355 @@ +// Switching a view's device frame keeps it painting — END-TO-END probe (macOS). +// +// FlutterFlow's Test Mode lays its preview out at the emulated device's logical +// size inside a FittedBox and renders it at devicePixelRatio × zoom, where zoom +// fits the device into the panel. Switching the device frame therefore changes +// the view's size AND its render scale at once. When the slot that hosts the +// preview remounts, the preview is first parked for a frame (hidden, at a +// fallback size and zoom 1) and then shown at the new device. +// +// Each round switches to the next device one of three ways: +// plain size + render scale change in one frame +// blip one parked frame (hidden, 393×852, zoom 1), then the new device shown +// anim size + render scale lerp over ~15 frames +// and checks that, within LIMIT_MS, the page reports the new viewport and +// density, and that it still presents a frame after a DOM change. +// +// The regression: a scale change that reached CEF while it was still waiting +// to paint an earlier resize was applied later under the renderer's old surface +// id. viz rejected the renderer's next frame and dropped its frame sink, and the +// view never painted again: no crash, no stall report, JS still answering, and +// neither hide/show nor reload brought it back. A page slow to relayout (like a +// debug web build) makes that wait long, so by default the page blocks its main +// thread for PAGE_SLOW_MS on every resize and the app's UI thread is kept busy +// for JANK_MS per frame around each switch. With FIXED_DPR=true switches change +// only the size, which never wedged. +// +// Run: FLUTTER_CEF_HOST=<.../cef_host.app/Contents/MacOS/cef_host> \ +// flutter run -d macos -t lib/device_frame_probe.dart +// [--dart-define=ROUNDS=30 --dart-define=LIMIT_MS=4000 +// --dart-define=PAGE_SLOW_MS=600 --dart-define=JANK_MS=40 +// --dart-define=FIXED_DPR=true --dart-define=PAGE_URL=] +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_cef/flutter_cef.dart'; + +const _rounds = int.fromEnvironment('ROUNDS', defaultValue: 30); +const _limitMs = int.fromEnvironment('LIMIT_MS', defaultValue: 4000); +const _slowMs = String.fromEnvironment('PAGE_SLOW_MS', defaultValue: '600'); +const _jankMs = int.fromEnvironment('JANK_MS', defaultValue: 40); +const _fixedDpr = bool.fromEnvironment('FIXED_DPR'); +// A page to preview instead of the built-in one, e.g. a served Flutter web app. +// It has to repaint on its own (a ticking clock) for the liveness check. +const _pageUrl = String.fromEnvironment('PAGE_URL'); + +// Like a Flutter web app: a WebGL canvas that redraws only on resize or on +// request, so it is static between switches. +const _page = ''' + +
0
+'''; + +class _Device { + const _Device(this.name, this.w, this.h); + final String name; + final double w, h; +} + +const _devices = [ + _Device('iPhone 16', 393, 852), + _Device('iPad Air', 820, 1180), + _Device('Desktop', 1440, 1024), + _Device('Pixel 8', 412, 915), + _Device('iPhone landscape', 852, 393), + _Device('Custom', 600, 700), +]; + +// The fallback size a parked FlutterFlow preview is laid out at. +const _parked = _Device('parked', 393, 852); + +void main() => runApp(const MaterialApp(home: ProbeApp())); + +class ProbeApp extends StatefulWidget { + const ProbeApp({super.key}); + @override + State createState() => _ProbeAppState(); +} + +class _ProbeAppState extends State { + final List _lines = []; + bool _pass = true; + final _controller = CefWebController(); + + // What the view is laid out at right now. + _Device _device = _devices[0]; + // Override [_device] while an animated switch runs. + Size? _animSize; + double? _animZoom; + bool _parkedNow = false; + Size _panel = const Size(800, 600); + double _screenDpr = 1.0; + + void _check(String name, bool cond, [Object? got]) { + if (!cond) _pass = false; + _log('${cond ? "PASS" : "FAIL"} $name${cond ? "" : " (got: $got)"}'); + } + + void _log(String s) { + _lines.add(s); + // ignore: avoid_print + print('CEF_PROBE_LOG $s'); + if (mounted) setState(() {}); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _run()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + double _fitZoom(_Device d) => + 0.9 * [_panel.width / d.w, _panel.height / d.h].reduce((a, b) => a < b ? a : b); + + double _renderScale(double zoom) => _screenDpr * (_fixedDpr ? 1.0 : zoom); + + Future _frame() => SchedulerBinding.instance.endOfFrame; + + Future _eval(String code) async { + try { + return '${await _controller.runJavaScriptReturningResult(code).timeout(const Duration(seconds: 3))}'; + } catch (e) { + return 'eval failed: $e'; + } + } + + Future _presents() async => (await _controller.sessionStats())?.presentCount ?? -1; + + /// Milliseconds until [cond] holds, or null past [limitMs]. + Future _until(Future Function() cond, int limitMs) async { + final sw = Stopwatch()..start(); + while (sw.elapsedMilliseconds < limitMs) { + if (await cond()) return sw.elapsedMilliseconds; + await Future.delayed(const Duration(milliseconds: 20)); + } + return null; + } + + Future _viewport() => + _eval('innerWidth + "x" + innerHeight + "@" + devicePixelRatio.toFixed(3)'); + + /// Whether the page presents a frame within 2 s of a DOM change. + Future _painting(String mark) async { + final before = await _presents(); + await _eval('(document.getElementById("n").textContent = "$mark", window.__redraw(), "ok")'); + return await _until(() async => await _presents() > before, 2000) != null; + } + + /// Keeps the UI thread busy for [_jankMs] after every frame until [until]. + void _jankUntil(DateTime until) { + if (_jankMs <= 0 || DateTime.now().isAfter(until)) return; + SchedulerBinding.instance.addPostFrameCallback((_) { + final sw = Stopwatch()..start(); + while (sw.elapsedMilliseconds < _jankMs) {} + _jankUntil(until); + }); + SchedulerBinding.instance.scheduleFrame(); + } + + Future _switchTo(_Device next, String how) async { + _jankUntil(DateTime.now().add(const Duration(milliseconds: 1500))); + switch (how) { + case 'plain': + setState(() => _device = next); + await _frame(); + case 'blip': + // As SandboxAppPreviewHost does when its slot remounts: parked (hidden, + // fallback size, zoom 1) for a frame, then the new device shown. + setState(() => _parkedNow = true); + await _controller.setVisible(false); + await _frame(); + await _frame(); + setState(() { + _parkedNow = false; + _device = next; + }); + await _controller.setVisible(true); + await _frame(); + case 'anim': + final from = Size(_device.w, _device.h); + final fromZoom = _fitZoom(_device); + const steps = 15; + for (var i = 1; i <= steps; i++) { + final t = Curves.easeInOut.transform(i / steps); + setState(() { + _animSize = Size.lerp(from, Size(next.w, next.h), t); + _animZoom = lerpDouble(fromZoom, _fitZoom(next), t); + }); + await _frame(); + } + setState(() { + _animSize = null; + _animZoom = null; + _device = next; + }); + await _frame(); + } + } + + Future _run() async { + final host = Platform.environment['FLUTTER_CEF_HOST']; + _check('FLUTTER_CEF_HOST is set', host != null && host.isNotEmpty, host); + final painted = await _until(() async => await _presents() > 0, 20000); + if (painted == null) { + _check('the view paints', false, '${await _controller.sessionStats()}'); + return _finish(); + } + await _until( + () async => + await _eval(_pageUrl.isEmpty + ? 'typeof window.__redraw' + : 'String(!!document.querySelector("flutter-view, flt-glass-pane"))') == + (_pageUrl.isEmpty ? 'function' : 'true'), + 15000); + _log('first frame after ${painted}ms; screen dpr $_screenDpr; ' + 'page slow ${_slowMs}ms, jank ${_jankMs}ms${_fixedDpr ? ", fixed dpr" : ""}'); + const hows = ['plain', 'blip', 'anim']; + var failed = 0; + final times = []; + for (var i = 1; i <= _rounds; i++) { + final how = hows[(i - 1) % hows.length]; + final next = _devices[i % _devices.length]; + final dpr = _renderScale(_fitZoom(next)).clamp(0.5, 8.0); + final want = '${next.w.round()}x${next.h.round()}@${dpr.toStringAsFixed(3)}'; + final label = 'round $i $how → ${next.name} $want'; + await _switchTo(next, how); + // The page sees the new viewport and density... + final sized = await _until(() async { + final p = (await _eval('[innerWidth, innerHeight, devicePixelRatio].join(",")')) + .split(',') + .map(double.tryParse) + .toList(); + return p.length == 3 && + p.every((v) => v != null) && + (p[0]! - next.w).abs() <= 1.5 && + (p[1]! - next.h).abs() <= 1.5 && + (p[2]! - dpr).abs() < 0.01; + }, _limitMs); + // ...and still paints. + final live = await _painting('$i'); + if (sized != null && live) { + times.add(sized); + _log('$label: ${sized}ms'); + continue; + } + failed++; + _log('$label: STUCK — viewport ${sized == null ? "stale" : "ok"}, ' + 'presents ${live ? "ok" : "stopped"}; page ${await _viewport()}, ' + '${await _controller.sessionStats()}'); + // A view that stopped painting stays stopped; the rounds after it prove nothing. + if (!live) break; + } + times.sort(); + final median = times.isEmpty ? 0 : times[times.length ~/ 2]; + final max = times.isEmpty ? 0 : times.last; + _log('$failed of $_rounds switches stuck; others: median ${median}ms, max ${max}ms'); + _check('every device switch repaints at the new size within ${_limitMs}ms', failed == 0, + '$failed of $_rounds'); + return _finish(); + } + + Future _finish() async { + // ignore: avoid_print + print('CEF_PROBE_RESULT ${_pass ? "PASS" : "FAIL"}'); + await Future.delayed(const Duration(milliseconds: 300)); + exit(_pass ? 0 : 1); + } + + @override + Widget build(BuildContext context) { + _screenDpr = MediaQuery.devicePixelRatioOf(context); + final d = _parkedNow ? _parked : _device; + final size = _animSize ?? Size(d.w, d.h); + final zoom = _parkedNow ? 1.0 : (_animZoom ?? _fitZoom(d)); + return Scaffold( + body: Row( + children: [ + Expanded( + flex: 3, + child: LayoutBuilder(builder: (context, c) { + _panel = Size(c.maxWidth, c.maxHeight); + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: _parkedNow ? -10000 : 20, + top: _parkedNow ? -10000 : 20, + width: size.width * zoom, + height: size.height * zoom, + child: FittedBox( + fit: BoxFit.fill, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(size: size), + child: SizedBox( + width: size.width, + height: size.height, + child: CefWebView( + url: _pageUrl.isEmpty ? 'about:blank' : _pageUrl, + html: _pageUrl.isEmpty ? _page : null, + controller: _controller, + renderScale: _renderScale(zoom), + enableZoomShortcuts: false, + ), + ), + ), + ), + ), + ], + ); + }), + ), + Expanded( + flex: 2, + child: ListView( + children: [for (final l in _lines) Text(l, style: const TextStyle(fontSize: 10))], + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_cef_macos/CHANGELOG.md b/packages/flutter_cef_macos/CHANGELOG.md index a6708ba..ab75443 100644 --- a/packages/flutter_cef_macos/CHANGELOG.md +++ b/packages/flutter_cef_macos/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +* Fix: a view could stop painting for good after a resize that changed the + render scale, typically when switching the device frame in a preview of a page + slow to relayout. Hide/show and reload didn't bring it back. `cef_host` now + hands CEF one resize at a time: a new size or scale reaches CEF only after it + has painted the previous one, and a resize with no paint at its size gets the + renderer a fresh surface (1, 2, then 4 s apart). Probe: `device_frame`. The + prebuilt must be republished. + ## 0.3.0 * Removed the unused `native/cef_host/entitlements.browser.plist`, which diff --git a/packages/flutter_cef_macos/native/cef_host/browser_ops.h b/packages/flutter_cef_macos/native/cef_host/browser_ops.h index c7b6d25..ec493f7 100644 --- a/packages/flutter_cef_macos/native/cef_host/browser_ops.h +++ b/packages/flutter_cef_macos/native/cef_host/browser_ops.h @@ -60,4 +60,11 @@ void DoKey(const std::shared_ptr& slot, int type, uint32_t modifiers, uint32_t character); void DoInvalidate(const std::shared_ptr& slot); +// Resizes, one at a time (see ApplyGeometry): hand CEF the latest geometry, +// note each view paint, and check for a stall from the begin-frame pump. All on +// the CEF UI thread. +void ApplyGeometry(const std::shared_ptr& slot); +void NoteViewPaint(const std::shared_ptr& slot, int pixel_w, int pixel_h); +void CheckResizeStall(const std::shared_ptr& slot); + } // namespace cef_host diff --git a/packages/flutter_cef_macos/native/cef_host/browser_ops.mm b/packages/flutter_cef_macos/native/cef_host/browser_ops.mm index 29cdb75..36f235f 100644 --- a/packages/flutter_cef_macos/native/cef_host/browser_ops.mm +++ b/packages/flutter_cef_macos/native/cef_host/browser_ops.mm @@ -2,6 +2,8 @@ #import +#include +#include #include #include #include @@ -10,9 +12,12 @@ #include "authored_content.h" #include "host_client.h" +#include "include/base/cef_callback.h" #include "include/cef_cookie.h" #include "include/cef_devtools_message_observer.h" #include "include/cef_request_context.h" +#include "include/cef_task.h" +#include "include/wrapper/cef_closure_task.h" #include "include/wrapper/cef_helpers.h" #include "ipc.h" #include "mac_key_bindings.h" @@ -74,9 +79,9 @@ void DoCreateBrowser(uint32_t wire_id, int w, int h, double dpr, } auto slot = std::make_shared(); slot->browser_id = wire_id; - slot->width = w < 1 ? 1 : w; - slot->height = h < 1 ? 1 : h; - slot->dpr = dpr; + slot->width = slot->cef_width = w < 1 ? 1 : w; + slot->height = slot->cef_height = h < 1 ? 1 : h; + slot->dpr = slot->cef_dpr = dpr; { auto early = g_early_channels.find(wire_id); if (early != g_early_channels.end()) { @@ -234,41 +239,22 @@ void DoResize(const std::shared_ptr& slot, int w, int h, double dpr) { // EnsureSurfaceForPaint (in the composite path) reallocates slot->surface to match + the next // present hands the consumer the new id. So there is no IOSurfaceLookup/CFRelease-swap here // (that was the consumer-allocates handoff that could crop when src≠dst). dpr<=0 = unchanged. - bool dpr_changed = false; { std::lock_guard lock(slot->surface_mutex); slot->width = w; slot->height = h; - if (dpr > 0.0 && dpr != slot->dpr) { - slot->dpr = dpr; - dpr_changed = true; - } + if (dpr > 0.0) slot->dpr = dpr; // dst_mtl is rebuilt by EnsureSurfaceForPaint on the realloc; nil it here too so a same-size // relayout that doesn't realloc still drops a wrap that could be mid-rebuild (belt + suspenders). [slot->dst_mtl release]; slot->dst_mtl = nil; slot->dst_mtl_sid = 0; } - if (slot->browser) { - if (slot->visible) { - // A device-scale change needs the renderer told (screen info), not just a relayout. - if (dpr_changed) slot->browser->GetHost()->NotifyScreenInfoChanged(); - slot->browser->GetHost()->WasResized(); - // Drive a frame right now at the new size. With external begin-frame this is a guaranteed - // tick (not a coalesce-able Invalidate request), so the re-laid-out content composites into - // the new surface immediately; PumpBeginFrame's ongoing ticks cover the heavy-page settle. - slot->browser->GetHost()->SendExternalBeginFrame(); - } else { - // HIDDEN — the begin-frame pump is gated off (PumpBeginFrame skips while - // !visible), so WasResized()+SendExternalBeginFrame() here would never paint the - // freshly-swapped (blank) surface, yet the Swift resizeWatchdog would force-promote - // it to the live texture → permanent blank on a static page. The surface + dims are - // already swapped above (geometry is current); defer the screen-info re-assert + the - // repaint to DoSetVisible's hidden->visible edge. WasResized while hidden is - // pointless (no frame can result), so it is dropped, not deferred. - if (dpr_changed) slot->needs_screen_info_on_show = true; - } - } + // HIDDEN: the begin-frame pump is gated off (PumpBeginFrame skips while !visible), so + // nothing CEF could paint now would reach the screen, yet the Swift resizeWatchdog would + // force-promote the blank surface → permanent blank on a static page. DoSetVisible's + // hidden->visible edge hands CEF the new geometry instead. + if (slot->browser && slot->visible) ApplyGeometry(slot); } void DoNavigate(const std::shared_ptr& slot, const std::string& url) { @@ -374,24 +360,132 @@ void DoSetVisible(const std::shared_ptr& slot, bool visible) { if (!slot->browser) return; slot->browser->GetHost()->WasHidden(!visible); // On the hidden->visible edge, FORCE a fresh full-viewport repaint at the - // current geometry. WasHidden(false) alone does NOT repaint, and three things can have left - // the live texture blank/stale while hidden: (a) a resize landed while the pump was gated off - // (DoResize deferred its paint here); (b) a dpr/screen-info change was deferred; (c) Chromium's - // FrameEvictionManager reclaimed the off-screen compositor frame entirely (happens past ~5 - // browsers / under memory pressure) so there is nothing to show even though geometry is - // unchanged. Re-assert screen info (if a dpr change was deferred) + size, then drive a - // guaranteed frame — mirrors DoResize/DoInvalidate. Unconditional on the edge because the - // eviction case carries no resize to key off. + // current geometry. WasHidden(false) alone does NOT repaint, and two things can have left + // the live texture blank/stale while hidden: (a) a resize or dpr change landed while the pump + // was gated off (DoResize left it for here); (b) Chromium's FrameEvictionManager reclaimed the + // off-screen compositor frame entirely (happens past ~5 browsers / under memory pressure) so + // there is nothing to show even though geometry is unchanged. Hand CEF the new geometry, re- + // assert size, then drive a guaranteed frame — mirrors DoResize/DoInvalidate. Unconditional on + // the edge because the eviction case carries no resize to key off. if (visible && !was_visible) { - if (slot->needs_screen_info_on_show) { - slot->browser->GetHost()->NotifyScreenInfoChanged(); - slot->needs_screen_info_on_show = false; - } + // A resize still in flight gets a fresh wait: nothing paints while hidden. + slot->resize_wait_since = std::chrono::steady_clock::now(); + ApplyGeometry(slot); slot->browser->GetHost()->WasResized(); slot->browser->GetHost()->Invalidate(PET_VIEW); slot->browser->GetHost()->SendExternalBeginFrame(); } } + +// Hand CEF a new size or density only once it has painted the last one. +// +// CEF's OSR view keeps one resize in flight: a change to the view size or scale +// "holds" until a paint arrives at the new pixel size, and a change made during +// the hold is queued and applied when it releases. A queued DENSITY change is +// applied through RenderWidgetHostViewBase::UpdateScreenInfo, which sends the +// renderer the new scale under the surface id it already has. The renderer's +// next frame then has a different scale for the same surface id, viz rejects it +// (a "surface invariants violation") and drops the renderer's frame sink. On +// macOS that view never draws again: the renderer comes back under a surface id +// it allocated itself, which CEF 144 never embeds (DidUpdateVisualProperties is +// compiled out for Mac), or the external begin-frame source waits forever on +// the dropped sink. Hide/show and reload don't bring it back. A slow page makes +// the hold long, so switching device frames on one froze Test Mode. +// +// NotifyScreenInfoChanged applies a density change safely: it retires the surface +// id first. It only gets to do that when no hold is on, so CEF sees geometry +// through cef_width/cef_height/cef_dpr, and those change only when no resize is +// in flight. Changes the plugin asks for meanwhile collapse into the latest one. +namespace { +constexpr int kResizePaintWaitMs = 1000; +constexpr int kResizePaintMaxWaitMs = 4000; +constexpr int kResizeMaxKicks = 6; + +// The pixel size CEF paints a view at: gfx::ScaleToCeiledSize in float, as +// CefRenderWidgetHostViewOSR computes it, so a paint matches it exactly. +int CefPixels(int logical, double dpr) { + return static_cast( + std::ceil(static_cast(logical) * static_cast(dpr))); +} +} // namespace + +void ApplyGeometry(const std::shared_ptr& slot) { + if (!slot->browser || !slot->visible || slot->resize_in_flight) return; + bool dpr_changed; + { + std::lock_guard lock(slot->surface_mutex); + if (slot->cef_width == slot->width && slot->cef_height == slot->height && + slot->cef_dpr == slot->dpr) + return; + dpr_changed = static_cast(slot->cef_dpr) != static_cast(slot->dpr); + slot->cef_width = slot->width; + slot->cef_height = slot->height; + slot->cef_dpr = slot->dpr; + slot->resize_want_w = CefPixels(slot->cef_width, slot->cef_dpr); + slot->resize_want_h = CefPixels(slot->cef_height, slot->cef_dpr); + } + CefRefPtr host = slot->browser->GetHost(); + // NotifyScreenInfoChanged applies the new size along with the new scale. + if (dpr_changed) + host->NotifyScreenInfoChanged(); + else + host->WasResized(); + // Drive a frame right now at the new size. With external begin-frame this is a guaranteed + // tick (not a coalesce-able Invalidate request), so the re-laid-out content composites into + // the new surface immediately; PumpBeginFrame's ongoing ticks cover the heavy-page settle. + host->Invalidate(PET_VIEW); + host->SendExternalBeginFrame(); + slot->resize_in_flight = true; + slot->resize_wait_since = std::chrono::steady_clock::now(); + slot->resize_wait_ms = kResizePaintWaitMs; + slot->resize_kicks = 0; +} + +void NoteViewPaint(const std::shared_ptr& slot, int pixel_w, int pixel_h) { + if (!slot->resize_in_flight || pixel_w != slot->resize_want_w || + pixel_h != slot->resize_want_h) + return; + if (slot->resize_kicks > 0) + SendLog(slot->browser_id, "resize: painted " + std::to_string(pixel_w) + "x" + + std::to_string(pixel_h) + " after " + + std::to_string(slot->resize_kicks) + " kick(s)"); + slot->resize_in_flight = false; + // CEF releases its hold after this paint callback returns; apply what came in + // meanwhile after that. + CefPostTask(TID_UI, base::BindOnce(&ApplyGeometry, slot)); +} + +// The fallback if a resize still doesn't paint. With the hold on and the +// geometry CEF sees unchanged, NotifyScreenInfoChanged only swaps in a fresh +// surface id, which gets the renderer to paint again. Waits back off (1, 2, +// 4 s) so a page that is merely slow to lay out isn't kicked over and over. +void CheckResizeStall(const std::shared_ptr& slot) { + if (!slot->resize_in_flight || !slot->visible || !slot->browser) return; + const auto now = std::chrono::steady_clock::now(); + if (now - slot->resize_wait_since < + std::chrono::milliseconds(slot->resize_wait_ms)) + return; + const std::string want = std::to_string(slot->resize_want_w) + "x" + + std::to_string(slot->resize_want_h); + if (slot->resize_kicks >= kResizeMaxKicks) { + SendLog(slot->browser_id, "resize: no paint at " + want + " after " + + std::to_string(kResizeMaxKicks) + + " kicks; moving on"); + slot->resize_in_flight = false; + ApplyGeometry(slot); + return; + } + slot->resize_kicks++; + SendLog(slot->browser_id, "resize: no paint at " + want + " in " + + std::to_string(slot->resize_wait_ms) + + "ms; kick " + std::to_string(slot->resize_kicks)); + CefRefPtr host = slot->browser->GetHost(); + host->NotifyScreenInfoChanged(); + host->Invalidate(PET_VIEW); + host->SendExternalBeginFrame(); + slot->resize_wait_since = now; + slot->resize_wait_ms = std::min(slot->resize_wait_ms * 2, kResizePaintMaxWaitMs); +} void DoSetAudioMuted(const std::shared_ptr& slot, bool muted) { if (slot->browser) slot->browser->GetHost()->SetAudioMuted(muted); } diff --git a/packages/flutter_cef_macos/native/cef_host/host_state.h b/packages/flutter_cef_macos/native/cef_host/host_state.h index 898be11..55731aa 100644 --- a/packages/flutter_cef_macos/native/cef_host/host_state.h +++ b/packages/flutter_cef_macos/native/cef_host/host_state.h @@ -145,9 +145,14 @@ struct Slot { // is. Guarded by surface_mutex. MRC: holds the +1 from newTextureWithDescriptor. id dst_mtl = nil; uint32_t dst_mtl_sid = 0; - int width = 800; // logical (DIP) — GetViewRect; CEF scales by dpr. + int width = 800; // logical (DIP) size the plugin asked for. int height = 600; - double dpr = 1.0; // device pixel ratio; the IOSurface is logical*dpr px. + double dpr = 1.0; // device pixel ratio the plugin asked for. + // The geometry CEF sees (GetViewRect / GetScreenInfo). It trails width/height/dpr + // while a resize is in flight: see ApplyGeometry in browser_ops.mm. + int cef_width = 800; + int cef_height = 600; + double cef_dpr = 1.0; // Popup widgets ( dropdown) painted on top of the view. @@ -450,8 +453,8 @@ void CompositeSoftwareLocked(IOSurfaceRef view_src) { IOSurfaceUnlock(view_src, kIOSurfaceLockReadOnly, nullptr); } if (slot_->popup_visible && !slot_->popup_buf.empty()) { - const int px = static_cast(slot_->popup_rect.x * slot_->dpr); - const int py = static_cast(slot_->popup_rect.y * slot_->dpr); + const int px = static_cast(slot_->popup_rect.x * slot_->cef_dpr); + const int py = static_cast(slot_->popup_rect.y * slot_->cef_dpr); BlitBGRA(dst, ds, dw, dh, slot_->popup_buf.data(), slot_->popup_w, slot_->popup_h, px, py); } @@ -632,6 +635,9 @@ void OnAcceleratedPaint(CefRefPtr, PaintElementType type, SendLog(slot_->browser_id, "OnAcceleratedPaint: null io_surface"); return; } + if (type == PET_VIEW) + NoteViewPaint(slot_, static_cast(IOSurfaceGetWidth(src)), + static_cast(IOSurfaceGetHeight(src))); std::lock_guard lock(slot_->surface_mutex); // PRODUCER-ALLOCATES: the surface is minted lazily by the FIRST view paint (and re-minted on // any size change) inside the composite path — so we must NOT early-return on a null surface diff --git a/tool/run_probes.sh b/tool/run_probes.sh index 6642fe3..3b02cfe 100755 --- a/tool/run_probes.sh +++ b/tool/run_probes.sh @@ -41,6 +41,7 @@ APP="$ROOT/example/build/macos/Build/Products/Debug/flutter_cef_example.app/Cont # soak or Windows-only: listed, never run) PROBES=( "authored_origin|authored_origin_probe|120|default|-|loadHtmlString(baseUrl:) serves the document at that origin" + "device_frame|device_frame_probe|300|default|-|switching size and render scale keeps a slow page painting" "document_start|document_start_probe|120|default|-|document-start scripts run before page scripts" "hidden_at_create|hidden_at_create_probe|120|default|-|a view created hidden paints once shown" "host_start_failure|host_start_failure_probe|60|false|-|a host that exits at once reports createFailed" From 364bf05310133680781984a6db182e387f776fa2 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Tue, 29 Sep 2026 13:01:46 -0700 Subject: [PATCH 2/2] macOS: apply a resize that arrived while the browser was being created CEF builds its view at the create size, and GetViewRect now reports the geometry handed to CEF rather than the latest request, so a resize that landed before OnAfterCreated would wait for the next one. Hand CEF the latest geometry when the browser binds. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/flutter_cef_macos/native/cef_host/host_client.mm | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/flutter_cef_macos/native/cef_host/host_client.mm b/packages/flutter_cef_macos/native/cef_host/host_client.mm index 650b94f..95b4a62 100644 --- a/packages/flutter_cef_macos/native/cef_host/host_client.mm +++ b/packages/flutter_cef_macos/native/cef_host/host_client.mm @@ -7,6 +7,7 @@ #include #include "authored_content.h" +#include "browser_ops.h" #include "include/cef_request_context.h" #include "include/wrapper/cef_helpers.h" #include "include/wrapper/cef_message_router.h" @@ -551,6 +552,9 @@ void OnAfterCreated(CefRefPtr browser) override { slot_->begin_frame_pump_started = true; PumpBeginFrame(slot_->browser_id); } + // A resize that arrived while the browser was being created: CEF built its view at the + // create size, so hand it the latest geometry now (hidden: DoSetVisible does it on show). + ApplyGeometry(slot_); } // CefLifeSpanHandler: route popups (window.open / target=_blank) to the host