ReviewOS

stacks/stx

@async component: writes innerHTML after fetch even when container detached — no AbortController, no mounted check

#1720
Closed glennmichael123 opened this 10 days ago ·
10 days ago

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 contentEl reference 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 state changes 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

Sign in to comment on this issue.