LibWeb: Repaint iframe containers after render unblocking

When an embedded document was render-blocked, the parent display list
recording skipped drawing that iframe's compositor surface. Once the
child document later unblocked, only the child surface was repainted, so
the parent could keep replaying a display list that had no surface draw
command until another invalidation, such as resize, forced a recording.

Invalidate the containing iframe when a document's render-blocking set
becomes empty so the parent display list records the child surface. Add
a deterministic reftest that gates a render-blocking stylesheet on a
test-server signal, forcing the parent to paint the blocked iframe
before unblocking it.
This commit is contained in:
Andreas Kling 2026-05-24 11:03:35 +02:00 committed by Andreas Kling
parent ca97f68cb7
commit 412a66ad5e
5 changed files with 132 additions and 4 deletions

View file

@ -7721,8 +7721,15 @@ void Document::add_render_blocking_element(GC::Ref<Element> element)
void Document::remove_render_blocking_element(GC::Ref<Element> element)
{
m_render_blocking_elements.remove(element);
if (m_render_blocking_elements.is_empty())
page().client().request_frame();
if (!m_render_blocking_elements.is_empty())
return;
if (auto navigable = this->navigable()) {
if (auto container = navigable->container())
container->set_needs_repaint(InvalidateDisplayList::Yes);
}
page().client().request_frame();
}
// https://fullscreen.spec.whatwg.org/#run-the-fullscreen-steps

View file

@ -8,6 +8,7 @@ import os
import socket
import socketserver
import sys
import threading
import time
import urllib.parse
@ -35,6 +36,7 @@ class Echo:
reason_phrase: Optional[str]
reflect_headers_in_body: bool
close_connection: bool
wait_for_unblock: Optional[str]
def __eq__(self, other):
if not isinstance(other, Echo):
@ -51,6 +53,7 @@ class Echo:
and self.reason_phrase == other.reason_phrase
and self.reflect_headers_in_body == other.reflect_headers_in_body
and self.close_connection == other.close_connection
and self.wait_for_unblock == other.wait_for_unblock
)
@ -60,6 +63,9 @@ echo_store: Dict[str, Echo] = {}
# Headers from the most recent request at each echo path, queryable via GET /recorded-request-headers<echo-path>.
recorded_request_headers: Dict[str, Dict[str, list]] = {}
# Named events used by tests that need deterministic delayed responses.
unblock_events: Dict[str, threading.Event] = {}
class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
static_directory: str
@ -115,7 +121,9 @@ class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
super().do_GET()
def do_GET(self):
if self.path.startswith("/echo"):
if self.path.startswith("/unblock/"):
self._serve_unblock()
elif self.path.startswith("/echo"):
self.handle_echo()
elif self.path.startswith("/recorded-request-headers/"):
self._serve_recorded_request_headers()
@ -173,6 +181,7 @@ class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
echo.reason_phrase = data.get("reason_phrase", None)
echo.reflect_headers_in_body = data.get("reflect_headers_in_body", False)
echo.close_connection = data.get("close_connection", False)
echo.wait_for_unblock = data.get("wait_for_unblock", None)
is_invalid_echo_path = echo.path is None or not echo.path.startswith("/echo/")
@ -205,6 +214,8 @@ class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
return
echo_store[key] = echo
if echo.wait_for_unblock is not None:
unblock_events[echo.wait_for_unblock] = threading.Event()
host = self.headers.get("host", "localhost")
path = echo.path.lstrip("/")
@ -222,6 +233,14 @@ class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
self.end_headers()
self.wfile.write(json.dumps(fetch_config).encode("utf-8"))
def _serve_unblock(self):
token = urllib.parse.unquote(self.path[len("/unblock/") :])
event = unblock_events.setdefault(token, threading.Event())
event.set()
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
def _serve_recorded_request_headers(self):
echo_path = self.path[len("/recorded-request-headers") :]
headers = recorded_request_headers.get(echo_path)
@ -268,6 +287,10 @@ class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
self.connection.close()
return
if echo.wait_for_unblock is not None:
event = unblock_events.setdefault(echo.wait_for_unblock, threading.Event())
event.wait()
response_headers = echo.headers.copy()
if echo.delay_ms is not None:
@ -363,7 +386,8 @@ def start_server(port, static_directory):
TestHTTPRequestHandler.wpt_directory = os.path.join(
TestHTTPRequestHandler.static_directory, "Text", "input", "wpt-import"
)
httpd = socketserver.TCPServer(("127.0.0.1", port), TestHTTPRequestHandler)
httpd = socketserver.ThreadingTCPServer(("127.0.0.1", port), TestHTTPRequestHandler)
httpd.daemon_threads = True
print(httpd.socket.getsockname()[1])
sys.stdout.flush()

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<style>
body {
margin: 0;
background: white;
}
#box {
width: 120px;
height: 120px;
background: green;
}
</style>
<div id="box"></div>

View file

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html class="reftest-wait">
<head>
<link
rel="match"
href="../expected/iframe-render-blocked-child-paints-after-unblock-ref.html"
/>
<script src="../../Text/input/include.js"></script>
<style>
body {
margin: 0;
background: white;
}
iframe {
display: block;
width: 120px;
height: 120px;
border: 0;
background: red;
}
</style>
</head>
<body>
<iframe></iframe>
<script>
const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve));
window.addEventListener("load", async () => {
const server = httpTestServer();
const unblockToken = "iframe-render-blocked-child-paints-after-unblock";
const stylesheetURL = await server.createEcho("GET", "/iframe-render-blocked-child-paints-after-unblock.css", {
status: 200,
headers: {
"Content-Type": "text/css",
},
body: "body { margin: 0; }",
wait_for_unblock: unblockToken,
});
const childHTML = "\x3C!DOCTYPE html>"
+ '\x3Clink id="blocker" rel="stylesheet" href="' + stylesheetURL + '">'
+ '\x3Cbody style="margin: 0; background: green;">\x3C/body>';
const childURL = await server.createEcho("GET", "/iframe-render-blocked-child-paints-after-unblock-child.html", {
status: 200,
headers: {
"Content-Type": "text/html",
},
body: childHTML,
});
const iframe = document.querySelector("iframe");
const iframeLoaded = new Promise(resolve => {
iframe.addEventListener("load", resolve, { once: true });
});
iframe.src = childURL;
let blocker = null;
while (!blocker) {
blocker = iframe.contentDocument && iframe.contentDocument.getElementById("blocker");
if (!blocker)
await nextFrame();
}
const blockerLoaded = new Promise(resolve => {
blocker.addEventListener("load", resolve);
});
await nextFrame();
await nextFrame();
await fetch(`${server.baseURL}/unblock/${unblockToken}`);
await blockerLoaded;
await iframeLoaded;
await nextFrame();
await nextFrame();
document.documentElement.className = "";
});
</script>
</body>
</html>

View file

@ -0,0 +1 @@
Content-Type: text/html