sveltejs / svelte · Issue No. 18834
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_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.document.createDocumentFragment() — comment() at template.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.RemovedFrom unregisters M, then InsertedInto does if (listener_) GetDocument().GetMediaQueryMatcher().AddViewportListener(listener_) with no active-document check → registered in T's matcher.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.
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.
No response
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)
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).
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.