Summary
packages/stx/src/async-components.ts:264-270 fetches a component's HTML and unconditionally writes it to contentEl.innerHTML once the response resolves. There's no AbortController to cancel the in-flight fetch, no check that contentEl is still in the DOM, and no cleanup if the user navigates away or the parent re-renders.
Symptoms:
- Spurious writes to detached DOM nodes.
- Late timeout firing on already-resolved components.
- Memory: closure retains
contentElreference until fetch resolves, even when the component was logically unmounted.
Reproducer
<!-- page-a.stx -->
@async(component: 'SlowChart', timeout: 30000)Navigate to /page-a, immediately navigate to /page-b before the fetch resolves. The SlowChart fetch keeps running. When it resolves, the code at line 268 writes to a contentEl that's no longer attached to the live document.
Root cause
packages/stx/src/async-components.ts:243-270:
// Start timeout timer
timeoutTimer = setTimeout(function() {
if (!resolved) {
handleError(new Error('Component load timeout after ' + timeout + 'ms'));
}
}, timeout);
try {
// Fetch the component
const response = await fetch('/_stx/component/' + encodeURIComponent(componentPath));
if (!response.ok) {
throw new Error('Failed to load component: ' + response.statusText);
}
const html = await response.text();
// Clear timers
clearTimeout(delayTimer);
clearTimeout(timeoutTimer);
resolved = true;
contentEl.innerHTML = html; // <-- no mounted check
setState('resolved');
// ...
}No AbortController, no if (!contentEl.isConnected) return; guard.
Impact
- Detached DOM writes (functionally a no-op but real memory pressure when many such components are in-flight).
- Race between user navigation and slow component loads.
- Late
statechanges on stale DOM. - Especially visible with
timeout: 30000+ on slow networks.
Suggested fix
const controller = new AbortController();
try {
const response = await fetch(
'/_stx/component/' + encodeURIComponent(componentPath),
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error('Failed to load component: ' + response.statusText);
}
const html = await response.text();
clearTimeout(delayTimer);
clearTimeout(timeoutTimer);
if (!contentEl.isConnected) return; // unmounted while fetching
resolved = true;
contentEl.innerHTML = html;
setState('resolved');
// ...
} catch (err) {
if (err.name === 'AbortError') return; // expected on unmount
handleError(err);
}
// Wire abort to component cleanup
if (typeof window.stx?.onDestroy === 'function') {
window.stx.onDestroy(() => {
controller.abort();
clearTimeout(timeoutTimer);
clearTimeout(delayTimer);
});
}Environment
- stx main
- Bun 1.3.13