#18070·svelte

`$state` write in `@attach` click handler triggers `$effect` flush before delegated `onclick` — handler silently skipped

Author: XhstormRCreated Apr 5, 2026Updated Sep 15, 2026
## 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 ```svelte Click me
  • onclick count: {onclickCount}
  • @attach count: {attachCount}
  • Missed: {attachCount - onclickCount}
``` ```js // 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**: 1. User clicks `` inside `` 2. Event bubbles to `` → `on(node, 'click')` handler fires → writes `$state(throttled = true)` → Svelte schedules `$effect` as a microtask 3. **Browser performs microtask checkpoint** (JS call stack empties between listeners on `` and `document`) → microtask runs → `$effect` flushes → `node.disabled = true` 4. 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()`: ```js 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. ```js 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 1. **Defer `$effect` DOM mutations during active event dispatch** — detect when an event is propagating and batch DOM-mutating effects until after `dispatchEvent` completes 2. **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 3. **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