#18834·svelte

Chromium: responsive <img> in an {#if}/snippet-only fragment is retained forever — template-document clone adopted into a page-document comment() fragment and back

Author: rickryan26-BotCreated Sep 16, 2026Updated Sep 16, 2026

Describe the bug

In Chromium, every client-rendered <img srcset sizes> (or <picture> with <source srcset>) whose component is mounted inside an anchor-only fragment — an {#if}/{:else} branch or snippet whose body is just a component, <svelte:boundary> pending/offscreen content — is retained forever after unmount, together with its whole detached subtree. On a SvelteKit e-commerce app this pinned ~4.7k DOM nodes per visited product page until the tab OOM'd.

The retainer is native: Chromium registers a document-owned HTMLImageElement::ViewportChangeListener (strong Member<HTMLImageElement>) in a document's MediaQueryMatcher for every viewport-dependent image. Svelte's fragment assembly bounces a subtree between two documents before it reaches the page, and Chromium ends up with a registration in the <template> content document's matcher that nothing ever removes:

  1. from_html clones markup from <template>.content with node.cloneNode(true) (src/internal/client/dom/template.js:78, non-Firefox path) → the clone is owned by the inert template content document T. Attribute effects set src/srcset/sizes there; Chromium's SelectSourceURL early-returns for an inactive document → no listener yet.
  2. The component is appended before its anchor. For anchor-only fragments that anchor lives in document.createDocumentFragment()comment() at template.js:342, boundary.js:266/317 — i.e. the page document M. Adoption T→M → HTMLImageElement::DidMoveToNewDocumentSelectSourceURL → listener created and registered in M's matcher.
  3. That M fragment is appended into the parent's not-yet-connected T clone. Chromium's RemovedFrom unregisters M, then InsertedInto does if (listener_) GetDocument().GetMediaQueryMatcher().AddViewportListener(listener_) with no active-document check → registered in T's matcher.
  4. The parent tree enters the page: root adoption T→M registers M again. Nothing unregisters T.
  5. Unmount → RemovedFrom unregisters M only. T is owned by M for the lifetime of the page (Document::template_document_), so T's matcher retains the listener → the <img> → (parent pointers) the entire removed subtree.

It is a Chromium bug too (DidMoveToNewDocument should unregister from the old document; reported separately with the plain-DOM repro below), but Svelte is the only framework I know of that adopts a subtree M→T→M during assembly: template clones come from T while comment()/boundary fragments come from M. Firefox is unaffected (importNode path); Chromium 151.0.7922.34 reproduces (also the version of the reporting user's Chrome).

Suggested fix: keep everything in one document until the final insertion — create comment()'s and the boundary's offscreen fragments from the template document (e.g. clone a cached <template>-content fragment) so the subtree is adopted exactly once at the real insertion; or use document.importNode unconditionally (the existing is_firefox branch) — Chromium then never pays the later per-node adoption walk, so it is unlikely to be slower than cloneNode + adoption.

Reproduction

Plain-DOM sequence, identical to what Svelte's compiled output does for {#if x}<Picture/>{/if} inside a template-cloned parent (var fragment = $.comment(); Picture(...); $.append($$anchor, fragment)):

template-document-viewport-listener-leak.html
xml
<!doctype html><meta charset="utf-8"><pre id="log"></pre>
<script>
const log = (s) => (document.getElementById('log').textContent += s + '\n');
const SIZES = '(min-width: 1280px) 640px, 100vw';
// 1. clone from <template>.content (owned by the template content document T) — Svelte from_html
const tpl = document.createElement('template');
tpl.innerHTML = '<div><picture><source type="image/webp"><img loading="lazy" width="400" height="300"></picture></div>';
const clone = tpl.content.firstChild.cloneNode(true);
const img = clone.querySelector('img'), source = clone.querySelector('source');
source.srcset = 'a.png 400w, b.png 800w'; source.sizes = SIZES;
img.srcset = 'a.png 400w, b.png 800w'; img.sizes = SIZES; img.src = 'a.png';
// 2. appended before an anchor in a PAGE-document fragment — Svelte comment()
const frag = document.createDocumentFragment(); const anchor = document.createComment('');
frag.append(anchor); anchor.before(clone);          // T→page: listener created, registered in the page
// 3. that fragment goes into the parent's not-yet-connected template clone
const tpl2 = document.createElement('template'); tpl2.innerHTML = '<div class="parent"><!----></div>';
const parent = tpl2.content.firstChild.cloneNode(true);
parent.firstChild.before(frag);                     // page→T: InsertedInto registers in T's matcher
// 4. into the page
document.body.append(parent);                       // T→page: registered in the page again; T never cleared
// 5. remove
requestAnimationFrame(() => { parent.remove(); log('done — take a heap snapshot'); });
</script>

Steps: open the file in Chrome, wait for "done", DevTools → Memory → heap snapshot (or HeapProfiler.collectGarbage ×3 first), search ViewportChangeListener. One listener is retained via the second HTMLDocument (the template content document; its template_document_host_ is the page) → MediaQueryMatcherHeapHashTableBackingViewportChangeListenerelement_ = the removed <img>, whose parent chain reaches the removed <div class="parent">. Skip step 3 (append the fragment straight into the page) or create the <img> with document.createElement and it is collected.

Svelte shape that produces this (compiled with 5.57.0, default fragments: 'html'):

svelte
<!-- Picture.svelte -->
<picture>
  <source type="image/webp" srcset="a.png 400w, b.png 800w" sizes="100vw" />
  <img src="a.png" srcset="a.png 400w, b.png 800w" sizes="100vw" alt="" />
</picture>

<!-- App.svelte -->
<script> import Picture from './Picture.svelte'; let show = $state(true); </script>
<button onclick={() => (show = !show)}>toggle</button>
{#if show}
  <div class="slide">
    {#if true}<Picture />{/if}   <!-- anchor-only branch → $.comment() fragment -->
  </div>
{/if}

Toggle N times, GC, snapshot: N <img>s (and their <div class="slide"> subtrees) stay reachable from the template content document's MediaQueryMatcher. In the real app I instrumented every insertion that adopts an <img srcset> from the page document into a template-document parent: 16 such events per navigation walk = exactly the 16 leaked images (SSR-hydrated images never go through a clone and were clean).

Note for anyone measuring this: a WeakRef to the <img> reports it collected — Chromium lets the JS wrapper die while the C++ node lives on in the matcher — so count ViewportChangeListeners in a heap snapshot, not WeakRefs.

Logs

No response

System Info

svelte 5.57.0 (main is identical: template.js:78/342, boundary.js:266/317)
Chromium 151.0.7922.34 (Playwright) and Chrome stable on the affected users' machines
Firefox: not affected (importNode path)

Severity

annoyance — but on a long single-page browse it is a tab OOM; every client-rendered responsive image leaks its subtree until reload. Worked around in-app by withholding srcset/sizes until onMount (a src-only <img> is not viewport-dependent, so nothing registers during assembly).