fix(js): fire load/error for dynamically inserted stylesheet links (#412)

A <link rel=stylesheet href> appended via JS (createElement +
appendChild) was never fetched and fired neither load nor error, so any
Promise.all awaiting the link's onload (lazy CSS loaders, antd/bootstrap
fetchers) hung forever and the page stalled at its pre-load stage.

appendChild already drives this for <script src>; mirror it for links:
resolve the href against <base>/document URL, fetch it, and dispatch
load on success or error on failure. CSS is not applied (no layout
engine yet) but the load-event contract matches Chrome.

Read the stylesheet intent from both the rel attribute and the rel
property, since obscura does not yet reflect the rel IDL attribute back
to the content attribute. Closes #409.
This commit is contained in:
SGavrl authored and GitHub committed 2026-07-14 12:13:25 +00:00
1 parent a92166897f
commit c8c78ba229
2 files changed
+167

No files matched your search

@@ -0,0 +1,119 @@
// Regression for issue #409: a `<link rel="stylesheet" href>` inserted via JS
// (createElement + appendChild) must fetch and fire `load`, so frameworks that
// await the link's onload (Promise.all of lazy CSS + JS, antd/bootstrap
// loaders) resolve instead of hanging forever. On main the link neither fires
// load nor error, so the page stays on stage1.
use obscura_cdp::dispatch::{dispatch, CdpContext};
use obscura_cdp::types::CdpRequest;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
// Serves a page that appends a stylesheet link and resolves a promise on load.
// Also serves the CSS body so the fetch succeeds.
async fn serve() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
for _ in 0..4 {
let (mut socket, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let _ = socket.read(&mut buf).await.unwrap();
let req = String::from_utf8_lossy(&buf[..]);
let (status, ct, body) = if req.starts_with("GET /style.css") {
("200 OK", "text/css", "body { color: red; }")
} else {
(
"200 OK",
"text/html",
r#"<html><head></head><body>
<div id="r">stage1</div>
<script>
window.__loaded = new Promise(function (resolve, reject) {
var l = document.createElement("link");
l.rel = "stylesheet";
l.href = "/style.css";
l.onload = function () { document.getElementById("r").textContent = "stage2"; resolve("ok"); };
l.onerror = function () { reject("err"); };
document.head.appendChild(l);
});
</script>
</body></html>"#,
)
};
let resp = format!(
"HTTP/1.1 {status}\r\nContent-Type: {ct}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(resp.as_bytes()).await;
});
}
});
format!("http://{addr}/")
}
async fn cdp(ctx: &mut CdpContext, id: u64, method: &str, params: Value, session_id: &str) -> Value {
let resp = dispatch(
&CdpRequest {
id,
method: method.to_string(),
params,
session_id: Some(session_id.to_string()),
},
ctx,
)
.await;
assert!(resp.error.is_none(), "CDP {method} failed: {:?}", resp.error);
resp.result.unwrap_or_else(|| json!({}))
}
#[tokio::test(flavor = "current_thread")]
async fn dynamic_stylesheet_fires_load() {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-1";
ctx.sessions.insert(session_id.to_string(), page_id.clone());
cdp(
&mut ctx,
1,
"Page.navigate",
json!({"url": url, "waitUntil": "load"}),
session_id,
)
.await;
// Race the link's onload promise against a 5s timeout. On main the link
// never settles, so this rejects with "timeout" and the assertion fails.
let v = cdp(
&mut ctx,
2,
"Runtime.evaluate",
json!({
"expression": "(async () => { try { return await Promise.race([window.__loaded, new Promise((_, r) => setTimeout(() => r('timeout'), 5000))]); } catch (e) { return 'rejected:' + e; } })()",
"awaitPromise": true,
"returnByValue": true,
}),
session_id,
)
.await;
let value = v["result"]["value"].as_str().unwrap_or("");
assert_eq!(value, "ok", "dynamic <link rel=stylesheet> must fire load (got {:?})", value);
let text = cdp(
&mut ctx,
3,
"Runtime.evaluate",
json!({"expression": "document.getElementById('r').textContent", "returnByValue": true}),
session_id,
)
.await;
assert_eq!(
text["result"]["value"], "stage2",
"the page must advance to stage2 once the stylesheet link loads"
);
}
+48
View File
@@ -198,6 +198,51 @@ async function __processDynScriptQueue() {
__dynScriptBusy = false;
}
}
// Resolve a resource URL (script src / link href) against <base href> or the
// document URL, the way the inline dynamic-script path does. Guarded so a bad
// base or href never throws into appendChild.
function _resolveResourceUrl(src) {
let baseHref = null;
try {
const baseEl = globalThis.document?.querySelector('base[href]');
baseHref = baseEl ? baseEl.getAttribute('href') : null;
} catch(e) { baseHref = null; }
const docUrl = globalThis.location?.href || 'http://localhost/';
let baseUrl;
try { baseUrl = baseHref ? new URL(baseHref, docUrl).href : docUrl; }
catch(e) { baseUrl = docUrl; }
try {
return src.startsWith('http') || src.startsWith('data:')
? src
: new URL(src, baseUrl).href;
} catch(e) { return src; }
}
// A dynamically-inserted <link rel="stylesheet" href> must fetch and fire
// load/error so frameworks awaiting the link's onload (Promise.all of lazy
// CSS + JS, antd/bootstrap loaders, etc.) resolve instead of hanging forever.
// There is no layout engine to apply the CSS, but the load-event contract
// matches Chrome. Issue #409.
async function _loadLinkedStylesheet(c) {
// obscura does not yet reflect the `rel` IDL attribute back to the content
// attribute, so `link.rel = "stylesheet"` leaves getAttribute('rel') null.
// Read both so the property-assignment form (the common framework pattern)
// and the parsed-from-HTML form are both recognized.
const rel = (c.getAttribute('rel') || c.rel || '').toString().toLowerCase();
if (!rel.split(/\s+/).includes('stylesheet')) return;
const href = c.getAttribute('href');
if (!href) return;
const fullUrl = _resolveResourceUrl(href);
let pageOrigin = "";
try { pageOrigin = new URL(fullUrl).origin; } catch(e) {}
try {
await Deno.core.ops.op_fetch_url(fullUrl, "GET", "{}", "", pageOrigin, "no-cors");
try { c.dispatchEvent(new Event('load', { bubbles: true })); } catch(e) {}
} catch(e) {
try { c.dispatchEvent(new Event('error', { bubbles: true })); } catch(e) {}
}
}
function _fpRand(salt) {
let h = (_fpSeed ^ (salt || 0)) | 0;
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
@@ -687,6 +732,9 @@ class Node {
}
}
}
if (c instanceof Element && c.tagName === 'LINK') {
_loadLinkedStylesheet(c);
}
return c;
}
removeChild(c) {