diff --git a/.changeset/short-fireants-flow.md b/.changeset/short-fireants-flow.md
new file mode 100644
index 0000000000..b9955ff577
--- /dev/null
+++ b/.changeset/short-fireants-flow.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: add `getAbortSignal()`
diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md
index 111b0b8940..3f33e37d2e 100644
--- a/documentation/docs/98-reference/.generated/client-errors.md
+++ b/documentation/docs/98-reference/.generated/client-errors.md
@@ -74,6 +74,12 @@ Effect cannot be created inside a `$derived` value that was not itself created i
Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops
```
+### get_abort_signal_outside_reaction
+
+```
+`getAbortSignal()` can only be called inside an effect or derived
+```
+
### hydration_failed
```
diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md
index 6d96770eba..47c2038d70 100644
--- a/packages/svelte/messages/client-errors/errors.md
+++ b/packages/svelte/messages/client-errors/errors.md
@@ -48,6 +48,10 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long
> Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops
+## get_abort_signal_outside_reaction
+
+> `getAbortSignal()` can only be called inside an effect or derived
+
## hydration_failed
> Failed to hydrate the application
diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js
index efd5628ae9..0d962aacd1 100644
--- a/packages/svelte/src/index-client.js
+++ b/packages/svelte/src/index-client.js
@@ -1,7 +1,7 @@
/** @import { ComponentContext, ComponentContextLegacy } from '#client' */
/** @import { EventDispatcher } from './index.js' */
/** @import { NotFunction } from './internal/types.js' */
-import { untrack } from './internal/client/runtime.js';
+import { active_reaction, untrack } from './internal/client/runtime.js';
import { is_array } from './internal/shared/utils.js';
import { user_effect } from './internal/client/index.js';
import * as e from './internal/client/errors.js';
@@ -44,6 +44,37 @@ if (DEV) {
throw_rune_error('$bindable');
}
+/**
+ * Returns an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that aborts when the current [derived](https://svelte.dev/docs/svelte/$derived) or [effect](https://svelte.dev/docs/svelte/$effect) re-runs or is destroyed.
+ *
+ * Must be called while a derived or effect is running.
+ *
+ * ```svelte
+ *
+ * ```
+ */
+export function getAbortSignal() {
+ if (active_reaction === null) {
+ e.get_abort_signal_outside_reaction();
+ }
+
+ return (active_reaction.ac ??= new AbortController()).signal;
+}
+
/**
* `onMount`, like [`$effect`](https://svelte.dev/docs/svelte/$effect), schedules a function to run as soon as the component has been mounted to the DOM.
* Unlike `$effect`, the provided function only runs once.
diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js
index 0f1aff8f5a..e5039cf150 100644
--- a/packages/svelte/src/index-server.js
+++ b/packages/svelte/src/index-server.js
@@ -35,6 +35,8 @@ export function unmount() {
export async function tick() {}
+export { getAbortSignal } from './internal/server/abort-signal.js';
+
export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js';
export { createRawSnippet } from './internal/server/blocks/snippet.js';
diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js
index 3ca915f98e..dd3d1b2df6 100644
--- a/packages/svelte/src/internal/client/constants.js
+++ b/packages/svelte/src/internal/client/constants.js
@@ -27,6 +27,12 @@ export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');
export const PROXY_PATH_SYMBOL = Symbol('proxy path');
+// allow users to ignore aborted signal errors if `reason.stale`
+export const STALE_REACTION = new (class StaleReactionError extends Error {
+ name = 'StaleReactionError';
+ message = 'The reaction that called `getAbortSignal()` was re-run or destroyed';
+})();
+
export const ELEMENT_NODE = 1;
export const TEXT_NODE = 3;
export const COMMENT_NODE = 8;
diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js
index a0ac021c03..5c3f5340e1 100644
--- a/packages/svelte/src/internal/client/errors.js
+++ b/packages/svelte/src/internal/client/errors.js
@@ -195,6 +195,22 @@ export function effect_update_depth_exceeded() {
}
}
+/**
+ * `getAbortSignal()` can only be called inside an effect or derived
+ * @returns {never}
+ */
+export function get_abort_signal_outside_reaction() {
+ if (DEV) {
+ const error = new Error(`get_abort_signal_outside_reaction\n\`getAbortSignal()\` can only be called inside an effect or derived\nhttps://svelte.dev/e/get_abort_signal_outside_reaction`);
+
+ error.name = 'Svelte error';
+
+ throw error;
+ } else {
+ throw new Error(`https://svelte.dev/e/get_abort_signal_outside_reaction`);
+ }
+}
+
/**
* Failed to hydrate the application
* @returns {never}
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index e9cea0df3e..d3123d24a1 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -53,7 +53,8 @@ export function derived(fn) {
rv: 0,
v: /** @type {V} */ (null),
wv: 0,
- parent: parent_derived ?? active_effect
+ parent: parent_derived ?? active_effect,
+ ac: null
};
if (DEV && tracing_mode_flag) {
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index 03d073781d..a2806bde81 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -32,7 +32,8 @@ import {
HEAD_EFFECT,
MAYBE_DIRTY,
EFFECT_HAS_DERIVED,
- BOUNDARY_EFFECT
+ BOUNDARY_EFFECT,
+ STALE_REACTION
} from '#client/constants';
import { set } from './sources.js';
import * as e from '../errors.js';
@@ -106,7 +107,8 @@ function create_effect(type, fn, sync, push = true) {
prev: null,
teardown: null,
transitions: null,
- wv: 0
+ wv: 0,
+ ac: null
};
if (DEV) {
@@ -397,6 +399,8 @@ export function destroy_effect_children(signal, remove_dom = false) {
signal.first = signal.last = null;
while (effect !== null) {
+ effect.ac?.abort(STALE_REACTION);
+
var next = effect.next;
if ((effect.f & ROOT_EFFECT) !== 0) {
@@ -478,6 +482,7 @@ export function destroy_effect(effect, remove_dom = true) {
effect.fn =
effect.nodes_start =
effect.nodes_end =
+ effect.ac =
null;
}
diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts
index 756bb98f09..88c84f27fe 100644
--- a/packages/svelte/src/internal/client/reactivity/types.d.ts
+++ b/packages/svelte/src/internal/client/reactivity/types.d.ts
@@ -40,6 +40,8 @@ export interface Reaction extends Signal {
fn: null | Function;
/** Signals that this signal reads from */
deps: null | Value[];
+ /** An AbortController that aborts when the signal is destroyed */
+ ac: null | AbortController;
}
export interface Derived extends Value, Reaction {
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index d057bfdf0d..3e70537d7c 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -22,7 +22,8 @@ import {
ROOT_EFFECT,
LEGACY_DERIVED_PROP,
DISCONNECTED,
- EFFECT_IS_UPDATING
+ EFFECT_IS_UPDATING,
+ STALE_REACTION
} from './constants.js';
import { flush_tasks } from './dom/task.js';
import { internal_set, old_values } from './reactivity/sources.js';
@@ -276,6 +277,11 @@ export function update_reaction(reaction) {
reaction.f |= EFFECT_IS_UPDATING;
+ if (reaction.ac !== null) {
+ reaction.ac.abort(STALE_REACTION);
+ reaction.ac = null;
+ }
+
try {
var result = /** @type {Function} */ (0, reaction.fn)();
var deps = reaction.deps;
diff --git a/packages/svelte/src/internal/server/abort-signal.js b/packages/svelte/src/internal/server/abort-signal.js
new file mode 100644
index 0000000000..da579b2592
--- /dev/null
+++ b/packages/svelte/src/internal/server/abort-signal.js
@@ -0,0 +1,13 @@
+import { STALE_REACTION } from '#client/constants';
+
+/** @type {AbortController | null} */
+export let controller = null;
+
+export function abort() {
+ controller?.abort(STALE_REACTION);
+ controller = null;
+}
+
+export function getAbortSignal() {
+ return (controller ??= new AbortController()).signal;
+}
diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js
index 2ca85fff44..ceb516ebb0 100644
--- a/packages/svelte/src/internal/server/index.js
+++ b/packages/svelte/src/internal/server/index.js
@@ -18,6 +18,7 @@ import { validate_store } from '../shared/validate.js';
import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js';
import { reset_elements } from './dev.js';
import { Payload } from './payload.js';
+import { abort } from './abort-signal.js';
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
// https://infra.spec.whatwg.org/#noncharacter
@@ -66,50 +67,54 @@ export let on_destroy = [];
* @returns {RenderOutput}
*/
export function render(component, options = {}) {
- const payload = new Payload(options.idPrefix ? options.idPrefix + '-' : '');
+ try {
+ const payload = new Payload(options.idPrefix ? options.idPrefix + '-' : '');
- const prev_on_destroy = on_destroy;
- on_destroy = [];
- payload.out += BLOCK_OPEN;
+ const prev_on_destroy = on_destroy;
+ on_destroy = [];
+ payload.out += BLOCK_OPEN;
- let reset_reset_element;
+ let reset_reset_element;
- if (DEV) {
- // prevent parent/child element state being corrupted by a bad render
- reset_reset_element = reset_elements();
- }
+ if (DEV) {
+ // prevent parent/child element state being corrupted by a bad render
+ reset_reset_element = reset_elements();
+ }
- if (options.context) {
- push();
- /** @type {Component} */ (current_component).c = options.context;
- }
+ if (options.context) {
+ push();
+ /** @type {Component} */ (current_component).c = options.context;
+ }
- // @ts-expect-error
- component(payload, options.props ?? {}, {}, {});
+ // @ts-expect-error
+ component(payload, options.props ?? {}, {}, {});
- if (options.context) {
- pop();
- }
+ if (options.context) {
+ pop();
+ }
- if (reset_reset_element) {
- reset_reset_element();
- }
+ if (reset_reset_element) {
+ reset_reset_element();
+ }
- payload.out += BLOCK_CLOSE;
- for (const cleanup of on_destroy) cleanup();
- on_destroy = prev_on_destroy;
+ payload.out += BLOCK_CLOSE;
+ for (const cleanup of on_destroy) cleanup();
+ on_destroy = prev_on_destroy;
- let head = payload.head.out + payload.head.title;
+ let head = payload.head.out + payload.head.title;
- for (const { hash, code } of payload.css) {
- head += ``;
- }
+ for (const { hash, code } of payload.css) {
+ head += ``;
+ }
- return {
- head,
- html: payload.out,
- body: payload.out
- };
+ return {
+ head,
+ html: payload.out,
+ body: payload.out
+ };
+ } finally {
+ abort();
+ }
}
/**
diff --git a/packages/svelte/tests/runtime-runes/samples/get-abort-signal/_config.js b/packages/svelte/tests/runtime-runes/samples/get-abort-signal/_config.js
new file mode 100644
index 0000000000..6a85e2615a
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/get-abort-signal/_config.js
@@ -0,0 +1,34 @@
+import { test } from '../../test';
+
+export default test({
+ html: `increment loading...
`,
+
+ async test({ assert, target, variant, logs }) {
+ await new Promise((f) => setTimeout(f, 50));
+
+ if (variant === 'hydrate') {
+ assert.deepEqual(logs, [
+ 'aborted',
+ 'StaleReactionError',
+ 'The reaction that called `getAbortSignal()` was re-run or destroyed'
+ ]);
+ }
+
+ logs.length = 0;
+
+ const [button] = target.querySelectorAll('button');
+
+ await new Promise((f) => setTimeout(f, 50));
+ assert.htmlEqual(target.innerHTML, 'increment 0
');
+
+ button.click();
+ await new Promise((f) => setTimeout(f, 50));
+ assert.htmlEqual(target.innerHTML, 'increment 2
');
+
+ assert.deepEqual(logs, [
+ 'aborted',
+ 'StaleReactionError',
+ 'The reaction that called `getAbortSignal()` was re-run or destroyed'
+ ]);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/get-abort-signal/main.svelte b/packages/svelte/tests/runtime-runes/samples/get-abort-signal/main.svelte
new file mode 100644
index 0000000000..be5625125b
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/get-abort-signal/main.svelte
@@ -0,0 +1,33 @@
+
+
+ {
+ count += 1;
+ await Promise.resolve();
+ count += 1;
+}}>increment
+
+{#await delayed_count}
+ loading...
+{:then count}
+ {count}
+{:catch error}
+ {console.log('this should never be rendered')}
+{/await}
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index 3401efac04..432171ae0d 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -348,6 +348,30 @@ declare module 'svelte' {
*/
props: Props;
});
+ /**
+ * Returns an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that aborts when the current [derived](https://svelte.dev/docs/svelte/$derived) or [effect](https://svelte.dev/docs/svelte/$effect) re-runs or is destroyed.
+ *
+ * Must be called while a derived or effect is running.
+ *
+ * ```svelte
+ *
+ * ```
+ */
+ export function getAbortSignal(): AbortSignal;
/**
* `onMount`, like [`$effect`](https://svelte.dev/docs/svelte/$effect), schedules a function to run as soon as the component has been mounted to the DOM.
* Unlike `$effect`, the provided function only runs once.