devShakib

Loading devShakib…

Flutter Assumes There Is Only One Window. I Gave It Two.

Chromium keeps painting a picture-in-picture opener at full rate, then reports the page hidden. Flutter believes the report and stops drawing.

Document Picture-in-Picture is a browser API that gives you a real operating system window. Not an overlay pinned inside your page — an actual window the browser owns, floating above your editor and your terminal and everything else, which keeps running when you switch tabs.

Chrome and Edge have had it since 116. Firefox shipped it in 151. Almost nothing uses it, and I could not find anything reaching it from Flutter, so I wrote document_pip to find out why.

The API itself is about four lines. The difficulty is entirely on the Flutter side, and it has one cause: the engine is built on the assumption that there is one window. Not stated anywhere as a constraint — just quietly baked into three different singletons. A second window makes each of them wrong, and every one of them fails silently, in a way that looks like a different problem.

A package cannot turn multi-view on

The first wall is structural. Multi-view Flutter has no single root, so an app that can grow a second view calls runWidget, not runApp. That part is documented.

The part that is not: only the JavaScript app object returned by engine.runApp() can add a view. dart:ui_web exposes the view list read-only. So the object that can create the pop-out's view lives in the bootstrap, before any Dart has run, and a package published to pub.dev cannot reach it. The app has to hand it over:

const engine = await engineInitializer.initializeEngine({
  multiViewEnabled: true,
});
const app = await engine.runApp();
window.documentPipApp = app;          // the package needs this
app.addView({ hostElement: document.querySelector('#app') });

Multi-view is a property of how the engine starts. Nothing published to pub.dev can switch it on from the inside, which is worth knowing before you go looking for the API that does.

One trap in that snippet. Never pass document.body as hostElement. Flutter clears a host element's children and sizes the view to 100% of it, so body wipes your page — script tags included — and then measures zero. A blank screen and no exception. I had that exact line sitting in an error message until the pre-publish audit caught it — of the three copies of that snippet in the package, the wrong one was the copy a stuck developer actually reaches, and the only copy never run.

The window freezes exactly when you need it

Here is the good one.

You open the pop-out, switch to another tab to do the thing you opened it for, and it stops. Frozen frame, still floating, still on top, completely dead.

The cause is a disagreement between two true statements. Chromium keeps painting a document-picture-in-picture opener at full rate while its tab is in the background — that is the entire point, the window has to keep working. But it still reports the page as hidden, because by any normal definition it is: the user is looking at another tab.

Flutter's web engine reads visibilityState: "hidden" and turns it into AppLifecycleState.hidden. SchedulerBinding responds by clearing framesEnabled, after which scheduleFrame() returns early forever. That is correct behaviour and good citizenship — nobody wants a backgrounded tab burning battery. It is also exactly wrong here, because the one surface the user can still see is the one Flutter just stopped drawing.

Measured rather than argued, because the whole failure is about believing a report instead of checking:

Chromium, tab in the backgroundbrowser framesFlutter frames
no pop-out open2 in 2.5s
pop-out open302 in 2.5s0 in 3s
pop-out open, with the fix302 in 2.5s311 in 3s

The browser was drawing at roughly 120fps. Flutter drew nothing.

scheduleForcedFrame() is the documented way past it — it ignores framesEnabled and only checks whether a frame is already pending. So the root widget re-arms it for exactly as long as both conditions hold:

void _keepPaintingWhileHidden(Duration _) {
  if (!mounted) return;
  final binding = WidgetsBinding.instance;
  if (binding.framesEnabled) return;              // page is back
  if (DocumentPip.popOutViewIds.isEmpty) return;  // last window closed
  binding.scheduleForcedFrame();
  binding.addPostFrameCallback(_keepPaintingWhileHidden);
}

Both guards end the loop on their own, which matters: this is a hand-rolled frame pump, and one that cannot stop is a battery bug wearing the costume of a fix. A post-frame callback rather than a persistent one for the same reason — a persistent frame callback can never be removed, and would pin the State forever.

And it is a Chromium problem specifically. Firefox 151 and 155 keep reporting the opener visible with a pop-out open — 308 frames in 2.5s against 9 for the same page without one — so framesEnabled is never cleared and that first guard returns immediately. The workaround is gated on the failure rather than on the browser, so where the failure does not exist it never runs.

The keyboard is dead, but typing works

The second singleton. Flutter's KeyboardBinding attaches capture-phase keydown/keyup listeners once, globally, to the opener's window. A picture-in-picture window is a separate browsing context with its own window, so while it has focus the engine hears nothing.

What makes this genuinely nasty is which half breaks. Type into a text field in the pop-out and it works perfectly — the browser routes characters to the focused DOM element natively, and the engine reads them back off that element. So the feature you would test first is the one that is fine.

Everything that travels as a key event is dead: Shortcuts, Actions, Focus.onKeyEvent, HardwareKeyboard, Escape, Tab traversal. Text selection has the same shape, since all three of the engine's selectionchange subscriptions are on the opener's document — so moving the caret with the arrow keys never reaches Flutter's editing state either.

The fix is to replay the events into the opener and hand Flutter's focus to the pop-out's view when its window takes focus. Which produces its own trap worth stealing:

// One tear-off each, kept: every `.toJS` makes a NEW JS function, so
// removing with a second one silently leaves the listener attached.
late final JSFunction _onKeyRef = _onKey.toJS;

.toJS on the same Dart function twice gives you two different JavaScript objects. removeEventListener compares by identity, finds nothing, removes nothing, and reports success. That is not specific to picture-in-picture — it applies to every addEventListener you write in Dart, and it fails as a leak rather than as an error.

Keys held when a window closes are released explicitly, too. Otherwise HardwareKeyboard still believes they are down, and the next real press of the same key trips its consistency assertion — a crash somewhere else entirely, minutes later.

Two rules the browser imposes

open() must be the first await in a gesture handler. The browser only permits this while handling a real click, and awaiting anything beforehand spends the gesture. Load your data after:

onPressed: () async {
  final window = await DocumentPip.open();   // first
  final data = await fetchTrack();           // then
}

Get it wrong and Chrome says NotAllowedError — which it also says when you call from an iframe, and when a window is already open. Three causes, one error name. The package lists all three rather than asserting the common one, because sending someone to fix a click handler that was already correct costs them an afternoon.

One window, browser-wide. Not per tab, per browser. Opening a second closes the first, including one belonging to a completely different site. Your window can vanish because someone else opened theirs, so closed completes for that too and you handle it like any other close.

What the pattern actually is

None of these are bugs in Flutter, and I want to be precise about that. Turning frames off for a hidden page is right. Binding the keyboard once is right. Assuming one view is right for every app that has one view, which is very nearly all of them.

They are all the same shape of problem: a framework generalisation that holds for every case but yours, failing in a way that produces no error. A frozen window looks like a rendering bug. A dead shortcut looks like a focus bug — especially when typing still works. Neither logs anything.

The only thing that actually worked was refusing to reason about it. Every number in this post came from driving a real browser and counting frames, because in all three cases the platform's report was true and the conclusion drawn from it was wrong, and no amount of reading the source would have told me which.

document_pip is MIT and on pub.dev; the source is at github.com/devShakib015/flutter_packages.