Polaris web components leak detached DOM nodes + event listeners on element removal

`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.

Thanks for the detailed report and repro. We’ve confirmed this is a real issue, though the shadow subtree and its listeners are actually cleaned up on removal – it’s the empty host element that lingers, and only on pages where nothing calls document.addEventListener after the elements are removed.

Workaround until the fix ships: the set gets flushed by any document.addEventListener call, so a no-op document.addEventListener('noop', () => {}) after tearing down elements will let them be garbage-collected.

We’ve got a fix in the works internally. Thanks again for flagging it.