feat: add `getAbortSignal()` (#16266)

* WIP getAbortSignal

* add test

* regenerate

* add error code

* changeset

* regenerate

* try this

* { stale: true }

* fix test

* lint

* abort synchronously in SSR

* make STALE_REACTION a `StaleReactionError extends Error`

* make non-optional

* Update packages/svelte/src/internal/server/abort-signal.js

Co-authored-by: Elliott Johnson <sejohnson@torchcloudconsulting.com>

---------

Co-authored-by: Elliott Johnson <sejohnson@torchcloudconsulting.com>
pull/16140/merge
Rich Harris 2 months ago committed by GitHub
parent 7c8be602be
commit b673145659
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: add `getAbortSignal()`

@ -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 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 ### hydration_failed
``` ```

@ -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 > 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 ## hydration_failed
> Failed to hydrate the application > Failed to hydrate the application

@ -1,7 +1,7 @@
/** @import { ComponentContext, ComponentContextLegacy } from '#client' */ /** @import { ComponentContext, ComponentContextLegacy } from '#client' */
/** @import { EventDispatcher } from './index.js' */ /** @import { EventDispatcher } from './index.js' */
/** @import { NotFunction } from './internal/types.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 { is_array } from './internal/shared/utils.js';
import { user_effect } from './internal/client/index.js'; import { user_effect } from './internal/client/index.js';
import * as e from './internal/client/errors.js'; import * as e from './internal/client/errors.js';
@ -44,6 +44,37 @@ if (DEV) {
throw_rune_error('$bindable'); 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. * `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. * Unlike `$effect`, the provided function only runs once.

@ -35,6 +35,8 @@ export function unmount() {
export async function tick() {} export async function tick() {}
export { getAbortSignal } from './internal/server/abort-signal.js';
export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js'; export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js';
export { createRawSnippet } from './internal/server/blocks/snippet.js'; export { createRawSnippet } from './internal/server/blocks/snippet.js';

@ -27,6 +27,12 @@ export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol(''); export const LOADING_ATTR_SYMBOL = Symbol('');
export const PROXY_PATH_SYMBOL = Symbol('proxy path'); 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 ELEMENT_NODE = 1;
export const TEXT_NODE = 3; export const TEXT_NODE = 3;
export const COMMENT_NODE = 8; export const COMMENT_NODE = 8;

@ -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 * Failed to hydrate the application
* @returns {never} * @returns {never}

@ -53,7 +53,8 @@ export function derived(fn) {
rv: 0, rv: 0,
v: /** @type {V} */ (null), v: /** @type {V} */ (null),
wv: 0, wv: 0,
parent: parent_derived ?? active_effect parent: parent_derived ?? active_effect,
ac: null
}; };
if (DEV && tracing_mode_flag) { if (DEV && tracing_mode_flag) {

@ -32,7 +32,8 @@ import {
HEAD_EFFECT, HEAD_EFFECT,
MAYBE_DIRTY, MAYBE_DIRTY,
EFFECT_HAS_DERIVED, EFFECT_HAS_DERIVED,
BOUNDARY_EFFECT BOUNDARY_EFFECT,
STALE_REACTION
} from '#client/constants'; } from '#client/constants';
import { set } from './sources.js'; import { set } from './sources.js';
import * as e from '../errors.js'; import * as e from '../errors.js';
@ -106,7 +107,8 @@ function create_effect(type, fn, sync, push = true) {
prev: null, prev: null,
teardown: null, teardown: null,
transitions: null, transitions: null,
wv: 0 wv: 0,
ac: null
}; };
if (DEV) { if (DEV) {
@ -397,6 +399,8 @@ export function destroy_effect_children(signal, remove_dom = false) {
signal.first = signal.last = null; signal.first = signal.last = null;
while (effect !== null) { while (effect !== null) {
effect.ac?.abort(STALE_REACTION);
var next = effect.next; var next = effect.next;
if ((effect.f & ROOT_EFFECT) !== 0) { if ((effect.f & ROOT_EFFECT) !== 0) {
@ -478,6 +482,7 @@ export function destroy_effect(effect, remove_dom = true) {
effect.fn = effect.fn =
effect.nodes_start = effect.nodes_start =
effect.nodes_end = effect.nodes_end =
effect.ac =
null; null;
} }

@ -40,6 +40,8 @@ export interface Reaction extends Signal {
fn: null | Function; fn: null | Function;
/** Signals that this signal reads from */ /** Signals that this signal reads from */
deps: null | Value[]; deps: null | Value[];
/** An AbortController that aborts when the signal is destroyed */
ac: null | AbortController;
} }
export interface Derived<V = unknown> extends Value<V>, Reaction { export interface Derived<V = unknown> extends Value<V>, Reaction {

@ -22,7 +22,8 @@ import {
ROOT_EFFECT, ROOT_EFFECT,
LEGACY_DERIVED_PROP, LEGACY_DERIVED_PROP,
DISCONNECTED, DISCONNECTED,
EFFECT_IS_UPDATING EFFECT_IS_UPDATING,
STALE_REACTION
} from './constants.js'; } from './constants.js';
import { flush_tasks } from './dom/task.js'; import { flush_tasks } from './dom/task.js';
import { internal_set, old_values } from './reactivity/sources.js'; import { internal_set, old_values } from './reactivity/sources.js';
@ -276,6 +277,11 @@ export function update_reaction(reaction) {
reaction.f |= EFFECT_IS_UPDATING; reaction.f |= EFFECT_IS_UPDATING;
if (reaction.ac !== null) {
reaction.ac.abort(STALE_REACTION);
reaction.ac = null;
}
try { try {
var result = /** @type {Function} */ (0, reaction.fn)(); var result = /** @type {Function} */ (0, reaction.fn)();
var deps = reaction.deps; var deps = reaction.deps;

@ -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;
}

@ -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 { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js';
import { reset_elements } from './dev.js'; import { reset_elements } from './dev.js';
import { Payload } from './payload.js'; import { Payload } from './payload.js';
import { abort } from './abort-signal.js';
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
// https://infra.spec.whatwg.org/#noncharacter // https://infra.spec.whatwg.org/#noncharacter
@ -66,50 +67,54 @@ export let on_destroy = [];
* @returns {RenderOutput} * @returns {RenderOutput}
*/ */
export function render(component, options = {}) { 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; const prev_on_destroy = on_destroy;
on_destroy = []; on_destroy = [];
payload.out += BLOCK_OPEN; payload.out += BLOCK_OPEN;
let reset_reset_element; let reset_reset_element;
if (DEV) { if (DEV) {
// prevent parent/child element state being corrupted by a bad render // prevent parent/child element state being corrupted by a bad render
reset_reset_element = reset_elements(); reset_reset_element = reset_elements();
} }
if (options.context) { if (options.context) {
push(); push();
/** @type {Component} */ (current_component).c = options.context; /** @type {Component} */ (current_component).c = options.context;
} }
// @ts-expect-error // @ts-expect-error
component(payload, options.props ?? {}, {}, {}); component(payload, options.props ?? {}, {}, {});
if (options.context) { if (options.context) {
pop(); pop();
} }
if (reset_reset_element) { if (reset_reset_element) {
reset_reset_element(); reset_reset_element();
} }
payload.out += BLOCK_CLOSE; payload.out += BLOCK_CLOSE;
for (const cleanup of on_destroy) cleanup(); for (const cleanup of on_destroy) cleanup();
on_destroy = prev_on_destroy; 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) { for (const { hash, code } of payload.css) {
head += `<style id="${hash}">${code}</style>`; head += `<style id="${hash}">${code}</style>`;
} }
return { return {
head, head,
html: payload.out, html: payload.out,
body: payload.out body: payload.out
}; };
} finally {
abort();
}
} }
/** /**

@ -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'
]);
}
});

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

@ -348,6 +348,30 @@ declare module 'svelte' {
*/ */
props: Props; 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. * `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. * Unlike `$effect`, the provided function only runs once.

Loading…
Cancel
Save