Skip to content
5 changes: 5 additions & 0 deletions .changeset/short-fireants-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: add `getAbortSignal()`
6 changes: 6 additions & 0 deletions documentation/docs/98-reference/.generated/client-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/messages/client-errors/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion packages/svelte/src/index-client.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
* <script>
* import { getAbortSignal } from 'svelte';
*
* let { id } = $props();
*
* async function getData(id) {
* const response = await fetch(`/items/${id}`, {
* signal: getAbortSignal()
* });
*
* return await response.json();
* }
*
* const data = $derived(await getData(id));
* </script>
* ```
*/
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.
Expand Down
16 changes: 16 additions & 0 deletions packages/svelte/src/index-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { current_component } from './internal/server/context.js';
import { noop } from './internal/shared/utils.js';
import * as e from './internal/server/errors.js';
import { STALE_REACTION } from '#client/constants';

/** @param {() => void} fn */
export function onDestroy(fn) {
Expand Down Expand Up @@ -35,6 +36,21 @@ export function unmount() {

export async function tick() {}

/** @type {AbortController | null} */
let controller = null;

export function getAbortSignal() {
if (controller === null) {
const c = (controller = new AbortController());
queueMicrotask(() => {
c.abort(STALE_REACTION);
controller = null;
});
}

return controller.signal;
}

export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js';

export { createRawSnippet } from './internal/server/blocks/snippet.js';
3 changes: 3 additions & 0 deletions packages/svelte/src/internal/client/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ 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 = { stale: true };

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this but I think it needs some refinement... right now refined users of the best typed systems would have to do something like this:

promise.catch(e => {
  if (typeof e === 'object' && Object.hasOwn(e, 'stale') && e.stale === true) {
    // stuff
  }
})

We either need to export an isStaleReactionAbortError helper that does this and types it correctly or change tact. I would also think about making this a class extending Error as right now it would fail an if (e instanceof Error) check which is always a bad feeling when catching an error...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm. this is exactly the rabbit hole I wanted to avoid. my hunch/hope is that there will be vanishingly few situations where you actually need to do this check, and so my thinking was that we can punt on this for now (while adhering to reason.stale being true if we do make it an error in future)

just don't want #15844 to be held up by unnecessary bikeshedding

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be fine punting on any type-related stuff but changing this to an error instance seems kinda essential, throwing non-Errors in any context where you expect users to catch them seems... bad


export const ELEMENT_NODE = 1;
export const TEXT_NODE = 3;
export const COMMENT_NODE = 8;
Expand Down
16 changes: 16 additions & 0 deletions packages/svelte/src/internal/client/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
3 changes: 2 additions & 1 deletion packages/svelte/src/internal/client/reactivity/deriveds.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 7 additions & 2 deletions packages/svelte/src/internal/client/reactivity/effects.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -478,6 +482,7 @@ export function destroy_effect(effect, remove_dom = true) {
effect.fn =
effect.nodes_start =
effect.nodes_end =
effect.ac =
null;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/internal/client/reactivity/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<V = unknown> extends Value<V>, Reaction {
Expand Down
8 changes: 7 additions & 1 deletion packages/svelte/src/internal/client/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (reaction.ac !== null) {
reaction.ac?.abort(STALE_REACTION);
reaction.ac = null;
}
reaction.ac?.abort(STALE_REACTION);
reaction.ac = null;

I don't think the check is buying us anything here right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's saving us from writing reaction.ac, which is more expensive than reading it. though it doesn't need to be optional


try {
var result = /** @type {Function} */ (0, reaction.fn)();
var deps = reaction.deps;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test } from '../../test';

export default test({
html: `<button>increment</button><p>loading...</p>`,

async test({ assert, target, variant, logs }) {
await new Promise((f) => setTimeout(f, 50));

if (variant === 'hydrate') {
assert.deepEqual(logs, ['aborted', { stale: true }]);
}

logs.length = 0;

const [button] = target.querySelectorAll('button');

await new Promise((f) => setTimeout(f, 50));
assert.htmlEqual(target.innerHTML, '<button>increment</button><p>0</p>');

button.click();
await new Promise((f) => setTimeout(f, 50));
assert.htmlEqual(target.innerHTML, '<button>increment</button><p>2</p>');

assert.deepEqual(logs, ['aborted', { stale: true }]);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script>
import { getAbortSignal } from 'svelte';

let count = $state(0);

let delayed_count = $derived.by(async () => {
let c = count;

const signal = getAbortSignal();

await new Promise((f) => setTimeout(f));

if (signal.aborted) {
console.log('aborted', signal.reason);
}

return c;
});
</script>

<button onclick={async () => {
count += 1;
await Promise.resolve();
count += 1;
}}>increment</button>

{#await delayed_count}
<p>loading...</p>
{:then count}
<p>{count}</p>
{:catch error}
{console.log('this should never be rendered')}
{/await}
24 changes: 24 additions & 0 deletions packages/svelte/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <script>
* import { getAbortSignal } from 'svelte';
*
* let { id } = $props();
*
* async function getData(id) {
* const response = await fetch(`/items/${id}`, {
* signal: getAbortSignal()
* });
*
* return await response.json();
* }
*
* const data = $derived(await getData(id));
* </script>
* ```
*/
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.
Expand Down