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
2 changes: 2 additions & 0 deletions packages/svelte/src/index-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 6 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,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 {

Choose a reason for hiding this comment

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

nasty. effective. love it

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;
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
15 changes: 15 additions & 0 deletions packages/svelte/src/internal/server/abort-signal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { STALE_REACTION } from '#client/constants';

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

export function abort() {
if (controller !== null) {
controller.abort(STALE_REACTION);
controller = null;
}
}

export function getAbortSignal() {
return (controller ??= new AbortController()).signal;
}
71 changes: 38 additions & 33 deletions packages/svelte/src/internal/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 += `<style id="${hash}">${code}</style>`;
}
for (const { hash, code } of payload.css) {
head += `<style id="${hash}">${code}</style>`;
}

return {
head,
html: payload.out,
body: payload.out
};
return {
head,
html: payload.out,
body: payload.out
};
} finally {
abort();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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',
'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, '<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',
'StaleReactionError',
'The reaction that called `getAbortSignal()` was re-run or destroyed'
]);
}
});
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.name, signal.reason.message);
}

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}
Loading