Skip to content

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

@XhstormR

Description

@XhstormR

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:

  1. User clicks <span> inside <button>
  2. Event bubbles to <button>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 <button> 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 disabledskips 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

  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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions