Svelte suspense (request for comments)
See original GitHub issueAfter reading this issue, I came up with a Suspense component for Svelte, replicating the behaviour of React Suspense. No React Cache, no throwing promises, no modifying your component to fit a use case, just Svelte component composition. A demo is available in the corresponding GitHub repository. Note that I could not have the demo running in Svelte REPL due to some issues with loading the axios
package.
The behaviour of the Suspense component is implemented with the Kingly state machine library. The summary of ‘findings’ can be found here. For info, here is the underlying state machine specifying the suspense behaviour:
The demo showcases the API and I will quickly illustrate it here. The demo consists of loading a gallery of images. The suspense functionality is applied twice: when fetching the remote data containing the image URLs, and then for each image which is subsequently downloaded. While the remote data is fetched, a spinner will display if fetching takes more than a configurable time. Similarly, images placeholder will also display a spinner if downloading the image takes more than a configurable time.
Firstly, the suspense functionality for the remote data fetching is implemented as follows:
<script>
... a bunch of imports
const iTunesUrl = `https://itunes.apple.com/in/rss/topalbums/limit=100/json`;
function fetchAlbums(intents){
const {done, failed} = intents;
axios.get(iTunesUrl)
.then(res => res.data.feed.entry)
.then(done)
.catch(failed)
}
</script>
<div class="app">
<Header />
<div class="albums">
<Suspense task={fetchAlbums} let:data={albums} timeout=10>
<div slot="fallback" class="album-img">
<img alt="loading" src="https://media.giphy.com/media/y1ZBcOGOOtlpC/200.gif" />
</div>
<div slot="error" class="album-img">
<h1>ERROR!</h1>
</div>
<LazyLoadContainer>
{#if albums}
{#each albums as album, i}
<LazyLoad id="{i}">
<Album {album} />
</LazyLoad >
{/each}
{/if }
</LazyLoadContainer>
</Suspense>
</div>
</div>
Note that the fetch task and minimum time (timeout
) before displaying the spinner is passed as parameters of the Suspense
component, while the fetched data is exposed to the slot component through the data
property. Note also how the fetching function is passed the done
and failed
callback to signal successful completion or error of the remote fetching.
The fallback slot is displayed when the timeout is expired. The error slot is displayed when fetching the data encounters an error.
Secondly, the Album
component suspense functionality is implemented as follows:
<ul class="album">
<li class="album-item">
<Suspense let:intents={{done, failed}} timeout=0>
<div slot="fallback" class="album-img">
<img alt="loading" src="https://media.giphy.com/media/y1ZBcOGOOtlpC/200.gif" />
</div>
<a href={link} target="blank" class="link">
<img class="album-img"
on:load={done}
src={image}
alt={'itunes' + Math.random()} />
</a>
</Suspense>
</li>
<li class="title album-item">
<a href={link} target="blank" class="link">
{title.slice(0, 20)}..</a></li>
<li class="price album-item">Price:{price}</li>
<li class="date album-item">Released:{formatDate(date, "MMM Do YY")}</li>
</ul>
This time the Suspense
component passes done
and failed
callbacks to its children slots. When the image is loaded, the done
callback is run.
This works well and I believe the API separates well the suspense functionality or concern from the slots. What we basically have is parent and children components communicating through events, except that the event comes included in the callback. As the demo shows, there is also no issues with nesting Suspense
components.
This GitHub issues has two purposes:
- gettign feedback on the API
- giving feedback on Svelte slot composition
The first point is more about hearing from you guys.
About the second point:
- slot composition is a powerful and flexible mechanism, specially in conjunction with scoped slots
- however, a few things would be nice:
being able to operate on the slot as if it were a regular html element. This mean the ability to style a slot with classes or possibly other attributes (Add some extra attributed to cover generic needs, i.e. needs that are independent of the content of the slot. To implement the suspense functionality I had to resort to hide the default slot with<slot class='...' style='...'> </slot>
).display:none
. Unfortunately to do that I had to wrap the slot around adiv
element, which can have side effects depending on the surrounding css. A syntax like<slot show={show}> </slot>
would have been ideal. After thinking some more, I think that slot cannot be considered as regular HTML elements but as an abstract container for such elements. The operations allowed on slots should be operation on the container, not on the elements directly. Styling or adding classes an abstract container does not carry an obvious meaning, as the container is not a DOM abstraction. The current operations I see existing on the container areget
(used internally by Svelte to get the slot content),show
could be another one. The idea is that if you have a Container type, and a Element type, your container isC<E>
. If you do operations that are independents ofE
, you can do only a few things like use E (get
), ignore E (don’t use the slot), repeat E (not sure how useful that would be), conditionally use E (show
, of type Maybe<E>). Using any knowledge about theE
I think leads to crossing abstraction boundaries which might not be a good thing future-wise.- having slots on component just like if components were regular elements
- having dynamic slots. In the
Suspense
component, I useif/then/else
to pick up the slot to display, which works fine (see code below). It would be nice however to have<slot name={expression ...}>
:
{#if stillLoading }
<slot name="fallback" dispatch={next} intents={intents} ></slot>
{:else if errorOccurred }
<slot name="error" dispatch={next} intents={intents} data={data}></slot>
{:else if done }
<slot dispatch={next} intents={intents} data={data}></slot>
{/if}
<div class="incognito">
<slot dispatch={next} intents={intents} ></slot>
</div>
I am not really strong about the dynamic slots. It might add some complexity that may be best avoided for now. The first and second point however I believe are important for abstraction and composition purposes. My idea is to use Svelte components which only implement behaviour and delegate UI to their children slots (similar to Vue renderless components). Done well, with this technique you end up with logic in logic components, and the view in stateless ui elements.
The technique has additionally important testing benefits (the long read is here).
For instance the behaviour of the Suspense
state machine can be tested independently of Svelte - and the browser, and with using a state machine, tests can even be automatically generated (finishing that up at the moment). Last, the state machine library can compile itself away just like Svelte does 😃 (the implementation is actually using the compiled machine).
About testing stateless components, Storybook can be set to good purpose. What do you Svelte experts and non experts think? I am pretty new with Svelte by the way, so if there is any ways to do what I do better, also please let me know.
Issue Analytics
- State:
- Created 4 years ago
- Reactions:24
- Comments:27 (1 by maintainers)
Perhaps my misunderstanding, but I understand the key benefit of Suspense to be the inversion of control (children control the loading/entering the suspense state). It isn’t difficult today to conditionally render child content based on whether its loaded, to show previous state, or a loading indicator on a cancelable timer. You can always push that to the parent to achieve this effect. What makes Suspense interesting is the parent goes “Here’s a placeholder. Children do whatever you need to do, I don’t even need to know what exactly. I’m here to support you and make sure you don’t embarrass yourselves.” We can probably avoid throwing Promises, but I think we need to at minimum execute the children without showing them if necessary. This makes everything more complicated like conditional rendering in between the Suspense placeholder and the asynchronous call. But that’s the problem we need to solve.Thats what makes this interesting.
Yeah a lot has changed in understanding (and even the React implementation) since the original post was made. I think things have been better defined now and Svelte should be able to better make a decision where to go here.
Vue/Preact decided to not follow React all the way down the concurrent mode rabbit hole. In fact, what originally was considered a single thing is now arguably a couple different but related features.
The idea here is that in order to maintain async consistency we can’t let an in flight change be visible outside the scope of that change. A more concrete example might be picturing a “Like” button on a User information carousel, where you click next to load the next user. In the ideal world proposed here, one could click next starting the rendering and loading of the next user off screen, and then the enduser can click like button while this is happening and still have the current in view user record be the one that is liked as the updated user id won’t have propagated yet.
Basically both rendering the next possible state (to perform data loads), while showing the current state that is completely interactive without causing any inconsistencies both in data or in visual tearing. Ie… the page number doesn’t update until everything is completed. This is the benefit of transitions.
Now React has refined this further stating that even under transitions it is only previously rendered Suspense boundaries that hold, and newly created ones instead fall to the fallback state (ie any async reads under them are not counted towards the transition). In so the enduser has the ability to create the boundaries and nested boundaries of where you want to hold or show placeholders while it frees up things like routers or design systems to just blindly implement transitions into their controls. If those transitions never trigger something async that is read under a Suspense boundary they are basically identity functions that do nothing. However if the application implements Suspense they tap into a much richer system for controlled orchestration of both placeholders and held(stale) states.
This seems complicated and it is, but the introduction of stale states save from excessive falling back to loading placeholders. You can picture with tab navigation if you are already looking at a tab and click the next one you don’t need to replace the whole view with a placeholder… a simple indicator of the stale state would be much less jarring. Browsers natively do this on page navigation. Even if a page is slow to load if it were server-rendered (ie the HTML all present when it does load) it skips ever showing the white screen. You see the current page, then some sort of loading indicator and then bam the new page. We are less accustomed to this these days because of all the client-side rendering going on and forced loading states. But the native browser handles this pretty seamlessly.
So I know reactive frameworks like Svelte (and I’ve made the same wager with the reactive frameworks I am maintaining) don’t need transitions to get this sort of rendering. I already have it working in both of mine without (even though I have implemented transitions in one and intend to in the other). So the 3rd feature I think is unneed here.
Analysis:
If desirable it’s probably fine to go ahead with 1 and forget about it. Mind you it is a lot less interesting for a library that already has an
await
control flow. Like just hoist the await, pretty easy way to get the placeholder in the right spot. Like if you not concerned with trying to mimic the transition behavior of stale states, you don’t need to worry about preserving anything. Vue and Preact added this Suspense component because they didn’t haveawait
.Suspense is a bit slicker to be sure but most of that comes in actually burying the promise in a resource primitive when it comes to data loading. You can basically treat it like a typical Svelte store and not even write the clearly async
await
blocks in the child code. A subscription to that store under a Suspense boundary is sufficient and child components can trigger Suspense without even being the wiser they are dealing with async data. I mean it’s powerful but is it worth adding a new primitive type… a new sort of Svelte store if you may. It does have to be one since it needs to be able to persist cross component boundaries. This is a lot for only a portion of the story, a portion already mostly taken care of by Svelte.The second feature is mind warping and really what people have been referring to when talking to this in the past. Rich in an issue a while back was talking about how to encode change sets as a way to achieve this. I’ve been down that road too but ultimately decided that forking the reactive primitives into transactions(while holding updating existing side effects) is probably the best way to achieve this in a reactive system. But let’s face it, again this sort of quantum reactivity is a steep price for basically a navigation trick. It’s a freaking cool navigation trick but you have to ensure large parts of your system are side-effect-free. This is all the grumbling you hear about the concurrent mode in React.
There are other challenges being a compiled language. It’s doable with dedicated reactive primitives but for a language it is a bit trickier. You could reserve syntax for it. Like $: with await’s in it could basically create the new primitives under the hood but would you really want to introduce the equivalent of the
useTransition
API. I mean you need it if you want this behavior to be opt in and I personally feel it needs to be opt-in as being on by default is nonsensical for end users since you’d in some cases always need to show them data in the past even after they thought they updated it. I will leave it up to the minds here to decide whether that is suitable for Svelte. But I suspect this whole thing brings with it a sort of weight that might be best left on the table. Not to say it isn’t beneficial, but does it align with Svelte’s goals?