sveltejs / svelte · Issue No. 18608
When an element receives spread attributes ({...attrs}) and one of the spread keys is a non-delegated on* handler whose value toggles between a function and undefined, Svelte attaches a real addEventListener and then fails to remove it. One listener leaks per toggle cycle, unbounded for the element's lifetime, and every leaked listener throws TypeError: Cannot read properties of undefined (reading 'call') on each subsequent event.
Capture-phase handlers are the common way to hit this, because can_delegate_event() is called with the un-stripped name — onclickcapture → 'clickcapture', which is not in DELEGATED_EVENTS — so a capture click handler is never delegated even though plain click is.
set_attributes keeps the attached listener in a $$-prefixed sibling key of the same long-lived current bag:
const event_handle_key = '$$' + key; // '$$onclickcapture'
function handle(evt) { current[key].call(this, evt); } // re-reads the LIVE value at event time
current[event_handle_key] = create_event(event_name, element, handle, opts);
The normalisation loop at the top of every call does not skip $$ keys:
for (var key in prev) {
if (!(key in next)) {
next[key] = null; // ← injects next['$$onclickcapture'] = null
}
}
A consumer's spread never contains $$onclickcapture, so once the listener exists every later pass injects that null. The main loop then reaches the key and clears the slot before the guard meant to protect it:
current[key] = value; // current['$$onclickcapture'] = null — reference GONE
var prefix = key[0] + key[1];
if (prefix === '$$') continue; // one line too late
The removal branch later calls element.removeEventListener(event_name, current[event_handle_key], opts) with null, which the DOM spec defines as a no-op. The listener stays attached forever.
Enumeration order matters: the normalisation loop appends $$onclickcapture to next, so for (const key in next) reaches onclickcapture first and the $$ key last. That is why the very first cycle can still remove correctly — see the trace below.
Repro.svelte:
<script>
let { disabled = false, tag = '' } = $props();
function block(event) {
event.preventDefault();
event.stopPropagation();
}
const attrs = $derived({
'data-tag': tag,
onclickcapture: disabled ? block : undefined
});
</script>
<button {...attrs}>click me</button>
Driver (vitest + jsdom). The listener registry is keyed on the full DOM quadruple {target, type, capture, listener}. This is essential: the defect is a removeEventListener('click', null, {capture:true}), so a registry that omits the callback from its key would match that null-callback removal against a live entry, decrement, and report zero leaks.
import { describe, it, expect } from 'vitest';
import { mount, unmount, flushSync } from 'svelte';
import Repro from './Repro.svelte';
function run(N) {
const origAdd = EventTarget.prototype.addEventListener;
const origRemove = EventTarget.prototype.removeEventListener;
const live = [];
const capOf = (o) => (typeof o === 'boolean' ? o : !!(o && o.capture));
const match = (e, self, type, listener, capture) =>
e.target === self && e.type === type && e.capture === capture && e.listener === listener;
EventTarget.prototype.addEventListener = function (type, listener, opts) {
const capture = capOf(opts);
if (!live.some((e) => match(e, this, type, listener, capture))) {
live.push({ target: this, type, capture, listener });
}
return origAdd.call(this, type, listener, opts);
};
EventTarget.prototype.removeEventListener = function (type, listener, opts) {
const capture = capOf(opts);
const i = live.findIndex((e) => match(e, this, type, listener, capture));
if (i !== -1) live.splice(i, 1);
return origRemove.call(this, type, listener, opts);
};
const target = document.createElement('div');
document.body.appendChild(target);
const props = $state({ disabled: false, tag: 'a' });
const app = mount(Repro, { target, props });
flushSync();
const el = target.querySelector('button');
for (let i = 0; i < N; i++) {
props.disabled = true;
flushSync();
props.tag = 'tick' + i; // any other spread attribute changing; onclickcapture unchanged
flushSync();
props.disabled = false;
flushSync();
}
const listeners = live.filter(
(e) => e.target === el && e.type === 'click' && e.capture === true
).length;
const errors = [];
const onError = (e) => errors.push(e?.message ?? String(e));
window.addEventListener('error', onError);
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
window.removeEventListener('error', onError);
unmount(app);
target.remove();
EventTarget.prototype.addEventListener = origAdd;
EventTarget.prototype.removeEventListener = origRemove;
return { N, listeners, errors: errors.length, sample: errors[0] ?? null };
}
describe('spread capture-listener leak', () => {
it('leaks one capture-click listener per disabled cycle', () => {
const results = [0, 1, 5, 10].map(run);
expect(results.map((r) => r.listeners)).toEqual([0, 1, 5, 10]);
expect(results.map((r) => r.errors)).toEqual([0, 1, 5, 10]);
});
});
| N cycles | live capture-click listeners | errors on one subsequent click |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 1 | 1 |
| 5 | 5 | 5 |
| 10 | 10 | 10 |
Every error is TypeError: Cannot read properties of undefined (reading 'call'):
TypeError: Cannot read properties of undefined (reading 'call')
at HTMLButtonElement.handle (svelte/src/internal/client/dom/elements/attributes.js:427:19)
at svelte/src/internal/client/dom/elements/events.js:67:21
at without_reactive_context (svelte/src/internal/client/dom/elements/bindings/shared.js:45:10)
at HTMLButtonElement.target_handler (svelte/src/internal/client/dom/elements/events.js:66:11)
Live capture-click count recorded after every flush, N = 3:
with an intervening pass:
mount:0 | c0.disabled=true:1 | c0.otherAttr:1 | c0.disabled=false:1
| c1.disabled=true:2 | c1.otherAttr:2 | c1.disabled=false:2
| c2.disabled=true:3 | c2.otherAttr:3 | c2.disabled=false:3 → N
without an intervening pass (bare true/false flip):
mount:0 | c0.disabled=true:1 | c0.disabled=false:0
| c1.disabled=true:1 | c1.disabled=false:1
| c2.disabled=true:2 | c2.disabled=false:2 → N − 1
Both shapes leak; only the first cycle differs. On cycle 0 of a bare flip prev has no $$onclickcapture key yet, so the normalisation loop injects nothing and the removal reads a still-live slot. From cycle 1 on, the slot is re-injected and nulled inside the same pass that attached the listener.
Skip $$ keys in the normalisation loop, or move the existing $$ guard above the assignment:
for (var key in prev) {
- if (!(key in next)) {
+ if (!(key in next) && !(key[0] === '$' && key[1] === '$')) {
next[key] = null;
}
}
main re-checked 2026-08-02 and still unfixed: the last commit touching packages/svelte/src/internal/client/dom/elements/attributes.js is 378bb25097088c2277aa063408c62818cc1f6c4e (2026-05-31, "fix: set input type before spread value (#18345)"), which predates the 5.56.8 release. The relevant lines are byte-identical to the installed copy.TypeError on the compiled, non-spread path (fixed by #15087)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.