From fd1e36e5344e78f74bfc56eb33044a6451dce569 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 5 Nov 2026 14:23:56 +0100 Subject: [PATCH] fix(sveltekit): Read navigation untracked in the Kit 3 fetch wrapper The SvelteKit 3 fetch wrapper read `navigating` from `$app/state` tracked. A `$effect` that calls fetch synchronously then picked up `navigating` as a dependency and re-ran (re-sending its requests) on every navigation. Read it with `untrack` instead. Fixes #26129 Co-Authored-By: Claude Opus 5.5 --- .../src/routes/effect-fetch/+page.svelte | 13 ++++++++++++ .../sveltekit-3/tests/tracing.client.test.ts | 20 +++++++++++++++++++ packages/sveltekit/rollup.npm.config.mjs | 8 +++++++- .../src/client/navigationState.svelte.ts | 5 ++++- packages/sveltekit/src/client/svelte5.d.ts | 7 +++++++ 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/effect-fetch/+page.svelte create mode 100644 packages/sveltekit/src/client/svelte5.d.ts diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/effect-fetch/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/effect-fetch/+page.svelte new file mode 100644 index 000000000000..0176ad4d6bb6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/effect-fetch/+page.svelte @@ -0,0 +1,13 @@ + + +

responses: {responses}

+Same route, new query +Leave page diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts index 47aa28294a58..08e7255b145f 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts @@ -77,3 +77,23 @@ test.describe('client-specific performance events', () => { ); }); }); + +test('fetch instrumentation does not make effects re-run on navigation', async ({ page }) => { + const effectFetches: string[] = []; + page.on('request', request => { + if (new URL(request.url()).pathname === '/api/users') { + effectFetches.push(request.url()); + } + }); + + await waitForInitialPageload(page, { route: '/effect-fetch' }); + await expect(page.locator('#effect-fetch-responses')).toHaveText('responses: 1'); + + await page.getByText('Same route, new query').click(); + await page.waitForURL('**/effect-fetch?q=1'); + + await page.getByText('Leave page').click(); + await expect(page.getByText('Navigation 1')).toBeVisible(); + + expect(effectFetches).toHaveLength(1); +}); diff --git a/packages/sveltekit/rollup.npm.config.mjs b/packages/sveltekit/rollup.npm.config.mjs index b6da582e41fc..a2ee50611cd8 100644 --- a/packages/sveltekit/rollup.npm.config.mjs +++ b/packages/sveltekit/rollup.npm.config.mjs @@ -20,7 +20,13 @@ export default makeNPMConfigVariants( packageSpecificConfig: { // Keep the variant subpath external so the transpiled output preserves the import for the // consumer to resolve (via `exports` or the `sentrySvelteKit()` plugin). - external: ['$app/state', '$app/stores', '@sentry/sveltekit/browser-tracing-variant', 'cloudflare:workers'], + external: [ + 'svelte', + '$app/state', + '$app/stores', + '@sentry/sveltekit/browser-tracing-variant', + 'cloudflare:workers', + ], output: { dynamicImportInCjs: true, }, diff --git a/packages/sveltekit/src/client/navigationState.svelte.ts b/packages/sveltekit/src/client/navigationState.svelte.ts index 0273dee73bf7..9df675259569 100644 --- a/packages/sveltekit/src/client/navigationState.svelte.ts +++ b/packages/sveltekit/src/client/navigationState.svelte.ts @@ -1,5 +1,6 @@ import type { Navigation } from '@sveltejs/kit'; import { navigating, page } from '$app/state'; +import { untrack } from 'svelte'; declare const $effect: { (callback: () => void): void; @@ -31,9 +32,11 @@ export function onNavigationChange(callback: (navigation: Navigation | null) => * Reads the current navigation synchronously. `$effect`s are always batched to a microtask, which is * too late to catch SvelteKit's synchronous data-request `fetch` at navigation start; a synchronous * rune read lets us start the navigation span before that request so it propagates the right trace. + * The read is untracked: the `fetch` wrapper calls this from user code, often inside a `$effect`, + * and a tracked read would make that effect re-run on every navigation. */ export function getCurrentNavigation(): Navigation | null { - return _snapshot(); + return untrack(_snapshot); } function _snapshot(): Navigation | null { diff --git a/packages/sveltekit/src/client/svelte5.d.ts b/packages/sveltekit/src/client/svelte5.d.ts new file mode 100644 index 000000000000..40d450d25dd8 --- /dev/null +++ b/packages/sveltekit/src/client/svelte5.d.ts @@ -0,0 +1,7 @@ +// The package is type-checked against Svelte 4, but the Kit 3 variant only ever runs on Svelte 5. +// Declares the Svelte 5 export it uses; build-time only and never emitted. +export {}; + +declare module 'svelte' { + export function untrack(fn: () => T): T; +}