pull/15844/head
Rich Harris 1 year ago
commit aff0f504ab

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

@ -84,6 +84,12 @@ The `flushSync()` function can be used to flush any pending effects synchronousl
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
### get_abort_signal_outside_reaction
```
`getAbortSignal()` can only be called inside an effect or derived
```
### hydration_failed
```

@ -56,6 +56,10 @@ The `flushSync()` function can be used to flush any pending effects synchronousl
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
## get_abort_signal_outside_reaction
> `getAbortSignal()` can only be called inside an effect or derived
## hydration_failed
> Failed to hydrate the application

@ -69,7 +69,7 @@ if (DEV) {
*/
export function getAbortSignal() {
if (active_reaction === null) {
throw new Error('TODO getAbortSignal can only be called inside a reaction');
e.get_abort_signal_outside_reaction();
}
return (active_reaction.ac ??= new AbortController()).signal;

@ -37,20 +37,7 @@ export async function tick() {}
export async function settled() {}
/** @type {AbortController | null} */
let controller = null;
export function getAbortSignal() {
if (controller === null) {
const c = (controller = new AbortController());
queueMicrotask(() => {
c.abort();
controller = null;
});
}
return controller.signal;
}
export { getAbortSignal } from './internal/server/abort-signal.js';
export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js';

@ -34,7 +34,11 @@ export const PROXY_PATH_SYMBOL = Symbol('proxy path');
export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');
export const STALE_REACTION = Symbol('stale reaction');
// allow users to ignore aborted signal errors if `reason.name === 'StaleReactionError`
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;

@ -204,12 +204,29 @@ export function flush_sync_in_effect() {
const error = new Error(`flush_sync_in_effect\nCannot use \`flushSync\` inside an effect\nhttps://svelte.dev/e/flush_sync_in_effect`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/flush_sync_in_effect`);
}
}
/**
* `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}
@ -319,6 +336,7 @@ export function set_context_after_init() {
const error = new Error(`set_context_after_init\n\`setContext\` must be called when a component first initializes, not in a subsequent effect or after an \`await\` expression\nhttps://svelte.dev/e/set_context_after_init`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/set_context_after_init`);

@ -306,6 +306,11 @@ export function update_reaction(reaction) {
reaction.ac = null;
}
if (reaction.ac !== null) {
reaction.ac.abort(STALE_REACTION);
reaction.ac = null;
}
try {
reaction.f |= REACTION_IS_UPDATING;
var result = /** @type {Function} */ (0, reaction.fn)();

@ -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 { 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 += `<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();
}
}
/**

@ -11,6 +11,7 @@ export function await_outside_boundary() {
const error = new Error(`await_outside_boundary\nCannot await outside a \`<svelte:boundary>\` with a \`pending\` snippet\nhttps://svelte.dev/e/await_outside_boundary`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/await_outside_boundary`);

@ -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}
Loading…
Cancel
Save