3

I have a specific app which should do the following:

  1. You have a list of jobs to inspect
  2. When you click a job you get a detail view
  3. The detail view will poll the API to see live progress updates

I did a basic implementation in the following REPL: https://svelte.dev/repl/fcdce26dc0d843dbb4b394dcd2c838af?version=3.20.1

There are a couple of issues with this approach:

  1. The Job.svelte view should basically reset when you provide a new id, and clear out any previous poller, but now it's super awkward with a reactive statement at the bottom
  2. Because the poller does an asynchronous fetch it can happen that the timeout handler poller is cleared even though the handler is executing already. This causes multiple poller loops to occur (you can reproduce this by clicking through the job list at random intervals between 0 and 2 seconds)
  3. The current approach is not developer friendly and is easily broken. The 'bug' above can be fixed by keeping track of a reference/lock kind of thing but then it's even harder to wrap your head around.

For this use case, what is a better way of implementing it (in Svelte)?

Thanks a lot!

Wilco
  • 928
  • 3
  • 9
  • 20

3 Answers3

3

The Job.svelte view should basically reset when you provide a new id, and clear out any previous poller, but now it's super awkward with a reactive statement at the bottom

Like a previous reply already pointed out, this is the Svelte way. It's awkward at first, until you realize it's very simple, handy and elegant.

There are two things I would improve in your code:

  • do away with the messy cache per ID + single progress value assignment ; this is what causes your late fetches to display visually (a late fetch reply in itself is not a bug, displaying it to your user is, however) ; I simply renamed your cache object to progress and chose to display progress[id], this way a late fetch reply will update in the background, but not interfere visually with your currently displayed job
  • use setInterval instead of multiple setTimeout for periodic polling
<script>
    export let id

    let progress = {}
    let poller

    const setupPoller = (id) => {
        if (poller) {
            clearInterval(poller)
        }
        poller = setInterval(doPoll(id), 2000)
    }

    const doPoll = (id) => async () => {
        console.log(`polling ${id}`)
        progress[id] = await new Promise(resolve => setTimeout(() => {
            resolve((progress[id] || 0) + 1)
        }, 500))
    }

    $: setupPoller(id)
</script>

<div>
    <p>
        ID: {id}
    </p>
    <p>
        Progress: {progress[id] || 0}
    <p>
</div>

See this REPL

Thomas Hennes
  • 9,023
  • 3
  • 27
  • 36
  • Thanks a lot for your reply! It really helps to understand the Svelte way better. Do you have some resources (articles, blogs) for me to read more about this? A note about your suggested improvements: in the real-life use case I want to be able to respond to completion/failure from the back-end and implement things like exponential back-off, that's why I choose `setTimeout` instead of `setInterval`. – Wilco Apr 30 '20 at 16:15
  • 1
    @Wilco You are very welcome. Can't think of any specific article or blog, but I do follow the `@sveltejs` and `@SvelteSociety` accounts on Twitter. Svelte Society casted a conference last Sunday on a variety of topics (haven't had time to watch it yet but planning to): https://www.youtube.com/watch?v=0rnG-OlzGSs. As for the `setInterval` suggestion, it was just that, a suggestion. If your needs require a different approach, it's a perfectly valid reason. You could also have used sockets instead of polling and pushed progress updates. Many ways to skin a cat (however horrible that sounds) ;) – Thomas Hennes Apr 30 '20 at 18:34
1

I think 1. is just the way to go on Svelte. There is no ngOnChanges like in angular.

2./3. is a problem independent of Svelte I think. Asynchronous stuff with time races is always hard. Libraries like rxjs make it easier to handle this, but have a steep learning curve. An example:

<script>
  import { interval, Subject } from 'rxjs';
  import { switchMap, take, map, startWith, tap } from 'rxjs/operators';

  const cache = {};
  const id$ = new Subject();
  const progress$ = id$.pipe(
    // every time id$ gets a new id, start new interval
    switchMap(id => {
      return interval(1000).pipe(
        // every time the interval emits, do an api call
        switchMap(() => {
          // fake api call
          return interval(200).pipe(
            take(1),
            map(() => id + '::' + Math.random()),
            // store value in cache
            tap(value => cache[id] = value)
          );
        }),
        // start with cached value
        startWith(cache[id]),
      );
    })
  );

  let id = 1;
  function switchPoll() {
    id = id === 2 ? 1 : 2;
    id$.next(id);
  }
</script>

<p>{$progress$}</p>
<button on:click={switchPoll}>Switch</button>
dummdidumm
  • 4,828
  • 15
  • 26
  • This is cool! I've played around with rxjs before but I didn't think about it. Thanks for your reply! If the API call is taking a long time or I would like to do something like exponential back-off, a fixed interval of 1 second might not be a good idea. That's why I used `setTimeout` in the example, can you do something similar in rxjs? Thanks again! – Wilco Apr 30 '20 at 16:17
  • If you fear your API might take too long, you can use `exhaustMap` instead of `switchMap`. This means if the API takes more than 1 second, the next incoming intervals are ignored as long as the inner observable (aka api call) is not completed. Exponential backoff is a little harder to implement, see here for more https://stackoverflow.com/questions/53015170/exponential-backoff-implementation-with-rxjs . – dummdidumm May 04 '20 at 13:21
-1

I would suggest using the await blocks from svelte. With it, you have all the logic you need for asynchronous functionality out of the box, like generating a loading screen or catching errors. I modified your REPL with a custom timeout function (sleep) to simulate data fetching from a remote server:

<script>    
    let selectedJob;
    let progress = 0
        function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }

    const pollProgress = async (id) => {    
        if(progress < 10) {;
      await sleep(300)
            progress += 1
            return pollProgress(id)
        }
        return "Done"
    }   

    function handleClick(id) {
        selectedJob = id
        promise = pollProgress(id);
    }
    let promise
</script>

<style>
    .list {
        background-color: yellow;
        cursor: pointer;
    }

    .details {
        background-color: cyan;
    }
</style>

<div class="list">
  {#each new Array(10).fill().map((_, i) => i) as i}
       <div on:click={() => (handleClick(i))}>
             Job {i}
         </div>
    {/each}
</div>

{#if selectedJob !== undefined}
    <div class="details">
        <h2>Job details</h2>
        <div>
        <p>
        ID: {selectedJob}
    </p>
{#await promise}
    <p>Progress: {progress}</p>
{:then res}
    <p>
        Progress: {res}
    </p>
{:catch error}
    <p style="color: red">{error.message}</p>
{/await}
</div>
    </div>
{/if}

It will display an updating progress to simulate polling while it is fetching the data and the string Done, which it receives as a result from the async function, when it is finished.

Gh05d
  • 7,923
  • 7
  • 33
  • 64
  • Thanks a lot for your reply! I would like to repeatedly poll for progress, not just once, so this solution does not fit my use case. – Wilco Apr 25 '20 at 10:26
  • 1
    I updated the repl and my code with a recursive function with simulates the wished polling functionality. Please take a look. – Gh05d Apr 25 '20 at 23:36
  • Thanks for the updated code! It's an interesting approach to recursively spawn a new Promise, I haven't thought about that. However, the progress is now shared between jobs and if I switch jobs and print in the console I can see the promise chains are not interrupted, this means that your example suffers from the same issues that I described in the initial post. – Wilco Apr 30 '20 at 16:11