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: `

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, '

0

'); + + button.click(); + await new Promise((f) => setTimeout(f, 50)); + assert.htmlEqual(target.innerHTML, '

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 @@ + + + + +{#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.