sveltejs / svelte

Cybernetically enhanced web apps
https://svelte.dev
MIT License
78.22k stars 4.09k forks source link

Svelte 5: make {#await} blocks support async stores that can also emit errors, such as the observables from RxJS #10227

Open Evertt opened 7 months ago

Evertt commented 7 months ago

Describe the problem

Context

With Svelte 5's adoption of signals, it's crucial to highlight the complementary role of observables. While signals efficiently handle simple and synchronous state, observables excel in managing complex, asynchronous states without creating spaghetti code.

Problem

Svelte’s current templating syntax offers native support for auto- subscribing and unsubscribing from both their built-in stores and RxJS's observables, but it doesn't offer native support for either the asynchronicity of RxJS's observables nor their error emissions. But Svelte does already offer the {#await} block for promises, which could also work perfectly for these observables.

Describe the proposed solution

Enhance the {#await} block to support observables that are asynchronous and emit errors, bridging the gap between signals and observables for more complex state management.

<script>
    import { BehaviorSubject } from 'rxjs';
    import { newTypeAheadStore } from './type-ahead.js'

    const searchQuery = new BehaviorSubject('')
    searchQuery.set = searchQuery.next // tiny necessary hack

    // this function creates a new observable that is debounced
    // and maps to the response to a search request done with `fetch`
    let searchResults = newTypeAheadStore(searchQuery)
</script>

<input type="text" placeholder="Type to search..." bind:value={$searchQuery}>

{#await searchResults}
    <p>Loading...</p>
{:then $searchResults}
    <pre>{JSON.stringify($searchResults, null, 2)}</pre>
{:catch error}
    <p>The following error occurred:</p>
    <p>{error.message}</p>
    <button onclick={() => searchResults = newTypeAheadStore(searchQuery)}>
        Click here to try again
    </button>
{/await}

(If you'd like to see the code in newTypeAheadStore() you can look at this REPL)

Explanation

The searchResults observable initially takes over half a second to emit its first value, displaying a Loading... message to the user during this period. Once it emits the first value, a <pre> block is shown, updating continuously with new results.

If an error occurs, particularly during a fetch operation, the {:catch} block is displayed. Post-error, searchResults stops emitting values and requires re-initialization through newTypeAhead() function. Upon re-initialization, the loading message displays again before showing updated results.

This concept also applies to Svelte stores. While they may not emit errors, their initial undefined state can be interpreted as a loading phase. The loading block remains until the store's state changes from undefined, after which the {:then} block is rendered, without reverting to the loading state, even if the store returns to undefined.

Slightly more advanced version (I'm okay if this doesn't get implemented)

RxJS's observables can also complete, after which they will no longer emit values. This is probably not relevant in most cases. But in some cases, we might want to give the user the option to re-subscribe to the observable to get a new stream of values. For that we could implement something like this:

{:then $someStream, completed}
    <pre>{JSON.stringify($someStream, null, 2)}</pre>

    {#if completed}
        <p>The stream has completed</p>
        <button onclick={() => someStream = startNewStream()}>
            Restart stream
        </button>
    {/if}
{/await}

Importance

nice to have

dummdidumm commented 7 months ago

I'm not sure if adding this to #await is the right move. I think we should instead investigate whether or not we want to have some first-class way to turn a subscribable into a value when called at component initialization. Right now it's pretty easy to do in userland though - you could create your own wrapper which handled pending, error states etc however you want so that you can use the result within an #if-#else block.

Evertt commented 7 months ago

I'm not sure if adding this to #await is the right move.

Why not though? The {#await} block offers the precise semantic building blocks that one would want for an asynchronous observable that can emit an error.

Also, yes of course with the right wrapper and the {#if} block you can make everything work. But this:

<script>
    import { createQuery } from '@tanstack/svelte-query';

    const query = createQuery({
        queryKey: ['todos'],
        queryFn: () => fetchTodos(),
    });
</script>

{#if $query.isLoading}
    <p>Loading...</p>
{:else if $query.isError}
    <p>Error: {$query.error.message}</p>
{:else if $query.isSuccess}
    {#each $query.data as todo}
        <p>{todo.title}</p>
    {/each}
{/if}

Is just so much more ugly than this:

<script>
    import { createQuery } from '@tanstack/svelte-query';

    const query = createQuery({
        queryKey: ['todos'],
        queryFn: () => fetchTodos(),
    });
</script>

{#await query}
    <p>Loading...</p>
{:then todos}
    {#each todos as todo}
        <p>{todo.title}</p>
    {/each}
{:catch error}
    <p>Error: {error.message}</p>
{/if}
SamMousa commented 2 months ago

While I do like the idea of supporting this natively in {#await} there is an alternative approach to solving this in userland.

function makePromiseLike<T>(observable: Observable<T>): Observable<T>&PromiseLike<T> {
    obs.then = (complete, error) => firstValueFrom(obs).then(complete, error);
    return obs;
}

The above function makes any observable Thenable, which is what SvelteJS checks for: https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/internal/shared/utils.js#L11

Alternatively, just wrap your observable like this:

 {#await firstValueFrom(name) }
        Waiting for operator name
    {:then _}
        { $name }
    {/await}

It's still not ideal but definitely workable in my opinion.

I'm not sure if the example given by @dummdidumm is valid. Svelte observables are like RxJs an BehaviorSubject, they always have a value, so awaiting them is never needed.

Evertt commented 2 months ago

@SamMousa I actually did implement something like your makePromiseLike() function to get it to work in my personal project. I even cheated a little bit, because since promises are always async and SvelteKit SSR does not wait for any async operation, it meant that the {#await promiseLikeObservable} block would always render the loading block during SSR, even if the data had in fact already been prefetched and could've been rendered during SSR.

I don't exactly remember how I cheated. I either created a makePromiseLike() function which would check whether the BehaviorSubject already had anything other than undefined in its .value. And if that was the case then my makePromiseLike() would sneakily make the newly added .then() method synchronous instead of asynchronous.

But that was fragile, because it worked in some contexts (such as the {#await} block, but it would result in a runtime error everywhere else where the JS runtime expects a .then() function to be asynchronous by definition. So then at first I wrote some more super fragile code that would try to interpret from new Error().stack whether the .then() was called by Svelte from an {#await} block. Which was of course super sketchy, because after minification all function names were reduced to just a couple random letters.

In the end I settled on {#await $store || firstValueFrom(store)}, which worked much more robustly, but was still ugly and not DX friendly at all imo. So still, having Svelte just support this natively would be so nice.