Chromium: responsive <img> in an {#if}/snippet-only fragment is retained forever — template-document clone adopted into a page-document comment() fragment and back
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:
from_htmlclones markup from<template>.contentwithnode.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 setsrc/srcset/sizesthere; Chromium'sSelectSourceURLearly-returns for an inactive document → no listener yet.- The component is appended before its anchor. For anchor-only fragments that anchor lives in
document.createDocumentFragment()—comment()attemplate.js:342,boundary.js:266/317— i.e. the page document M. Adoption T→M →HTMLImageElement::DidMoveToNewDocument→SelectSourceURL→ listener created and registered in M's matcher. - That M fragment is appended into the parent's not-yet-connected T clone. Chromium's
RemovedFromunregisters M, thenInsertedIntodoesif (listener_) GetDocument().GetMediaQueryMatcher().AddViewportListener(listener_)with no active-document check → registered in T's matcher. - The parent tree enters the page: root adoption T→M registers M again. Nothing unregisters T.
- Unmount →
RemovedFromunregisters 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)):
<!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) → MediaQueryMatcher → HeapHashTableBacking → ViewportChangeListener → element_ = 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'):
<!-- 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).
Source: sveltejs/svelte