Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

445. A page is not shown before it can be seen

Date: 2026-09-20

Status

Accepted. Builds on ADR-0444, whose parking mechanism this reuses for a second reason.

Context

Open the showcase’s web tab and the page is a white rectangle for as long as the network takes, and then the page appears in it. Nothing says it is loading, because nothing can.

The white is WebKit’s default document background. goldberry_webview_create_embedded maps the engine’s window and reparents it into the application’s inside one call — that is the order ADR-0442 requires, because GDK picks its backend during gtk_init and a page created before the backend is settled gets a wl_surface that cannot be reparented. So by the time Java has a handle, a window is already on screen, over the widget’s box, showing an empty document.

The obvious fix is not available. A spinner drawn over the page would be drawn underneath it: a page is a platform window above the frame, which is the whole of what ADR-0441 and ADR-0442 established and what ADR-0444 had to work around for dialog. There is no raster to composite into.

What ADR-0444 did leave behind is a mechanism: parking, which moves the page’s child window past its parent’s corner where the parent clips every pixel of it, without resizing it and therefore without reflowing the document. A parked page is invisible and the box it will occupy is ordinary frame that Goldberry paints.

Decision

A page stays parked until it has something to show, and a spinner is drawn in the box it will occupy.

Three parts.

The page is opened parked, not parked on the next frame. open hands Host.embeddedWebView the parked rectangle rather than the box, because the shim maps and reparents inside that call — a page created over its box is visible, and empty, before anything in the widget could move it. One frame of white is still a flash.

The widget asks, once a frame, whether the page is ready. goldberry_webview_load_state answers -1 unknown, 0 not started, 1 loading, 2 finished, and the widget is already calling the shim once a frame from its painter to keep the page over its box, so the question rides along.

A stack, so there is somewhere to draw. The surface is the first child and stays in flow, so it still sizes the box and still carries the id Host.anchor resolves; the spinner is absolute and covers nothing.

Polled, not signalled

WebKit has a load-changed signal and it is not bound. Binding it means an upcall stub whose lifetime outlives the Java object that owns it, a callback arriving from GLib’s thread into a widget confined to the UI thread, and a registration to unwind when the page closes. What it would buy over a poll is nothing: the consumer is a painter that runs every frame anyway, and one int per frame is not a cost anybody can measure against a frame budget of 16 ms.

The two facts are joined in C

webkit_web_view_is_loading and webkit_web_view_get_estimated_load_progress are combined in the shim rather than in Java, because the interesting state is the one neither reports alone. A page that has been created and never navigated — which is every page between create_embedded and the navigate after it — is not loading and has made no progress, and it is exactly as unready as one still fetching. Java would otherwise have to know that estimated-load-progress is 0 before the first load and stays at 1 after the last, which is WebKit’s business.

Unknown means show it

WebLoad.isReady() answers true for UNKNOWN, and that is the load-bearing default. A build whose engine will not say — Windows and macOS, where the embedding itself is unwritten — must behave as every build did before there was a question to ask. A page that never appears at all is a far worse failure than a white flash.

The flag is read in build, so only setState may write it

Stated because the first version of this got it wrong, and the way it failed is worth keeping. open set loading = true directly — the page had just been created parked, and the flag was true, so the code read correctly. But build had already run for that frame and nothing asked it to run again, so no spinner was ever put in the tree; and the next frame’s loadingIs(true) found the flag already true and skipped the setState that was the only thing left to do.

Both reported symptoms followed from that one line. No spinner, ever — and then, because a spinner is what asks for frames for ever, no frames: the loop went idle with the page still parked, and the page appeared only when something unrelated caused a repaint. Moving the pointer was what did it.

So there are two fields. loading is what build reads and changes only through setState; loadingWanted is what has been asked for, because the setState is requested from inside a paint and runs on the next turn of the loop — every frame in between would otherwise queue the same change again.

And the poll asks for its own next frame

Host.repaint() while the page is held off, rather than relying on the spinner’s own animation to keep the loop turning. A spinner does ask for frames for ever, so the loop would in fact stay awake once one existed — but that makes this widget’s correctness depend on which indicator it happens to draw, and it does not solve the first frame, which is a deadlock either way: no frame, no setState, no spinner, no frames.

The page’s progress changes with no tree change to notice it. Polling something means asking for the frame that looks again, and it stops the moment the page is ready, which is what keeps an idle application idle.

Alternatives considered

Set the page’s background colour with webkit_web_view_set_background_color, so the flash is the theme’s surface rather than white. Cheap, and it treats the symptom: the box is then a coloured rectangle with nothing in it and no indication that anything is happening. It is not exclusive with this decision and may still be worth doing; it is not a substitute for it.

Draw the spinner over the page. Impossible, and it is the premise of ADR-0441 rather than a thing that could be tried.

Hide the page with gtk_widget_hide instead of parking it. The same alternative ADR-0444 weighed and left as the designed fallback, for the same reasons: it costs an export and an ABI bump of its own, and whether gtk_widget_show restores an already-reparented window into the same X parent is the class of question that produced an empty rectangle the first time. Parking is already built and already tested.

Wait for LOADING before showing a spinner, rather than treating IDLE as unready. That is a spinner that appears a frame or two after the white it was meant to replace, which is the defect with an extra step.

Consequences

No white. The page is never on screen without content. What is on screen instead is an empty box and a spinner, which is what a user reads as “this is coming”.

The page loads while parked. A clipped-out child window is still mapped, so WebKit fetches, parses and lays out exactly as before — the page is finished when it appears rather than appearing and then finishing. Whether it also keeps painting while clipped is the open question ADR-0444 already records, and this decision makes it apply for longer: a slow page is now parked for the whole of its load rather than only for the length of a dialog.

A page that never loads shows a spinner for ever. WebKit substitutes its own error document for a page that fails, which ends the load and shows the error — so this is the case where the server accepts the connection and never answers. There is no timeout, and adding one would mean choosing a number on every application’s behalf.

stack leaves WidgetParityTest’s unstyled list, the way canvas did for ADR-0442 and for the same reason: the toolkit now builds one, so it needs one class-scoped rule to lay it out. A bare stack still gets nothing.

Webview ABI 5. One new export. A stale libgoldberry-webview is refused with the message it already had rather than called into.

Still no automated end-to-end test, for ADR-0444’s reason: no golden image can contain a page. What is tested is the arithmetic, that IDLE and LOADING are both unready, that UNKNOWN is not, and that stage puts a spinner in the tree exactly when it is loading. What is not testable is the half that actually broke — that the flag reaches build on the right frame — because that is a fact about frames and there is no frame in a test.

So the widget says so out loud instead: raising and taking down the indicator are logged at debug, which is how the fix above was confirmed and is the only evidence a running application can give.

22:49:17.930 INFO  web-view: a page is open inside the window over ...
22:49:17.953 DEBUG web-view "web-page": raising the loading spinner
22:49:20.659 DEBUG web-view "web-page": taking down the loading spinner
22:49:20.660 TRACE web-view "web-page" placed over ...