Prevent WebUI extension loading stalls

Serialize extension discovery requests to avoid exhausting embedded Chromium request resources. Always clear component loading placeholders after failures and add focused regression coverage.

Alessandro committed Jul 17, 2026 at 16:57 UTC bf2046ff8704b4f70065226b96059795ae257fe7
4 files changed +43 -14
tests/test_webui_loader_static.py new
+19
@@ -0,0 +1,19 @@
1 +from pathlib import Path
2 +
3 +
4 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
5 +
6 +
7 +def test_extension_discovery_requests_are_serialized() -> None:
8 + source = (PROJECT_ROOT / "webui/js/extensions.js").read_text(encoding="utf-8")
9 +
10 + assert "let extensionRequestQueue = Promise.resolve();" in source
11 + assert "extensionRequestQueue = request.catch(() => {});" in source
12 + assert source.count("await requestExtensionPaths(") == 2
13 +
14 +
15 +def test_component_placeholder_is_removed_after_failure() -> None:
16 + source = (PROJECT_ROOT / "webui/js/components.js").read_text(encoding="utf-8")
17 + finally_block = source.rsplit("} finally {", maxsplit=1)[1]
18 +
19 + assert "targetElement.querySelector(':scope > .loading')?.remove();" in finally_block
webui/js/AGENTS.md
+2
@@ -37,8 +37,10 @@
37 - `scrollModal(id)` scrolls inside the top modal's `.modal-scroll`.
38 - Keep extension loader cache keys and extension point names stable for plugins.
39 - HTML extension loading turns discovered HTML files into `<x-component>` tags; JavaScript extensions must export a default function.
40 +- Serialize frontend extension-discovery API requests so a page with many extension points cannot exhaust embedded Chromium request resources.
41 - `<x-component>` loading must process component `style`, `script`, and stylesheet-link assets only once, even when a component keeps its scoped `<style>` inside `<body>`.
42 - Every `<x-component>` instance must await cached module-load promises before markup is appended so Alpine bindings only run after imported stores exist.
43 +- Every `<x-component>` loading placeholder must be removed after success or failure; a failed asset must never leave a perpetual shimmer.
44 - Frontend extension hooks such as `confirm_dialog_after_render` and `get_tool_message_handler` must preserve their mutable context contracts.
45 - Sanitize or safely render user/model-provided HTML and markdown.
46 - Convert standard TeX delimiters before Markdown parsing without touching inline or fenced code. Keep thought-card math rendering local to the agent-message handler rather than adding math flags to generic process-step or key/value rendering.
webui/js/components.js
+1 -6
@@ -170,12 +170,6 @@ export async function importComponent(path, targetElement) {
170 targetElement.appendChild(deferred);
171 }
172
173 - // Remove loading indicator
174 - const loadingEl = targetElement.querySelector(':scope > .loading');
175 - if (loadingEl) {
176 - targetElement.removeChild(loadingEl);
177 - }
178 -
173 // // Load any nested components
174 // await loadComponents([targetElement]);
175
@@ -185,6 +179,7 @@ export async function importComponent(path, targetElement) {
179 console.error("Error importing component:", error);
180 throw error;
181 } finally {
182 + targetElement.querySelector(':scope > .loading')?.remove();
183 // Release the lock when done, regardless of success or failure
184 importLocks.delete(lockKey);
185 }
webui/js/extensions.js
+21 -8
@@ -20,6 +20,7 @@ import * as cache from "./cache.js";
20
21 const JS_CACHE_AREA = "frontend_extensions_js(extensions)(plugins)";
22 const HTML_CACHE_AREA = "frontend_extensions_html(extensions)(plugins)";
23 +let extensionRequestQueue = Promise.resolve();
24
25 export const API_EXTENSION_EXCLUDED_ENDPOINTS = new Set([
26 "/api/load_webui_extensions",
@@ -30,6 +31,17 @@ export function clearCache() {
31 cache.clear(HTML_CACHE_AREA);
32 }
33
34 +function requestExtensionPaths(extensionPoint, filters) {
35 + const request = extensionRequestQueue.then(() =>
36 + api.callJsonApi(`/api/load_webui_extensions`, {
37 + extension_point: extensionPoint,
38 + filters,
39 + }),
40 + );
41 + extensionRequestQueue = request.catch(() => {});
42 + return request;
43 +}
44 +
45 /**
46 * Call all JS extensions for a given extension point.
47 *
@@ -60,10 +72,10 @@ export async function loadJsExtensions(extensionPoint) {
72 if (cached != null) return cached;
73
74 /** @type {LoadWebuiExtensionsResponse} */
63 - const response = await api.callJsonApi(`/api/load_webui_extensions`, {
64 - extension_point: extensionPoint,
65 - filters: ["*.js", "*.mjs"],
66 - });
75 + const response = await requestExtensionPaths(extensionPoint, [
76 + "*.js",
77 + "*.mjs",
78 + ]);
79 /** @type {JsExtensionImport[]} */
80 const imports = await Promise.all(
81 response.extensions.map(async (path) => ({
@@ -167,10 +179,11 @@ export async function importHtmlExtensions(extensionPoint, targetElement) {
179 }
180
181 /** @type {LoadWebuiExtensionsResponse} */
170 - const response = await api.callJsonApi(`/api/load_webui_extensions`, {
171 - extension_point: extensionPoint,
172 - filters: ["*.html", "*.htm", "*.xhtml"],
173 - });
182 + const response = await requestExtensionPaths(extensionPoint, [
183 + "*.html",
184 + "*.htm",
185 + "*.xhtml",
186 + ]);
187 let combinedHTML = "";
188 for (const extension of response.extensions) {
189 const path = normalizePath(extension);