From bcd78e6940eb5cf36057dd8a26e7eab0be6c5a60 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 10 Jul 2026 23:36:56 +0200 Subject: [PATCH 1/5] fix: use mouseover+mousemove for preloading to reduce events Instead of always firing the mousemove event, we first use mouseover which fires less often, and only if we enter a link we start the mousemove logic. We do that to preserve the "mouse comes to a rest" behavior of the previous solution. Closes #12664 --- .changeset/upset-needles-beg.md | 5 +++ packages/kit/src/runtime/client/client.js | 41 +++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 .changeset/upset-needles-beg.md diff --git a/.changeset/upset-needles-beg.md b/.changeset/upset-needles-beg.md new file mode 100644 index 000000000000..68fbbc5ae54e --- /dev/null +++ b/.changeset/upset-needles-beg.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: use mouseover+mousemove for preloading to reduce events diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index dcc3c53aee61..3a977e89a129 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2112,16 +2112,51 @@ let current_a = { element: undefined, href: undefined }; function setup_preload() { /** @type {NodeJS.Timeout} */ let mousemove_timeout; + /** @type {HTMLAnchorElement | SVGAElement | undefined} */ + let hovered_a; /** @type {PreloadDataPriority} */ let current_priority; - container.addEventListener('mousemove', (event) => { - const target = /** @type {Element} */ (event.target); + function clear_hover_preload() { + clearTimeout(mousemove_timeout); + hovered_a = undefined; + container.removeEventListener('mousemove', mousemove); + } + function start_hover_preload() { clearTimeout(mousemove_timeout); mousemove_timeout = setTimeout(() => { - void preload(target, PRELOAD_PRIORITIES.hover); + if (hovered_a) { + void preload(hovered_a, PRELOAD_PRIORITIES.hover); + } }, 20); + } + + /** @param {MouseEvent} event */ + function mousemove(event) { + const target = event.target; + if (!(target instanceof Node) || !hovered_a?.contains(target)) { + clear_hover_preload(); + return; + } + + start_hover_preload(); + } + + // Use mouseover initially instead of mousemove to avoid cluttering the event queue + container.addEventListener('mouseover', (event) => { + if (!(event.target instanceof Element)) return; + + const a = find_anchor(event.target, container); + if (!a || a === hovered_a) return; + + clear_hover_preload(); + hovered_a = a; + start_hover_preload(); + // Instead of just preloading right away, we start a mousemove listener to implement + // "mouse comes to a rest" behavior. This avoid false positives when you just move + // your mouse across the screen and happen to pass over a link. + container.addEventListener('mousemove', mousemove); }); /** @param {Event} event */ From b1c8ec846b8741b46fd5699d8c6d5731feead51b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 13 Jul 2026 16:53:55 -0400 Subject: [PATCH 2/5] simplify a bit --- packages/kit/src/runtime/client/client.js | 29 ++++++++--------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 3a977e89a129..ea2ab6c94309 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2119,30 +2119,18 @@ function setup_preload() { function clear_hover_preload() { clearTimeout(mousemove_timeout); + hovered_a?.removeEventListener('mousemove', start_hover_preload); hovered_a = undefined; - container.removeEventListener('mousemove', mousemove); } function start_hover_preload() { clearTimeout(mousemove_timeout); mousemove_timeout = setTimeout(() => { - if (hovered_a) { - void preload(hovered_a, PRELOAD_PRIORITIES.hover); - } + if (!hovered_a) return; + void preload(hovered_a, PRELOAD_PRIORITIES.hover); }, 20); } - /** @param {MouseEvent} event */ - function mousemove(event) { - const target = event.target; - if (!(target instanceof Node) || !hovered_a?.contains(target)) { - clear_hover_preload(); - return; - } - - start_hover_preload(); - } - // Use mouseover initially instead of mousemove to avoid cluttering the event queue container.addEventListener('mouseover', (event) => { if (!(event.target instanceof Element)) return; @@ -2150,13 +2138,16 @@ function setup_preload() { const a = find_anchor(event.target, container); if (!a || a === hovered_a) return; - clear_hover_preload(); - hovered_a = a; - start_hover_preload(); // Instead of just preloading right away, we start a mousemove listener to implement // "mouse comes to a rest" behavior. This avoid false positives when you just move // your mouse across the screen and happen to pass over a link. - container.addEventListener('mousemove', mousemove); + a.addEventListener('mousemove', start_hover_preload); + a.addEventListener('mouseleave', clear_hover_preload, { once: true }); + + clear_hover_preload(); + start_hover_preload(); + + hovered_a = a; }); /** @param {Event} event */ From ac4856ea037561af68b3f0d0b2ce930968fe84c0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 13 Jul 2026 16:56:10 -0400 Subject: [PATCH 3/5] shuffle, so we can early-exit --- packages/kit/src/runtime/client/client.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index ea2ab6c94309..c62f027fdd8a 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2138,16 +2138,16 @@ function setup_preload() { const a = find_anchor(event.target, container); if (!a || a === hovered_a) return; + clear_hover_preload(); + hovered_a = a; + // Instead of just preloading right away, we start a mousemove listener to implement // "mouse comes to a rest" behavior. This avoid false positives when you just move // your mouse across the screen and happen to pass over a link. a.addEventListener('mousemove', start_hover_preload); a.addEventListener('mouseleave', clear_hover_preload, { once: true }); - clear_hover_preload(); start_hover_preload(); - - hovered_a = a; }); /** @param {Event} event */ From f0e14d2b10f7902ed9e51e11e50f62066b546c97 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 13 Jul 2026 17:27:15 -0400 Subject: [PATCH 4/5] return early if no preloading will happen --- packages/kit/src/runtime/client/client.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index c62f027fdd8a..198027f5e337 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2141,6 +2141,16 @@ function setup_preload() { clear_hover_preload(); hovered_a = a; + const options = get_router_options(a); + + if ( + options.preload_code < PRELOAD_PRIORITIES.hover && + options.preload_data < PRELOAD_PRIORITIES.hover + ) { + // don't add event listeners if no preloading will happen + return; + } + // Instead of just preloading right away, we start a mousemove listener to implement // "mouse comes to a rest" behavior. This avoid false positives when you just move // your mouse across the screen and happen to pass over a link. @@ -2153,7 +2163,11 @@ function setup_preload() { /** @param {Event} event */ function tap(event) { if (event.defaultPrevented) return; - void preload(/** @type {Element} */ (event.composedPath()[0]), PRELOAD_PRIORITIES.tap); + + const a = find_anchor(/** @type {Element} */ (event.composedPath()[0]), container); + if (!a) return; + + void preload(a, PRELOAD_PRIORITIES.tap); } container.addEventListener('mousedown', tap); @@ -2172,12 +2186,10 @@ function setup_preload() { ); /** - * @param {Element} element + * @param {HTMLAnchorElement | SVGAElement} a * @param {PreloadDataPriority} priority */ - async function preload(element, priority) { - const a = find_anchor(element, container); - + async function preload(a, priority) { // we don't want to preload data again if the user has already hovered/tapped const interacted = a === current_a.element && a?.href === current_a.href && priority >= current_priority; From 8fe0bfd8046d8e7c019bfdbbb6dd7bb09150f64f Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:46:29 +0000 Subject: [PATCH 5/5] Fix: Stale `mouseleave` listener from a previously-hovered anchor is never removed, cancelling hover-preload for the newly-hovered adjacent anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the issue reported at packages/kit/src/runtime/client/client.js:2120 ## Bug In `setup_preload()` (`packages/kit/src/runtime/client/client.js`), each preloadable anchor gets two listeners on `mouseover`: ```js a.addEventListener('mousemove', start_hover_preload); a.addEventListener('mouseleave', clear_hover_preload, { once: true }); ``` But `clear_hover_preload()` only removed the `mousemove` listener: ```js function clear_hover_preload() { clearTimeout(mousemove_timeout); hovered_a?.removeEventListener('mousemove', start_hover_preload); hovered_a = undefined; } ``` The `mouseleave` listener on the previous anchor is left attached. ### Trigger: moving the cursor directly from anchor A to adjacent anchor B Per the DOM UI Events spec, moving from element A to a sibling B dispatches events in this order: ``` mouseout(A) → mouseover(B) → mouseleave(A) → mouseenter(B) ``` Note `mouseover(B)` fires **before** `mouseleave(A)`. 1. Hovering A: `hovered_a = A`; A has `mousemove` + `mouseleave` (once) listeners. 2. `mouseover(B)` bubbles to `container`: `find_anchor` returns B, `B !== A`, so `clear_hover_preload()` runs (clears timeout, removes only `mousemove` from A). Then `hovered_a = B`, B gets its listeners, and `start_hover_preload()` schedules B's preload timeout. **A's `mouseleave` listener is still attached.** 3. `mouseleave(A)` now fires the still-attached once-listener → `clear_hover_preload()` runs again → `clearTimeout` cancels B's just-scheduled preload, removes `mousemove` from `hovered_a` (now B), and sets `hovered_a = undefined`. Net result: B never preloads on hover. In a nav menu / list of adjacent links this breaks hover-preload for essentially every direct link-to-link transition. ## Fix Remove the `mouseleave` listener inside `clear_hover_preload` as well: ```js function clear_hover_preload() { clearTimeout(mousemove_timeout); hovered_a?.removeEventListener('mousemove', start_hover_preload); hovered_a?.removeEventListener('mouseleave', clear_hover_preload); hovered_a = undefined; } ``` During step 2, `clear_hover_preload()` now runs while `hovered_a` is still A, so A's `mouseleave` listener is removed *before* the browser dispatches `mouseleave(A)`. The stale handler therefore never fires, and B's preload survives. (Even though the listener was registered with `{ once: true }`, the browser only auto-removes it upon dispatch; explicit removal before dispatch prevents the firing.) Co-authored-by: Vercel Co-authored-by: Rich-Harris --- packages/kit/src/runtime/client/client.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 198027f5e337..b98fc9e7c29c 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2120,6 +2120,7 @@ function setup_preload() { function clear_hover_preload() { clearTimeout(mousemove_timeout); hovered_a?.removeEventListener('mousemove', start_hover_preload); + hovered_a?.removeEventListener('mouseleave', clear_hover_preload); hovered_a = undefined; }