Repository: sveltejs/svelte (2025-08-14T11:48:53Z)

Issue:
Redirects inside remote function queries not working as expected
### Describe the bug

In [the SvelteKit docs](https://svelte.dev/docs/kit/remote-functions#Redirects), it’s stated that redirects should work inside query functions. However, when I trigger a redirect within a query, the navigation happens, but the pending state persists — the UI continues to show the loading snippet instead of rendering the new page’s content. This gives the impression that the page is stuck in a loading state, even though the redirect has occurred.

Expected behavior: After a redirect from a query, the new page should render immediately, just like with redirects in load functions.

### Reproduction

[Stackblitz](https://stackblitz.com/edit/sveltejs-kit-template-default-fjfrbi2p?file=src%2Flib%2Fredirect.remote.ts)

### Logs

```Shell
Uncaught (in promise) Redirect {status: 307, location: '/about'}
```

### System Info

```Shell
From Stackblitz:

  System:
    OS: Linux 5.0 undefined
    CPU: (8) x64 Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
    Memory: 0 Bytes / 0 Bytes
    Shell: 1.0 - /bin/jsh
  Binaries:
    Node: 20.19.1 - /usr/local/bin/node
    Yarn: 1.22.19 - /usr/local/bin/yarn
    npm: 10.8.2 - /usr/local/bin/npm
    pnpm: 8.15.6 - /usr/local/bin/pnpm
  npmPackages:
    @sveltejs/adapter-auto: ^6.0.0 => 6.1.0 
    @sveltejs/kit: ^2.28.0 => 2.28.0 
    @sveltejs/vite-plugin-svelte: ^6.0.0 => 6.1.1 
    svelte: ^5.38.1 => 5.38.1 
    vite: ^7.0.4 => 7.1.2
```

### Severity

blocking an upgrade

### Additional Information

_No response_


Discussion:
This is a Svelte issue. [Minimum reproduction](https://svelte.dev/playground/hello-world?version=5.38.1#H4sIAAAAAAAACnVSTWvcMBD9K8O0YBuM7R560doLpZcecgght7oHZT3uCmRJSONsgvF_L7JcutmkR715783H04JGToQCf5DWFi7W6wFyGhTTUGCJo9IUUPxckF9d5EUAy7-qb85V4Zk0R-xJBvoIP1nDZDigwDacvHIMkx1mTcfe9KwmZz3D97PSA4zeTpBV9fbaHbJDpNHLRtPE4O3MBB18DiyZ8uU0e0-GRXa2E2VrcehNW6dGsYNpk494srMZpH_d2i6f1Jicql0PXdfB7hEZbRqpTnRBOtCG99y640NUClg2h7WtXWLVaoyczT8Y5RwxODKDMr_zYlffWRnfVVUlyc5bt6lvJsUSmV4YBfuZ1vI_KVxf620ON5X3SVxHsOyXXVMMPVb1vxx7PKTF5EUqBkMXuPd2UoHy3JfgKVj9TAV0R1jSnoH4UU1kZ87za7znm7NDZvlMPkvltYSvTVO889g7lPClaZpi-xO3Ub-91q8SWSp9UWZAMcoY3x-byUd-7gIAAA==)



Patch:
diff --git a/.changeset/silent-suns-whisper.md b/.changeset/silent-suns-whisper.md
new file mode 100644
index 000000000000..7ee7d74abc3f
--- /dev/null
+++ b/.changeset/silent-suns-whisper.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: properly catch top level await errors
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
index 166207f66aa7..940d6a9e004d 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
@@ -359,16 +359,31 @@ export function client_component(analysis, options) {
 	if (dev) push_args.push(b.id(analysis.name));
 
 	let component_block = b.block([
+		store_init,
 		...store_setup,
 		...legacy_reactive_declarations,
 		...group_binding_declarations,
-		...state.instance_level_snippets,
-		.../** @type {ESTree.Statement[]} */ (instance.body),
-		analysis.runes || !analysis.needs_context
-			? b.empty
-			: b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined))
+		...state.instance_level_snippets
 	]);
 
+	if (analysis.instance.has_await) {
+		const body = b.block([
+			.../** @type {ESTree.Statement[]} */ (instance.body),
+			b.if(b.call('$.aborted'), b.return()),
+			.../** @type {ESTree.Statement[]} */ (template.body)
+		]);
+
+		component_block.body.push(b.stmt(b.call(`$.async_body`, b.arrow([], body, true))));
+	} else {
+		component_block.body.push(.../** @type {ESTree.Statement[]} */ (instance.body));
+
+		if (!analysis.runes && analysis.needs_context) {
+			component_block.body.push(b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined)));
+		}
+
+		component_block.body.push(.../** @type {ESTree.Statement[]} */ (template.body));
+	}
+
 	if (analysis.needs_mutation_validation) {
 		component_block.body.unshift(
 			b.var('$$ownership_validator', b.call('$.create_ownership_validator', b.id('$$props')))
@@ -389,41 +404,6 @@ export function client_component(analysis, options) {
 		analysis.uses_slots ||
 		analysis.slot_names.size > 0;
 
-	if (analysis.instance.has_await) {
-		const params = [b.id('$$anchor')];
-		if (should_inject_props) {
-			params.push(b.id('$$props'));
-		}
-		if (store_setup.length > 0) {
-			params.push(b.id('$$stores'));
-		}
-		const body = b.function_declaration(
-			b.id('$$body'),
-			params,
-			b.block([
-				b.var('$$unsuspend', b.call('$.suspend')),
-				...component_block.body,
-				b.if(b.call('$.aborted'), b.return()),
-				.../** @type {ESTree.Statement[]} */ (template.body),
-				b.stmt(b.call('$$unsuspend'))
-			]),
-			true
-		);
-
-		state.hoisted.push(body);
-
-		component_block = b.block([
-			b.var('fragment', b.call('$.comment')),
-			b.var('node', b.call('$.first_child', b.id('fragment'))),
-			store_init,
-			b.stmt(b.call(body.id, b.id('node'), ...params.slice(1))),
-			b.stmt(b.call('$.append', b.id('$$anchor'), b.id('fragment')))
-		]);
-	} else {
-		component_block.body.unshift(store_init);
-		component_block.body.push(.../** @type {ESTree.Statement[]} */ (template.body));
-	}
-
 	// trick esrap into including comments
 	component_block.loc = instance.loc;
 
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index c094c9e04449..c5b7bb845c4a 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -99,6 +99,7 @@ export {
 	with_script
 } from './dom/template.js';
 export {
+	async_body,
 	for_await_track_reactivity_loss,
 	save,
 	track_reactivity_loss
@@ -151,7 +152,8 @@ export {
 	untrack,
 	exclude_from_object,
 	deep_read,
-	deep_read_state
+	deep_read_state,
+	active_effect
 } from './runtime.js';
 export { validate_binding, validate_each_keys } from './validate.js';
 export { raf } from './timing.js';
@@ -176,3 +178,4 @@ export {
 } from '../shared/validate.js';
 export { strict_equals, equals } from './dev/equality.js';
 export { log_if_contains_state } from './dev/console-log.js';
+export { invoke_error_boundary } from './error-handling.js';
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index 2b133e5f4492..1ea1bbe56160 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -11,7 +11,7 @@ import {
 	set_active_effect,
 	set_active_reaction
 } from '../runtime.js';
-import { current_batch } from './batch.js';
+import { current_batch, suspend } from './batch.js';
 import {
 	async_derived,
 	current_async_effect,
@@ -19,6 +19,7 @@ import {
 	derived_safe_equal,
 	set_from_async_derived
 } from './deriveds.js';
+import { aborted } from './effects.js';
 
 /**
  *
@@ -170,3 +171,21 @@ export function unset_context() {
 	set_component_context(null);
 	if (DEV) set_from_async_derived(null);
 }
+
+/**
+ * @param {() => Promise<void>} fn
+ */
+export async function async_body(fn) {
+	const unsuspend = suspend();
+	const active = /** @type {Effect} */ (active_effect);
+
+	try {
+		await fn();
+	} catch (error) {
+		if (!aborted(active)) {
+			invoke_error_boundary(error, active);
+		}
+	} finally {
+		unsuspend();
+	}
+}
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index 68a155503237..df3dd7580896 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -648,7 +648,6 @@ function resume_children(effect, local) {
 	}
 }
 
-export function aborted() {
-	var effect = /** @type {Effect} */ (active_effect);
+export function aborted(effect = /** @type {Effect} */ (active_effect)) {
 	return (effect.f & DESTROYED) !== 0;
 }


Test patch:
diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/Child.svelte
new file mode 100644
index 000000000000..f7ba132acee1
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/Child.svelte
@@ -0,0 +1,9 @@
+<script>
+	import { route } from "./main.svelte";
+
+	await new Promise(async (_, reject) => {
+		await Promise.resolve();
+		route.current = 'other'
+		route.reject = reject;
+	});
+</script>
diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/_config.js b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/_config.js
new file mode 100644
index 000000000000..298e33e9a299
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/_config.js
@@ -0,0 +1,15 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+	html: `<button>reject</button> <p>pending</p>`,
+
+	async test({ assert, target }) {
+		const [reject] = target.querySelectorAll('button');
+
+		await tick();
+		reject.click();
+		await tick();
+		assert.htmlEqual(target.innerHTML, '<button>reject</button> <p>route: other</p>');
+	}
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/main.svelte
new file mode 100644
index 000000000000..2f461e96c856
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested-obsolete/main.svelte
@@ -0,0 +1,18 @@
+<script module>
+	import Child from './Child.svelte';
+	export let route = $state({ current: 'home' });
+</script>
+
+<button onclick={() => route.reject()}>reject</button>
+
+<svelte:boundary>
+	{#if route.current === 'home'}
+		<Child />
+	{:else}
+		<p>route: {route.current}</p>
+	{/if}
+
+	{#snippet pending()}
+		<p>pending</p>
+	{/snippet}
+</svelte:boundary>
diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/Child.svelte
new file mode 100644
index 000000000000..11c9ebd6532a
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/Child.svelte
@@ -0,0 +1,7 @@
+<script>
+	import { route } from "./main.svelte";
+
+	await new Promise(async (_, reject) => {
+		route.reject = reject;
+	});
+</script>
diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/_config.js b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/_config.js
new file mode 100644
index 000000000000..57005b41120c
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/_config.js
@@ -0,0 +1,14 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+	html: `<button>reject</button> <p>pending</p>`,
+
+	async test({ assert, target }) {
+		const [reject] = target.querySelectorAll('button');
+
+		reject.click();
+		await tick();
+		assert.htmlEqual(target.innerHTML, '<button>reject</button> <p>failed</p>');
+	}
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/main.svelte
new file mode 100644
index 000000000000..2fdf4c0d2f65
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-top-level-error-nested/main.svelte
@@ -0,0 +1,18 @@
+<script module>
+	import Child from './Child.svelte';
+	export let route = $state({});
+</script>
+
+<button onclick={() => route.reject()}>reject</button>
+
+<svelte:boundary>
+	<Child />
+
+	{#snippet pending()}
+		<p>pending</p>
+	{/snippet}
+
+	{#snippet failed()}
+		<p>failed</p>
+	{/snippet}
+</svelte:boundary>