Skip to content

Mid run cancellation

As we hinted in the What is it? explainer one of the advantages of @sheepdog/svelte is that it allows you to really “cancel” a task. Let’s look at the actual problem and how it is solves with @sheepdog/svelte.

The problem

Promises are the de facto way to run asynchronous code in Javascript and (especially after the introduction of the async and await keywords) they are quite nice to work with.

// this automatically returns a promise
async function myStuff() {
//i can await for the execution of async code to happen
const response = await fetch('/api/my-endpoint');
const myEndpoint = await response.json();
return myEndpoint.data;
}

However, they have a big problem: once invoked there’s no way to stop the execution of the code. This can lead to performance problems in the simplest case or even bugs in more complex scenarios.

async function fetchALotOfStuff() {
const response = await fetch('/api/very-long-list');
const longList = await response.json();
// what if the user canceled the request before this step?
for (const element of longList) {
expensiveCalculation(element);
}
}

This is especially true if we are invoking those functions within a UI framework because we tend to assign values outside of the scope of the function to reactively show them in the ui.

<script>
let list;
function fetchList(){
const response = await fetch('/api/very-long-list');
list = await response.json();
}
</script>
<button on:click={fetchList}>fetch</button>
<ul>
{#each list as element}
<li>{element}</li>
{/each}
</ul>

The simplest way to solve this problem is to set up a variable and check it after the fetch.

<script>
let list;
let canceled = false;
function fetchList(){
canceled = false;
const response = await fetch('/api/very-long-list');
if(canceled) return;
list = await response.json();
}
</script>
<button on:click={fetchList}>fetch</button>
<button
on:click={() => {
cancel = true;
}}>cancel</button
>
<ul>
{#each list as element}
<li>{element}</li>
{/each}
</ul>

This works fine but it gets tedious pretty fast, especially if you need to do it multiple times. That’s where @sheepdog/svelte comes into play.

The solution(s)

@sheepdog/svelte provides you multiple tools to solve this problem (hence the parenthesized plural in the title of this section); let’s go over them one by one

Solution 1: AbortSignal

The simplest but more verbose solution to this problem is to use AbortSignal solution: @sheepdog/svelte invokes your task with a series of utils, one of which is the AbortSignal. Every task has its own AbortController and you can cancel a single task instance by invoking the cancel method on it or cancel every instance by invoking the cancelAll method on the task.

<script>
import { task } from "@sheepdog/svelte";
let list;
const fetchTask = task((_, { signal })=>{
// we can pass the signal to fetch to eventually abort the in-flight request
const response = await fetch('/api/very-long-list', { signal });
// and we can check if the signal is aborted and return to avoid side effects
if(signal.aborted) return;
list = await response.json();
});
let lastInstance;
</script>
<button
on:click={() => {
lastInstance = fetchTask.perform();
}}>fetch</button
>
<button
on:click={() => {
lastInstance.cancel();
}}>cancel</button
>
<button
on:click={() => {
fetchTask.cancelAll();
}}>cancel all</button
>
<ul>
{#each list as element}
<li>{element}</li>
{/each}
</ul>

We’ve gained the ability to stop in-flight fetches with the AbortSignal without having to create a separate canceled variable. That’s a win. But we can do better.

Solution 2: Async generators

Those who doesn’t know about generators might be a bit confused right know and those who know about them might be already running away in fear but please bear with us for a second and we will show you that generators are not really that scary.

A generator is a particular function in Javascript that is able to yield back the execution to the caller, the syntax to create one looks like this

function* ping() {
console.log('in the generator');
const value = yield 'pong';
console.log(`after yield: ${value}`);
}
const generator = ping();
const yielded = generator.next();
// logs: "in the generator"
console.log(yielded);
// logs: { done: false, value: "pong" }
generator.next('ping');
// logs: "after yield: ping"

I know, I told you this wouldn’t be scary and for the moment I haven’t keep my promise (pun intended). But the main takeaway from this snippet of code is to show that generator functions have a way to stop executing and return something to the caller and the caller has a way to communicate something back.

@sheepdog/svelte has been built to be able to accept an async generator function and, most importantly, has been built to make the generator function work basically like a normal async function if you change await with yield. Let’s take a look

let value;
const myTask = task(async function* (_, { signal }) {
const response = yield fetch('/api/my-endpoint', { signal });
value = yield response.json();
});

As you can see, the code in the two tabs the code changes very little but with generators @sheepdog/svelte has the ability to never call next if the task was canceled. This means that the if you cancel the task while fetch is still in-flight the second line of the function will never be called!

There’s one small detail we’ve hidden from you however: yield doesn’t work very well with Typescript, especially if there are multiple of them. If you try to paste that code in a .ts file (or in a svelte component with <script lang='ts'>) you will see all sort of errors. This is because Typescript doesn’t know which kind of data @sheepdog/svelte will pass back to the generator.

To fix this problem you can use yield as a sort of if+return

let value;
const myTask = task(async function* (_, { signal }) {
// use normal await here to get proper typing
const response = await fetch('/api/my-endpoint', { signal });
// yield after every await to allow `@sheepdog/svelte` to cancel the function
yield;
// pay attention to never assign directly to an await if you want to still have proper cancellation
const tempValue = await response.json();
yield;
value = tempValue;
});

Can we do better than this? Yes we can!

Solution 3: Async Transform

@sheepdog/svelte really cares about your DX and that’s why we have built a vite plugin that you can use to get the best of both words: the dynamic cancellation of generators and the expressivity and simplicity of async functions.

In short, what the vite plugin does is transform every async function inside a task to an async generator and it substitute every await with a yield. This fixes all our problems because the Typescript language server will resolve the types based on your actual code while at runtime @sheepdog/svelte will be able to cancel every task, even in the middle of an execution!

<script>
import { task } from "@sheepdog/svelte";
let list;
const fetchTask = task((_, { signal })=>{
const response = await fetch('/api/very-long-list', { signal });
// this line will never be executed if the task is canceled before fetch ends
list = await response.json();
});
let lastInstance;
</script>
<button
on:click={() => {
lastInstance = fetchTask.perform();
}}>fetch</button
>
<button
on:click={() => {
lastInstance.cancel();
}}>cancel</button
>
<button
on:click={() => {
fetchTask.cancelAll();
}}>cancel all</button
>
<ul>
{#each list as element}
<li>{element}</li>
{/each}
</ul>