fix(js): serialize dynamic module imports + re-land base href for SPA lazy chunks (#246) (#275)

* fix(js): serialize dynamic module imports + re-land base href for SPA lazy chunks (#246)

Add a serialized import queue (__dynScriptQueue + __processDynScriptQueue)
in bootstrap.js so that dynamically inserted <script module> tags execute
one at a time rather than concurrently. This prevents re-entrant RefCell
panics in deno_core's futures_unordered_driver when SPAs (Angular, etc.)
append multiple lazy chunks at once.

Also re-land dynamic import <base href> resolution (previously reverted
as 88af239). The original panic was caused by concurrency, not the URL
resolution logic — now safe because imports are serialized.

Inline module scripts also route through the same queue for consistency.

Fixes #246
Closes #242 (dynamic import base href)

* Fix base-href resolution, queue wedge, and double-fired load event

- Resolve <base href> to an absolute URL before resolving the script src, and
  guard the new URL() call. A path-only base like <base href="/"> (the standard
  Angular form) was passed raw to new URL(src, base), which threw "Invalid URL"
  out of appendChild and broke the SPAs this change targets.
- Wrap the dynamic-script queue drain in try/finally so __dynScriptBusy is always
  cleared. An unexpected throw used to wedge the queue and silently block every
  later dynamic script on the page.
- Fire load/error through dispatchEvent only. dispatchEvent already invokes the
  element's onload property handler, so the extra explicit onload() call
  double-fired it. This also drops the stale enqueue-time callback snapshot and
  reads handlers live off the element.

---------

Co-authored-by: SGavrl <[email protected]>
This commit is contained in:
zhangyang-crazy-oneandSGavrl authored and GitHub committed 2026-06-09 10:27:50 +02:00
1 parent 179d9f3369
commit 5225f6d985
1 file changed
+84 -29
+84 -29
View File
@@ -59,6 +59,46 @@ if (_origStackDesc && _origStackDesc.get) {
} }
let _fpSeed = 0; let _fpSeed = 0;
// Dynamic script import queue — serializes concurrent import() calls
// to prevent re-entrant RefCell panic in deno_core's futures_unordered_driver
// when SPAs dynamically insert multiple <script module> tags at once.
let __dynScriptQueue = [];
let __dynScriptBusy = false;
async function __processDynScriptQueue() {
if (__dynScriptBusy) return;
__dynScriptBusy = true;
// try/finally so the busy flag is always cleared even if a task throws
// outside its own guard; otherwise the queue would wedge and silently
// block every later dynamic script on the page.
try {
while (__dynScriptQueue.length > 0) {
const task = __dynScriptQueue.shift();
try {
if (task.isModule) {
await import(task.url);
} else {
const raw = await Deno.core.ops.op_fetch_url(task.url, "GET", "{}", "", task.pageOrigin, "no-cors");
const parsed = JSON.parse(raw);
if (parsed.body) {
globalThis.__currentScriptNid = task.nid;
try { (0, eval)(parsed.body); }
catch(e) { console.error('Dynamic script error (' + task.url + '):', e.message); }
finally { globalThis.__currentScriptNid = task.prevNid || 0; }
}
}
// Fire load via dispatchEvent only: it invokes the element's onload
// property handler and any addEventListener('load') listeners, read
// live off the element. Calling onload separately would double-fire it.
try { task.dispatchEvent(new Event('load')); } catch(e) {}
} catch(e) {
console.error('Dynamic script fetch error:', e.message);
try { task.dispatchEvent(new Event('error')); } catch(ex) {}
}
}
} finally {
__dynScriptBusy = false;
}
}
function _fpRand(salt) { function _fpRand(salt) {
let h = (_fpSeed ^ (salt || 0)) | 0; let h = (_fpSeed ^ (salt || 0)) | 0;
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
@@ -363,40 +403,55 @@ class Node {
const src = c.getAttribute('src'); const src = c.getAttribute('src');
const prevNid = globalThis.__currentScriptNid; const prevNid = globalThis.__currentScriptNid;
if (src) { if (src) {
const fullUrl = src.startsWith('http') || src.startsWith('data:') // Resolve against <base href> when present, else the document URL.
? src // The base href is resolved to an absolute URL first: a bare path like
: new URL(src, globalThis.location?.href || 'http://localhost/').href; // <base href="/"> (the common Angular form) is not a valid URL base on
const pageOrigin = (function() { try { return new URL(globalThis.location?.href || "about:blank").origin; } catch(e) { return ""; } })(); // its own and would otherwise throw. Both the base and the final
(async () => { // resolution are guarded so a bad value can never escape appendChild.
try { let baseHref;
if (isModule) { try {
await import(fullUrl); const baseEl = globalThis.document?.querySelector('base[href]');
} else { baseHref = baseEl ? baseEl.getAttribute('href') : null;
const raw = await Deno.core.ops.op_fetch_url(fullUrl, "GET", "{}", "", pageOrigin, "no-cors"); } catch(e) { baseHref = null; }
const parsed = JSON.parse(raw); const docUrl = globalThis.location?.href || 'http://localhost/';
if (parsed.body) { let baseUrl;
globalThis.__currentScriptNid = c._nid; try { baseUrl = baseHref ? new URL(baseHref, docUrl).href : docUrl; }
try { (0, eval)(parsed.body); } catch(e) { baseUrl = docUrl; }
catch(e) { console.error('Dynamic script error (' + fullUrl + '):', e.message); } let fullUrl;
finally { globalThis.__currentScriptNid = prevNid || 0; } try {
} fullUrl = src.startsWith('http') || src.startsWith('data:')
} ? src
if (typeof c.onload === 'function') try { c.onload(new Event('load')); } catch(e) {} : new URL(src, baseUrl).href;
try { c.dispatchEvent(new Event('load')); } catch(e) {} } catch(e) {
} catch(e) { console.error('Dynamic script URL resolve failed (' + src + '):', e.message);
console.error('Dynamic script fetch error:', e.message); fullUrl = src;
if (typeof c.onerror === 'function') try { c.onerror(e); } catch(ex) {} }
} const pageOrigin = (function() { try { return new URL(baseUrl).origin; } catch(e) { return ""; } })();
})(); // Enqueue — serialized via __processDynScriptQueue to prevent
// concurrent import() calls from triggering deno_core RefCell panic.
__dynScriptQueue.push({
url: fullUrl,
isModule,
nid: c._nid,
prevNid,
pageOrigin,
dispatchEvent: (ev) => { try { c.dispatchEvent(ev); } catch(e) {} },
});
__processDynScriptQueue();
} else { } else {
const code = c.textContent; const code = c.textContent;
if (code) { if (code) {
if (isModule) { if (isModule) {
const dataUrl = 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code))); const dataUrl = 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code)));
(async () => { __dynScriptQueue.push({
try { await import(dataUrl); } url: dataUrl,
catch(e) { console.error('Dynamic inline module error:', e.message); } isModule: true,
})(); nid: c._nid,
prevNid,
pageOrigin: "",
dispatchEvent: (ev) => { try { c.dispatchEvent(ev); } catch(e) {} },
});
__processDynScriptQueue();
} else { } else {
globalThis.__currentScriptNid = c._nid; globalThis.__currentScriptNid = c._nid;
try { (0, eval)(code); } try { (0, eval)(code); }