From c8c78ba2294baf78aac8b5feb040e281b6a71468 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:13:25 +0000 Subject: [PATCH] fix(js): fire load/error for dynamically inserted stylesheet links (#412) A 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 +"#, + ) + }; + 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 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" + ); +} diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 0625061..8d50245 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -198,6 +198,51 @@ async function __processDynScriptQueue() { __dynScriptBusy = false; } } +// Resolve a resource URL (script src / link href) against 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 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) {