Description
When using {@attach} to register a click listener via on(node, 'click') from svelte/events, writing to a $state variable inside the handler causes $effect to flush synchronously via a microtask checkpoint during event propagation. If the $effect sets node.disabled = true, this happens before the event bubbles to the document root where Svelte 5's delegated onclick handler lives — causing onclick to be silently skipped.
This is 100% reproducible, not intermittent.
Reproduction
Playground: https://svelte.dev/playground/hello-world?version=5.55.1#H4sIAAAAAAAAE4VVzW7jNhB-lYEawHbWayXZmyKrzabpbREgXaCHKEAkamSzoUmBHNmbGgJ66gMUfcJ9koIUJcuO0t4kcubjN_PNzz6Q2QaDKPhcryI4M5QRwk5zQuASfsqIMrYGJjh7AZbVBg2cYVkiIyhFbdaQY6k0QoECVxlhAUo662AelFygCaLHfUCvlX3DHgTz7sWbqlqYLQqyZ3lmcOycKUkoyQRREBumeUVJKlPim0ppgj3QWisigdBAqdUGJouwO_Igi9_N5DqV1ksgdfRuVS0Jlj7i6cXsujNoQx69lymVtWTElQQlb5zhdAZ7e5HSwPHDBwfXnPisM1kIvLXvH9yGhLxfSkxJowQuhFpNnx-9yROUXGMBP5zth07N86x7LQ4POZLx-ipxqsZMFZi0kcSh-zlI3F7un_de6-a56Wxa1VvSGgpuslyggbwmUvKt8C2SZ-Yx4nB91ZKpEhc30BoPCELtFnD3rUJGWESQK1oDszGhNsAl07hBSQu4YVRnIgIlxat_x9PtubZeByczHyUE3ECcJxK3qOMwT4BlQmCxiMOq5em5denoC2zaCT5ruhpa7geCNmDoVeAyDaqsKLhcRXCJG7jCzTWUStJHw__ACC4X7oTV2igdQaW4pX2dBq6sY1Nl0idqg3Hofq2sLSsv66fkAU0tyMTh-pO9rkXrLXgyGrNLTgSxIa3kKjmunjj0x3Eo-AnOWJIHOIOKfwfmCzfGKutd-hwxJWz4QwT4eNybCVzAjzDRWEwggslKI8pJkwbJfzi9YRGHbW5c1m7bpgLSGcMud1VyX6GEn3H7VSlhgHVGCgyiK1fcorTjTu2itkxidZrwx5bTEyg5larAeds8sy5ztnHNSIJ7v26ofv_r767TClgC6bpv2ThP3MhFA3yzwYJnhOLVFfF0w5lWlJkXYGtkL66sIEfaoY3t_gtYUmY2xqAbLiMNAkKtVli4J77_-Q_86gbqxAyavpsOBtH4hjvQXx4HkMkCzAuvDHDq9VEiCeYB4TcKImvczN9ZF2_H-vHaGL0frI9-ZSjZL4vWNHQC-y0Rnp-nEs7hQSlqN14ERa25XPk6qLSqslVmB_occq12xg4rXUvIYEwGh9ZJIbghlNZBSSh4WaK2mL1CC_hNc7KP-VXM5WBiTowDO57LpPlqZRG7EtryAZE5GCQHaPEXJ6Xl4D7f_XL_cDeo9LzO3aAnBYVitZ2msFujxrEC8OUDgm_RLCxgmEr85nLdL75-iNppm2fsZQ4bA0u4vLi48ItQI9VaguueGSyTbj3aldy5F4eFXGbCYLeU231JoMoSlocWnDhmkzlMh4DHy3XyXuf6XWvLvsugl2TIxiZxMrvukX1408PRqXV_YZC-8g2qmqYtv-nQtI3PpqmDag7ReqWnJ3HxcoAxO5y_F_Fw5oxVxyCwlMYMBtedfMeM_v9tJjCTdTXKweXgiMRbGs5mYNH0382btB1xVGXpVbIuzfEMepoHlHGx47IIIvdE8y9gHptRKQsAAA
<!-- App.svelte -->
<script>
import { throttle } from "./throttle.svelte.js";
let onclickCount = $state(0);
let attachCount = $state(0);
function onAttach() {
attachCount++;
}
function handleClick() {
onclickCount++;
console.log(`[onclick] fired #${onclickCount}`);
}
</script>
<button {@attach throttle(onAttach)} onclick={handleClick}>
<span>Click me</span>
</button>
<ul>
<li>onclick count: <strong>{onclickCount}</strong></li>
<li>@attach count: <strong>{attachCount}</strong></li>
<li>Missed: <strong>{attachCount - onclickCount}</strong></li>
</ul>
// throttle.svelte.js
import { on } from "svelte/events";
export function throttle(callback, ms = 3000) {
return node => {
let throttled = $state(false);
const off = on(node, "click", () => {
console.log("[attach] on(node, click) fired");
callback();
throttled = true;
setTimeout(() => (throttled = false), ms);
});
$effect(() => {
if (throttled) {
console.log("[attach] $effect → node.disabled = true");
node.disabled = true;
return () => {
console.log("[attach] $effect cleanup → node.disabled = false");
node.disabled = false;
};
}
});
return () => off();
};
}
Expected Behavior
onclick handler fires on every click. The @attach throttle disables the button after the current event is fully dispatched.
Actual Behavior
onclick is never called. The $effect setting node.disabled = true flushes during event propagation — before the event reaches the document root where Svelte 5's delegated onclick handler lives.
Root Cause (confirmed via Chrome DevTools debugging)
The event flow with console instrumentation
[debug-capture] button.disabled: false ← document capture phase
[throttle] on(node, click) fired ← @attach handler on button (target/bubble phase)
[throttle] $effect → disabled = true ← $effect flushes HERE via microtask checkpoint!
[debug-bubble] button.disabled: true ← document bubble phase — too late, already disabled
← Svelte's delegated onclick SKIPPED
[throttle] $effect cleanup ← later: setTimeout restores button
Why this happens
The mechanism is a browser-level microtask checkpoint between event listeners on different DOM nodes during event propagation:
- User clicks
<span> inside <button>
- Event bubbles to
<button> → on(node, 'click') handler fires → writes $state(throttled = true) → Svelte schedules $effect as a microtask
- Browser performs microtask checkpoint (JS call stack empties between listeners on
<button> and document) → microtask runs → $effect flushes → node.disabled = true
- Event continues bubbling to
document → Svelte's delegated onclick handler checks event.target → finds button is disabled → skips onclick
Key insight: queueMicrotask does NOT help
We tested wrapping the $state write in queueMicrotask():
on(node, "click", () => {
queueMicrotask(() => {
throttled = true; // still flushes before onclick!
});
});
The microtask checkpoint between DOM nodes executes this callback before the event reaches document. Confirmed via console logs:
[throttle] raw addEventListener fired
[throttle] queueMicrotask callback → setting throttled = true ← runs BEFORE document bubble!
[throttle] $effect → disabled = true
[debug-bubble] button.disabled: true ← too late
Workaround: setTimeout(0) works
Only a macrotask (setTimeout(0)) defers the $state write past the entire event dispatch:
- Use setTimeout(0) to delay writing $state to the macrotask
- Reason: During event bubbling, microtask checkpoints are triggered between listeners on different DOM nodes.
- Writing to $state directly or via queueMicrotask will cause $effect to flush synchronously.
- Set disabled = true before the event bubbles up to the document.
- This causes the onclick event delegated to the document in Svelte 5 to be skipped.
on(node, "click", () => {
setTimeout(() => {
throttled = true; // runs after all event listeners complete
}, 0);
});
Console output confirms onclick fires:
[debug-capture] button.disabled: false
[throttle] click fired
[onclick] onCopy fired ← ✅ onclick fires!
[debug-bubble] button.disabled: false ← ✅ still enabled during bubble
[throttle] setTimeout(0) → throttled=true
[throttle] $effect → disabled = true ← correctly delayed
Analysis
| Approach |
$state write timing |
$effect flush timing |
onclick fires? |
| Synchronous write |
Immediate |
Microtask checkpoint (between button→document) |
No |
queueMicrotask |
Microtask |
Same microtask checkpoint |
No |
setTimeout(0) |
Next macrotask |
After event dispatch |
Yes |
The fundamental issue: Svelte 5's $effect scheduling (microtask-based) is incompatible with its own event delegation (document-level) when $state is written inside event handlers on child elements. The browser's microtask checkpoint between DOM nodes during event propagation creates a window where $effect flushes and mutates the DOM before the delegated handler processes the event.
Suggested Fixes
- Defer
$effect DOM mutations during active event dispatch — detect when an event is propagating and batch DOM-mutating effects until after dispatchEvent completes
- Don't check
disabled in the delegated event handler — if the button was enabled when the event was originally dispatched (capture phase), honor the click regardless of subsequent mutations
- Document the limitation — warn that
@attach handlers should not write $state that triggers $effect DOM mutations if those mutations can interfere with delegated event handlers
Environment
- Svelte 5 (runes mode)
@attach directive with on() from svelte/events
$state + $effect in .svelte.ts / .svelte.js runes module
- Tested in Chrome (via Astro + Svelte integration,
client:only="svelte")
- Confirmed with both
on() from svelte/events and raw addEventListener — the issue is $state/$effect scheduling, not the event registration method
Description
When using
{@attach}to register a click listener viaon(node, 'click')fromsvelte/events, writing to a$statevariable inside the handler causes$effectto flush synchronously via a microtask checkpoint during event propagation. If the$effectsetsnode.disabled = true, this happens before the event bubbles to the document root where Svelte 5's delegatedonclickhandler lives — causingonclickto be silently skipped.This is 100% reproducible, not intermittent.
Reproduction
Playground: https://svelte.dev/playground/hello-world?version=5.55.1#H4sIAAAAAAAAE4VVzW7jNhB-lYEawHbWayXZmyKrzabpbREgXaCHKEAkamSzoUmBHNmbGgJ66gMUfcJ9koIUJcuO0t4kcubjN_PNzz6Q2QaDKPhcryI4M5QRwk5zQuASfsqIMrYGJjh7AZbVBg2cYVkiIyhFbdaQY6k0QoECVxlhAUo662AelFygCaLHfUCvlX3DHgTz7sWbqlqYLQqyZ3lmcOycKUkoyQRREBumeUVJKlPim0ppgj3QWisigdBAqdUGJouwO_Igi9_N5DqV1ksgdfRuVS0Jlj7i6cXsujNoQx69lymVtWTElQQlb5zhdAZ7e5HSwPHDBwfXnPisM1kIvLXvH9yGhLxfSkxJowQuhFpNnx-9yROUXGMBP5zth07N86x7LQ4POZLx-ipxqsZMFZi0kcSh-zlI3F7un_de6-a56Wxa1VvSGgpuslyggbwmUvKt8C2SZ-Yx4nB91ZKpEhc30BoPCELtFnD3rUJGWESQK1oDszGhNsAl07hBSQu4YVRnIgIlxat_x9PtubZeByczHyUE3ECcJxK3qOMwT4BlQmCxiMOq5em5denoC2zaCT5ruhpa7geCNmDoVeAyDaqsKLhcRXCJG7jCzTWUStJHw__ACC4X7oTV2igdQaW4pX2dBq6sY1Nl0idqg3Hofq2sLSsv66fkAU0tyMTh-pO9rkXrLXgyGrNLTgSxIa3kKjmunjj0x3Eo-AnOWJIHOIOKfwfmCzfGKutd-hwxJWz4QwT4eNybCVzAjzDRWEwggslKI8pJkwbJfzi9YRGHbW5c1m7bpgLSGcMud1VyX6GEn3H7VSlhgHVGCgyiK1fcorTjTu2itkxidZrwx5bTEyg5larAeds8sy5ztnHNSIJ7v26ofv_r767TClgC6bpv2ThP3MhFA3yzwYJnhOLVFfF0w5lWlJkXYGtkL66sIEfaoY3t_gtYUmY2xqAbLiMNAkKtVli4J77_-Q_86gbqxAyavpsOBtH4hjvQXx4HkMkCzAuvDHDq9VEiCeYB4TcKImvczN9ZF2_H-vHaGL0frI9-ZSjZL4vWNHQC-y0Rnp-nEs7hQSlqN14ERa25XPk6qLSqslVmB_occq12xg4rXUvIYEwGh9ZJIbghlNZBSSh4WaK2mL1CC_hNc7KP-VXM5WBiTowDO57LpPlqZRG7EtryAZE5GCQHaPEXJ6Xl4D7f_XL_cDeo9LzO3aAnBYVitZ2msFujxrEC8OUDgm_RLCxgmEr85nLdL75-iNppm2fsZQ4bA0u4vLi48ItQI9VaguueGSyTbj3aldy5F4eFXGbCYLeU231JoMoSlocWnDhmkzlMh4DHy3XyXuf6XWvLvsugl2TIxiZxMrvukX1408PRqXV_YZC-8g2qmqYtv-nQtI3PpqmDag7ReqWnJ3HxcoAxO5y_F_Fw5oxVxyCwlMYMBtedfMeM_v9tJjCTdTXKweXgiMRbGs5mYNH0382btB1xVGXpVbIuzfEMepoHlHGx47IIIvdE8y9gHptRKQsAAA
Expected Behavior
onclickhandler fires on every click. The@attachthrottle disables the button after the current event is fully dispatched.Actual Behavior
onclickis never called. The$effectsettingnode.disabled = trueflushes during event propagation — before the event reaches the document root where Svelte 5's delegatedonclickhandler lives.Root Cause (confirmed via Chrome DevTools debugging)
The event flow with console instrumentation
Why this happens
The mechanism is a browser-level microtask checkpoint between event listeners on different DOM nodes during event propagation:
<span>inside<button><button>→on(node, 'click')handler fires → writes$state(throttled = true)→ Svelte schedules$effectas a microtask<button>anddocument) → microtask runs →$effectflushes →node.disabled = truedocument→ Svelte's delegatedonclickhandler checksevent.target→ finds button isdisabled→ skipsonclickKey insight:
queueMicrotaskdoes NOT helpWe tested wrapping the
$statewrite inqueueMicrotask():The microtask checkpoint between DOM nodes executes this callback before the event reaches document. Confirmed via console logs:
Workaround:
setTimeout(0)worksOnly a macrotask (
setTimeout(0)) defers the$statewrite past the entire event dispatch:Console output confirms
onclickfires:Analysis
$statewrite timing$effectflush timingonclickfires?queueMicrotasksetTimeout(0)The fundamental issue: Svelte 5's
$effectscheduling (microtask-based) is incompatible with its own event delegation (document-level) when$stateis written inside event handlers on child elements. The browser's microtask checkpoint between DOM nodes during event propagation creates a window where$effectflushes and mutates the DOM before the delegated handler processes the event.Suggested Fixes
$effectDOM mutations during active event dispatch — detect when an event is propagating and batch DOM-mutating effects until afterdispatchEventcompletesdisabledin the delegated event handler — if the button was enabled when the event was originally dispatched (capture phase), honor the click regardless of subsequent mutations@attachhandlers should not write$statethat triggers$effectDOM mutations if those mutations can interfere with delegated event handlersEnvironment
@attachdirective withon()fromsvelte/events$state+$effectin.svelte.ts/.svelte.jsrunes moduleclient:only="svelte")on()fromsvelte/eventsand rawaddEventListener— the issue is$state/$effectscheduling, not the event registration method