`s-*` custom elements from the App Home / Polaris web components runtime (`https://cdn.shopify.com/shopifycloud/polaris.js\`) are not garbage-collected
after being removed from the DOM. Their shadow subtrees and event listeners
stay retained indefinitely. `s-clickable` is the worst: ~17 nodes + 1 event
listener retained per element, per create/destroy.
## Environment
- Script: `https://cdn.shopify.com/shopifycloud/polaris.js\`
- CDN `Last-Modified`: Fri, 03 Jul 2026 22:14:53 GMT (build stamp `-26021`
visible in shadow-DOM custom property names, e.g.
`–s-input-field-box-shadow-width-26021`)
- Reproduced in Chrome (headed and headless), macOS 26 (Darwin 25.5.0)
- No framework involved — the repro is a single HTML file using plain DOM APIs
<!doctype html>
<!--
Visual demo: Polaris web components retain their elements + listeners
after removal from the DOM.
Every element created is tracked with a WeakRef after removal. If GC can
reclaim it, "reclaimed" climbs. If the runtime holds a strong reference,
"still in memory" stays pinned at 100%. Plain divs are the control.
For the definitive numbers, DevTools > Performance monitor (DOM Nodes /
JS event listeners) tells the same story. "Apply GC pressure" allocates
and drops ~400 MB to coax a major GC without DevTools.
No framework, no dependencies - just this file + Shopify's CDN script.
-->
<html>
<head>
<meta charset="utf-8" />
<title>Polaris web components — detached element retention demo</title>
<script src="https://cdn.shopify.com/shopifycloud/polaris.js"></script>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px; background: #f1f1f1; color: #303030;
font: 14px/1.45 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
h1 { font-size: 18px; margin: 0 0 4px; }
.sub { color: #616161; margin: 0 0 16px; max-width: 720px; }
.panel {
background: #fff; border: 1px solid #e3e3e3; border-radius: 12px;
padding: 16px; margin-bottom: 16px; max-width: 960px;
}
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
button, select {
font: inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid #8a8a8a;
background: #fff; cursor: pointer;
}
button.primary { background: #303030; color: #fff; border-color: #303030; }
button:disabled { opacity: .5; cursor: default; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: right; padding: 10px 12px; border-bottom: 1px solid #ebebeb; }
th:first-child, td:first-child { text-align: left; }
th { color: #616161; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
td.big { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
.retained { color: #8e0b21; }
.reclaimed { color: #047b5d; }
.muted { color: #b5b5b5; }
.heap { display: flex; align-items: baseline; gap: 12px; margin-bottom: 8px; }
.heap b { font-size: 26px; font-variant-numeric: tabular-nums; }
canvas { width: 100%; height: 120px; display: block; }
#status { color: #616161; min-height: 1.4em; margin-top: 8px; }
.work { position: fixed; left: -10000px; top: 0; }
</style>
</head>
<body>
<h1>Polaris web components — detached element retention</h1>
<p class="sub">
Each cycle creates the selected markup, waits two frames, removes it, and keeps only a
<code>WeakRef</code> to every element. Garbage-collectable elements move to
<b class="reclaimed">reclaimed</b>; elements the runtime still references stay
<b class="retained">still in memory</b> forever. Plain divs are the control.
</p>
<div class="panel">
<div class="controls">
<select id="candidate">
<option value="clickable">s-clickable Ă— 40</option>
<option value="button">s-button Ă— 12</option>
<option value="select">s-select + 20 options</option>
<option value="popover">s-popover + invoker</option>
<option value="div">plain div Ă— 40 (control)</option>
</select>
<button id="run" class="primary">Run 20 cycles</button>
<button id="auto">Auto-run: off</button>
<button id="gc">Apply GC pressure</button>
</div>
<div id="status"></div>
</div>
<div class="panel">
<table id="stats">
<thead>
<tr>
<th>Markup</th><th>Elements removed</th>
<th>Still in memory</th><th>Reclaimed by GC</th><th>Retention</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="panel">
<div class="heap">JS heap <b id="heapNow">–</b><span class="muted" id="heapNote">(performance.memory, sampled every 500 ms)</span></div>
<canvas id="chart" width="1856" height="240"></canvas>
</div>
<div id="work" class="work"></div>
<script>
const MARKUP = {
clickable: { label: 's-clickable Ă— 40', html: '<s-clickable paddingBlock="small-400" paddingInline="small-300">x</s-clickable>'.repeat(40) },
button: { label: 's-button Ă— 12', html: '<s-button accessibilityLabel="x" icon="text-bold" variant="tertiary"></s-button>'.repeat(12) },
select: { label: 's-select + 20 options', html: '<s-select label="Font">' + Array.from({ length: 20 }, (_, i) => `<s-option value="${i}">${i}</s-option>`).join('') + '</s-select>' },
popover: { label: 's-popover + invoker', html: '<s-button commandfor="pop-x" command="--show">open</s-button><s-popover id="pop-x">hi</s-popover>' },
div: { label: 'plain div Ă— 40 (control)', html: '<div>x</div>'.repeat(40) },
}
// One WeakRef per removed element, grouped by candidate. Nothing here
// holds the elements alive - if they stay, the Polaris runtime holds them.
const tracked = Object.fromEntries(Object.keys(MARKUP).map((k) => [k, []]))
const work = document.getElementById('work')
const raf = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
async function runCycle(kind) {
const host = document.createElement('div')
host.innerHTML = MARKUP[kind].html
work.appendChild(host)
await raf()
for (const el of host.querySelectorAll('*')) tracked[kind].push(new WeakRef(el))
host.remove()
await raf()
}
let running = false
async function runCycles(kind, n) {
if (running) return
running = true
const btn = document.getElementById('run')
btn.disabled = true
for (let i = 0; i < n; i++) {
await runCycle(kind)
status(`${MARKUP[kind].label}: cycle ${i + 1}/${n}`)
}
status(`${MARKUP[kind].label}: done - now "Apply GC pressure" (or DevTools > Memory > collect garbage)`)
btn.disabled = false
running = false
}
document.getElementById('run').onclick = () =>
runCycles(document.getElementById('candidate').value, 20)
let auto = false, autoTimer = null
document.getElementById('auto').onclick = (e) => {
auto = !auto
e.target.textContent = `Auto-run: ${auto ? 'on' : 'off'}`
clearInterval(autoTimer)
if (auto) autoTimer = setInterval(() => {
if (!running) runCycles(document.getElementById('candidate').value, 5)
}, 400)
}
// Allocate and drop ~400 MB so Chrome runs a major GC without DevTools.
document.getElementById('gc').onclick = async () => {
status('applying GC pressure…')
for (let i = 0; i < 8; i++) {
let junk = new Array(6_000_000).fill(i)
junk = null
await new Promise((r) => setTimeout(r, 60))
}
status('GC pressure applied - watch "reclaimed"')
}
function status(msg) { document.getElementById('status').textContent = msg }
// ---- live stats table
const tbody = document.querySelector('#stats tbody')
function renderStats() {
tbody.innerHTML = Object.entries(tracked).map(([kind, refs]) => {
const total = refs.length
const alive = refs.reduce((n, r) => n + (r.deref() !== undefined ? 1 : 0), 0)
const pct = total ? Math.round((alive / total) * 100) : 0
const cls = total === 0 ? 'muted' : ''
return `<tr class="${cls}">
<td>${MARKUP[kind].label}</td>
<td class="big">${total.toLocaleString()}</td>
<td class="big retained">${alive.toLocaleString()}</td>
<td class="big reclaimed">${(total - alive).toLocaleString()}</td>
<td class="big ${total && pct === 100 ? 'retained' : ''}">${total ? pct + '%' : '–'}</td>
</tr>`
}).join('')
}
// ---- heap sparkline
const samples = []
const canvas = document.getElementById('chart')
const ctx = canvas.getContext('2d')
function sampleHeap() {
const m = performance.memory
if (!m) { document.getElementById('heapNote').textContent = '(performance.memory unavailable in this browser)'; return }
const mb = m.usedJSHeapSize / 1048576
samples.push(mb)
if (samples.length > 360) samples.shift()
document.getElementById('heapNow').textContent = mb.toFixed(1) + ' MB'
const w = canvas.width, h = canvas.height
const min = Math.min(...samples), max = Math.max(...samples)
const span = Math.max(max - min, 5)
ctx.clearRect(0, 0, w, h)
ctx.strokeStyle = '#e3e3e3'
ctx.strokeRect(0.5, 0.5, w - 1, h - 1)
ctx.beginPath()
samples.forEach((v, i) => {
const x = (i / 359) * (w - 8) + 4
const y = h - 8 - ((v - min) / span) * (h - 16)
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)
})
ctx.strokeStyle = '#8e0b21'
ctx.lineWidth = 3
ctx.stroke()
ctx.lineWidth = 1
}
setInterval(() => { renderStats(); sampleHeap() }, 500)
renderStats()
// hook for automated verification
window.__stats = () => Object.fromEntries(Object.entries(tracked).map(([kind, refs]) => {
const total = refs.length
const alive = refs.reduce((n, r) => n + (r.deref() !== undefined ? 1 : 0), 0)
return [kind, { total, alive }]
}))
</script>
</body>
</html>
## Steps to reproduce
Visual version: open the attached `polaris-leak-demo.html`, click
“Run 20 cycles” for `s-clickable × 40`, then for the plain-div control, then
“Apply GC pressure”. Every removed element is tracked with a `WeakRef`:
the control drops to ~0% still-in-memory, `s-clickable` stays at 100%.
DevTools version:
1. Open the attached `polaris-leak-repro.html` in Chrome.
2. Open DevTools → Performance monitor (watch “DOM Nodes” and
“JS event listeners”).
3. Pick a component in the dropdown and click “Run 20 cycles”. Each cycle
appends the markup, waits two frames, and removes it.
4. Force GC (DevTools → Memory → “Collect garbage”).
## Actual result
Counters climb linearly with cycles and never recover, even after forced GC.
Measured via CDP `Memory.getDOMCounters` after two forced GC passes
(20 cycles each, growth shown per cycle):
The plain-div control shows the harness itself retains nothing.
(`measure.mjs` automates this table: `npm i playwright-core`, point
`CHROME` at a local Chrome binary, `node measure.mjs` from this folder.)
## Expected result
Removed elements become collectable after GC, like the plain-div control.
## Impact
Any embedded app that renders `s-*` components inside React/Vue lists or
conditionals accumulates memory for the lifetime of the tab — every re-render
that destroys and recreates an element strands its shadow subtree. In our app
(a Shopify embedded app), a rich-text-editor toolbar built from `s-*`
components strands ~490 nodes / ~98 listeners / ~0.27 MB per unmount, and
search-filtering a list of `s-clickable` rows strands nodes on every
keystroke. Growth is unbounded and GC-proof; long merchant sessions degrade.
## Suspected cause
The retained-listener counts match one per destroyed element exactly for
`s-clickable`, which suggests a document-level or module-global registry
(interaction/command layer?) holding strong references to disconnected
elements. Worth checking `disconnectedCallback` cleanup, or switching such
registries to weak references.