sveltejs / svelte

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

Svelte suspense (request for comments) #3203

Open brucou opened 5 years ago

brucou commented 5 years ago

After 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.

svelte suspense demo

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:

suspense machine

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:

The first point is more about hearing from you guys.

About the second point:

{#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.

ryansolid commented 5 years ago

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.

brucou commented 5 years ago

@ryansolid I would agree with most of that. Suspense in React is generic in the sense that its behaviour does not depend on its children. Children indeed signal (through promise sending) something to their parent, with the parent deciphering the meaning. For now it is just a 'not ready' signal but it could be anything, this is really a communication children - parent that is occurring here. I know only three ways of doing that: shared state, events, and passing continuations (callbacks, coroutines etc.), each with its own set of tradeoff.

The <suspense> component I propose is doing the same, except that the communication child - parent happens through callbacks (done, failed) which are injected by the parent. It could also happen through events (dispatch event emitter is also injected, and done is in fact sugar for dispatch(DONE_EVENT)).

One advantage of throwing a promise is that it seems to magically remove the necessity for a communication interface, e.g. there is no need to inject anything in the child as throw comes with JavaScript just like window comes with the browser (and the swallows with the spring? I digress). On the downside, you are producing an effect on the whole program execution (modifying the call stack etc.), and well you are changing however slightly the pragmatics of exceptions. Using throw as an event emitter, and exception bubbling on the call stack is an original albeit transgressive idea but I would wait and see what consequences that brings in the future.

We can probably avoid throwing Promises, but I think we need to at minimum execute the children without showing them if necessary.

That exactly what I do. Children are wrapped in a display:none styled div and set in the template, i.e. they are executed. The issue I describe in my OP is that adding the div to hide the slot content has side effects on the styling precisely so in some cases, the children will temporarily render poorly.

Here is the displaying code:

<style>
.incognito {
  display: none;
  border: 0;
  padding: 0;
  margin: 0;
}
.cognito {
  display: inherit;
  border: 0;
  padding: 0;
  margin: 0;
}
</style>

{#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>

This behaviour is illustrated in the example I gave:

<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>

The <a href={link} > links need to be displayed (executed) for the download of the image to start.

That brings me to the last point which is about another advantage of the Suspense I propose over React:

brucou commented 5 years ago

@ryansolid After rereading your message, actually I think I do not understand that part:

I understand the key benefit of Suspense to be the inversion of control (children control the loading/entering the suspense state).

In most of the suspense example I have seen, the loading state is entered immediately (fetch happening when the component is mounted, or React Cache used immediately). It is of course possible to have a suspended component which throw its promise post rendering or mounting but I wonder how that would work. Did anybody try that. I am curious.

Anyways, in the Suspense component I propose, a task if any is run on mounting the children, and the results (if any) of this task are passed to the children. A different logic is easy to implement by just changing the state machine. The whole control flow logic is embedded in the machine. In fact, if you remove the Spinning state from the machine, you have the machine for a Promise. So that Suspense machine is just Promise with a twist. You can add as many twists as you like.

ryansolid commented 5 years ago

It's entered immediately often (although not always), but the child is responsible for loading the data. So even if there is a cache etc, it's the execution of the child that actually does the async request etc. The Suspense element itself is above it in the hierarchy(usually a different component) and isn't aware of the specifics. So when the execution starts in the parent and you execute the Suspense element your aren't suspended, but when the downstream child is executed and does an async request that is the point at which it is realized. So from that perspective, the image loading example holds up, but the data loading one where the parent doing the fetch is too coupled I think.

The original example for React I believe was roughly the following. Pretend we have a Component on the page that has a navigation bar and a main content section. As I change the navigation the content changes and may or may not have different async data needs. On a change of navigation I want to show the existing content until the new navigation has fully loaded, and if it takes too long show a spinner.

So we implement this by wrapping the content section with our Suspense Component. Lets say after 300ms it shows a spinner until the section fully loads. So say one child loads feeds, one loads albums. We start by rendering the feeds page. It realizes that we need to do a data load and triggers Suspense. When it is complete we see the feeds content. When the user clicks "Albums" we update the navigation highlight (perhaps with a sliding animation that takes 300ms). It triggers the navigation change loading the Albums Component doing a different data load triggering the parent Suspense. However we still show the Feeds content. If it takes over 300ms we show we replace it with a spinner otherwise if less we replace the Feeds content with the Album content. At no point did we flash the page blank for 100s of ms or load an unnecessary spinner.

I think your approach could handle this. But the key is that the child have different data needs (one loads feeds, one loads albums) so the handling of that belongs with the Child Components and not with the parent container.

brucou commented 5 years ago

What you describe is the following flow:

Up to the last part it is fairly easy to implement, this is the standard event-action paradigm which characterizes reactive systems. The last part however does not have the form event => action. For instance, timeout has not expired is not an event, it is a piece of state.

Instead of routing let's consider a master-detail navigation. We have the following component tree rendered at t0:

<A>
  <B>
</A>

User clicks at t1:

<A>
  <B />               // Master
  <C invisible /> // Detail
</A>

Fetch is successful:

<A>
  <B />
  <C />
</A>

In this case, we are covered because we only add to the existing hierarchy. So the DO NOT REMOVE STUFF FROM DOM part is already the normal rendering behaviour. I mean by that what is displayed by A and B is not going anywhere.

If we talk about routing, from url1:

<A />

User clicks at t1 (no rendering, but fetch is ran) and we go to url2 (or later?):

<A />

Fetch Succeed event before Timer Expired event:

<B />

Timer Expired before Fetch Succeed arrives:

<Spinner />

I confess that I have no idea what is the behaviour of Svelte with respect to routing. Outside of Svelte, there are routing libraries which have asynchronous transitions, and that could probably implement the sought out functionality. But the functionality would be coupled to the router, so probably the fetch would have to be in the router, not in the component. In the same way, the Suspense functionality is integrated into React (because Error Boundaries are integrated into React),

ryansolid commented 5 years ago

Yeah, I was thinking as much. Any conditional(routing) in between the Suspense placeholder and Child Component Resolution would have to be Suspense aware. If this was solvable, I think pretty much any suspense case would be. If the Suspense API here was standardized for Svelte this could be something a Router provider could build in if they could observe the same state. Mind you they would need to run multiple paths at the same time. Without switching the display necessarily.

React's versions is absolute in that even changes to the current Component would not track during that period (since they interrupt rendering) but that might be unnecessary in practice. I also wonder React's Suspense is just a restrictive implementation of a subset of the use cases here and the real feature looks a little different. Especially when put against a different system (like Svelte).

In any case I think we understand each other. I think the Routing case can probably be a future consideration. The key part for me is is that any data fetching mechanism should be triggerable from below. Separating the display handling of fallbacks from the details of what goes on below.

brucou commented 5 years ago

If you would do client-side routing by hand, the behaviour you want is described by the following machine -- note how I reused the Suspense machine, by replacing the START triggering event by the route changed event:

Imgur

brucou commented 5 years ago

Actually I am thinking that maybe this could be solved if Svelte has a special NULL value for templates. e.g. when a template evaluates to empty, then don't update the DOM (I believe the current behaviour would be to detach the relevant pieces DOM).

So if from <A/> you should render <B/> but B after computation renders the special NULL value, then no op. Otherwise, detach A, attach B. So the action DO NOT REMOVE STUFF FROM DOM can be signaled to Svelte just like any other render actions. So the flow I mentioned above would become:

If I dare say, that would be analog to encoding exceptions in a return value instead of throwing.

sschottler commented 4 years ago

the key benefit of Suspense to be the inversion of control (children control the loading/entering the suspense state)

Excellent point and it helped me think more clearly about a presentation I had to give on suspense and concurrent mode.

it's the execution of the child that actually does the async request etc.

The key part for me is is that any data fetching mechanism should be triggerable from below.

What's interesting is that the inversion of control to children does not necessarily mean the children initiate the promise they are waiting on. That was emphasized more in their additional blog post:

https://reactjs.org/blog/2019/11/06/building-great-user-experiences-with-concurrent-mode-and-suspense.html

In the case of data fetching, they don't necessarily trigger the data fetch. They just check on the state of that request (such as calling read() on result returned by wrapPromise from the example code). Event handlers (route changes, tab clicks, button clicks, etc.) that trigger showing the view could initiate data fetches:

https://reactjs.org/blog/2019/11/06/building-great-user-experiences-with-concurrent-mode-and-suspense.html#fetch-in-event-handlers

So how do they maintain this inversion of control then? How do parents stay agnostic of children data dependencies to maintain loose-coupling/maintainability/etc.?

In the case of Relay and GraphQL, the components declare the "fragments" of data they need and then Relay analyzes them at build time and aggregates the fragments into top level queries that are kicked off on routing changes, etc. The initiating of the data fetch promise(s) is built into the router.

So children's data dependencies are still colocated with the component, but not necessarily the fetching of that data.

They also have a REST proof of concept:

https://github.com/gaearon/suspense-experimental-github-demo

In that example, the components have separate data files that allow them to colocate data dependencies. However, the top level aggregated parallel fetch on route change is not generated at build time and is instead written by hand. Example:

https://github.com/gaearon/suspense-experimental-github-demo/blob/master/src/UserPage.data.js

But in theory, they could be generated as well. If not, then you lose some of that loosely-coupled inversion of control. But if you generate them at build time like the Relay case, maybe not?

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.

I haven't gone through the relay docs and example app yet to fully understand how it works. I'm also curious about things like conditionally rendered components inside suspense boundaries and how that would work with these generated aggregated queries for a tree of components.

ryansolid commented 4 years ago

What's interesting is that the inversion of control to children does not necessarily mean the children initiate the promise they are waiting on.

Yeah I did not understand that at the time but they definitely do not need to do the async request themselves just trigger Suspense on read. This wasn't as clear before they released the more recent docs for data-loading.


Since last posting to this conversation, I've worked through designing a Suspense pattern that can be mimicked with a reactive library and I've implemented them in my reactive UI library(SolidJS).

The first piece comes down to understanding how rendering works in Svelte, as rendering has to be non-blocking. We need to ensure that Components are still able to go through initialization code without being visible. In Solid's case I do it before attaching to the DOM, but if we need to mount with Svelte it can still work by controlling visibility.

The next is having a means to setup reading of a Suspense state. I ended up using a Context API mechanism to do that making the Suspense Component essentially a Provider. From there specialized loading methods can read into that context to maintain suspense state. I am unsure of an easy way to handle useTransition equivalent with Svelte since it doesn't really match the DX experience, but there is probably another way to configure the timeout.

Finally all control flow directives in Suspense boundaries need to be aware of this same context. From there it is fairly easy to hold changes. It might make sense for this behavior to be opt in (some syntax addition) to allow better tree shaking since Suspense boundaries can be cross Component meaning that the compiler probably will have difficulty predicting this completely from the compilation step.

I assume the Svelte solution would need to be a little different but the fundamentals should be similar. If its any help the latter half of my medium article might shed some clues I'm not thinking of at the moment. This conversation helped me take the first steps along this path and I'd love to help any way I can.

brucou commented 4 years ago

@sschottler While I understand the general principles behind React Concurrent Mode, I am not sure I understand in the details how it works. Splitting work into chunks, and giving back the hand to the browser for user event handling obviously improves the reactivity of the application. What I don't understand is how the back pressure issues that it creates are solved. Conceptually this is similar to a search-as-you-type field. Any field update triggers a new query, and then responses come at any time and in any order. So if you want to display responses in-order, in the worse case (the response for the first query comes last), you have to buffer as many responses as there were queries. In the real world, you would drop the responses that do not correspond to the current query - which correspond to lossy back-pressure.

The analogy with Concurrent Mode is that user interactions may generate new render queries. It is not clear to me whether React discards the vDOM it was computing (no longer current) to focus on the new vDom triggered by user interaction; or do both vDOM computation in parallel, and displays (commit phase) them individually in order when computation is finished; or display what it has computed so far on every requestAnimationFrame; or does a merge between vDOM version a-la git; or ... But that gets up away from the OP's topic.

Indepedently of the specific techniques used, I think the API for Suspense is worth meditating on. As I said in a previous comment, some form of communication children-parent will have to be involved, and I can think of three ways to do that: shared state, events, and callbacks. So @ryansolid seems to be using shared state (context). I used callbacks in my previous Svelte examples (callback which use events under the hood). The point is while the technique may differ, it will be possible to implement suspending functionalities no matter what the framework/library is. The issue for me is thus the API. My preferred component organization is to have component with a single concern. That means presentational component, render-less or logic component, layout components, etc. Suspense enters in the category of the logic component and ideally its children should be presentational component.

Now, lets review the great examples of @ryansolid:

  1. Fallback Fundamentals
    <Suspense fallback={<Loader />}>
      <AsyncChild start={startTime} />
      <AsyncChild start={startTime} />
      <AsyncChild start={startTime} />
      <AsyncChild start={startTime} />
      <LazyChild start={startTime} />
      <LazyAsyncChild start={startTime} />
    </Suspense>

This is a good example, because the suspense logic resides in the Suspense component. However there is not a complete separation achieved here because the code for the children components would change (however little) if no suspense functionality was used, e.g.

      <AsyncChild start={startTime} />
      <AsyncChild start={startTime} />
      <AsyncChild start={startTime} />
      <AsyncChild start={startTime} />
      <LazyChild start={startTime} />
      <LazyAsyncChild start={startTime} />

But on the surface of things, it looks good enough.

  1. Transitions
        <Suspense fallback={<Loader />}>
          <Switch transform={awaitSuspense}>
            <Match when={state.tab === 0}>
              <AsyncChild page="Uno" />
            </Match>
            <Match when={state.tab === 1}>
              <AsyncChild page="Dos" />
            </Match>
            <Match when={state.tab === 2}>
              <AsyncChild page="Tres" />
            </Match>
          </Switch>
        </Suspense>

This is also a terrific example, where Suspense does the suspensing, Switch and Match do the tab switching, and AsyncChild is concerned with presentation and asynchronous data fetching.

There again if I wanted to discard the suspense implementation detail, I can just remove the Suspense component (almost).

  1. Suspense List
const App = () => {
  const resource = fetchProfileData(0);
  return (
    <Suspense fallback={<h1>Loading...</h1>}>
      <ProfilePage resource={resource} />
    </Suspense>
  );
};

const ProfilePage = ({ resource }) => (
  <SuspenseList revealOrder="forwards" tail="collapsed">
    <ProfileDetails user={resource.user.value} />
    <Suspense fallback={<h2>Loading posts...</h2>}>
      <ProfileTimeline posts={resource.posts.value || []} />
    </Suspense>
    <Suspense fallback={<h2>Loading fun facts...</h2>}>
      <ProfileTrivia trivia={resource.trivia.value || []} />
    </Suspense>
  </SuspenseList>
);

Same comments, the difference is that the fetch is the at the top level App.

So the questions for me here are two:

ryansolid commented 4 years ago

Ok first on Concurrent Mode. React uses their own scheduler but essentially it's a play on requestIdleCallback. I implemented the Sierpinski demo and basically made my own version of this scheduling. The key to it is to use deadline based scheduling. requestIdleCallback has 2 really useful things that aren't as well known. First the second optional argument of timeout is key to ensure that at worst the call becomes a setTimeout. Second requestIdleCallback calls your callback with an object with 2 really useful things didTimeout and timeRemaining. Using these you can push work on to a queue and while time remains (browser based heuristic estimate of available idle time) you can pull work off the queue and process it in a loop. If you set a reasonably fast timeout and figure out default behavior like say pulling off atleast one item in the queue each time you can basically schedule all work off just repeatedly requestingIdleCallback. I mean that is just tip of the iceberg if you can estimate work effort you could probably do even better scheduling.

This repo helped me understand the potential of different scheduling mechanisms: https://github.com/justinfagnani/queue-scheduler

That being said this is no silver bullet and has tiny bit of overhead on normal operation even when able to use the idle time as now all updates are asynchronous. I implemented this mechanism in a demo by essentially by passing in reactive var and creating a new reactive var that basically acted like a buffer. Then I'd queue up reading the first reactive variable to set the 2nd in a computation if it wasn't already scheduled. It's a cool demo but I think it's really inconsistent thing to generalize. Quite often skipping this is just so much faster it doesn't matter. Without having to deal with Top Down reconciliation in a reactive library the gains aren't often as big. I see some uses for introducing this sort of scheduling but I think it probably needs to be opt in or deliberate unless you want performance across the board to take a bit of a hit. Sort of a self fulfilling prophecy if not done perfectly and I think that is an area that needs way more research.

But luckily for a reactive library we can do Suspense without Concurrent Mode since we already have that granularity at our disposal. To your questions. I'm not sure you can always make the child unaware. I set up solid so that the Suspense aware helpers like loadResource can work without Suspense since it's just a Context lookup that on failing to find can just skip. But if the child does loading they are aware. In React the children need to be aware even on read since they need to get passed down the resource and then read from it. The reason I don't in Solid is since every binding expression is wrapped in a function and then not ran until the property is accessed they can treat the read like any other prop access. Basically all props are lazy evaluated. This is a runtime trick. And honestly a happy accident, I wasn't aiming for it but once I wrote the example I had that moment that I realized how powerful that is.

But can you do similar with Svelte's compiler/reactive system? Probably, but not 100% sure. Even React requires the child component to be aware they are reading from a resource (special object). Svelte does have reactive props that can be passed down so if there is some way to hide it in there I believe it can work the same way.

sschottler commented 4 years ago

I see some uses for introducing this sort of scheduling but I think it probably needs to be opt in or deliberate unless you want performance across the board to take a bit of a hit.

I asked Kent C Dodds about this:

https://github.com/kentcdodds/ama/issues/766

His post made me think some of this scheduling happens automatically, but in my simple experiments, I've had to opt in by wrapping my state changes in startTransition or using the useDeferred hook. I'm curious if React has heuristics for knowing to automatically schedule some things, but if it does, they weren't triggered by my simple tests.

ryansolid commented 4 years ago

Yeah I wonder too. They've built a queuing system if you look at the source code that they could apply anywhere. They basically polyfill requestIdleCallback by using the timestamp on requestAnimationFrame and couple other tricks to determine available time. But from there it works the same way. Their scheduler takes a priority argument and then prioritizes appropriately. So I imagine it's a question of whether they decide internally to apply this scheduling to certain types of actions. I forget where now, but I saw discussions where they were talking about how they'd classify priority of certain types of actions generally so they could be implementing that already. And if it's things like state updates are triggered from the network it might not be that noticeable. However useDeferredValue feels a lot like this as well.

sschottler commented 4 years ago

React core team member responded regarding whether concurrent mode requires opt-in:

https://twitter.com/acdlite/status/1205694631398137857?s=20

antony commented 4 years ago

Is it worth turning this into a RFC (https://github.com/sveltejs/rfcs)?

WaltzingPenguin commented 3 years ago

Either I am dramatically misunderstanding the problem, or this conversation has gotten quite sidetracked from the original goal.

It looks like we can do a very good approximation of Suspense with existing infrastructure.

This lets us write code like:

// parent.svelte
<Suspense>
  <Loading slot="loading" />

  <Child />
  <Child />
</Suspense>

// child.svelte
<script>
import { createSuspense } from 'suspense'
const suspend = createSuspense()

const data = fetch('/something')
</script>

{#await suspend(data) then item}
 { item }
{/await}

without the consumer passing data around. The child component, or any of their children components, can dictate when loading is finished.

There are two limitations I've found so far that would require Svelte itself to makes changes to overcome:

  1. Wrapping Container The above <Suspense> component has to wrap its default slot in a div in order to control visibility. @brucou mentioned this as a issue in the opening post but I think it merits more focus than it's gotten lost in the subsequent discussion.

  2. Intro Animations Because we're rendering inside of hidden container, any intro animations will play out before the container is shown.

Both of these could be solved by adding a hidden attribute to <slot>, mimicking the hidden attribute in HTML. If set to true, the slot content could be rendered off screen into a DocumentFragment or equivalent and skip all transitions. When the attribute changes to false (also the default), we could attach the fragment to the appropriate spot in the DOM and kick off those transitions that have been delayed.

MrBigBanks commented 3 years ago

On Thu, Mar 11, 2021 at 10:59 PM Mason DaBanks @.***> wrote:

On Thu, Mar 11, 2021 at 4:46 PM Brian K @.***> wrote:

Either I am dramatically misunderstanding the problem, or this conversation has gotten quite sidetracked from the original goal.

It looks like we can do a very good approximation of Suspense with existing infrastructure.

This lets us write code like:

// parent.svelte

// child.svelte

{#await suspend(data) then item} { item } {/await}

without the consumer passing data around. The child component, or any of their children components, can dictate when loading is finished.

There are two limitations I've found so far that would require Svelte itself to makes changes to overcome:

1.

Wrapping Container The above component has to wrap its default slot in a div in order to control visibility. @brucou https://github.com/brucou mentioned this as a issue in the opening post but I think it merits more focus than it's gotten lost in the subsequent discussion. 2.

Intro Animations Because we're rendering inside of hidden container, any intro animations will play out before the container is shown.

Both of these could be solved by adding a hidden attribute to , mimicking the hidden attribute in HTML https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/hidden. If set to true, the slot content could be rendered off screen into a DocumentFragment or equivalent and skip all transitions. When the attribute changes to false (also the default), we could attach the fragment to the appropriate spot in the DOM and kick off those transitions that have been delayed.

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/sveltejs/svelte/issues/3203#issuecomment-797153885, or unsubscribe https://github.com/notifications/unsubscribe-auth/ARJ6A4OMMZK3CNSNZAPENVTTDFI6PANCNFSM4H7A363A .

MrBigBanks commented 3 years ago

Error.

On Thu, Mar 11, 2021, 11:00 PM MrBigBanks @.***> wrote:

On Thu, Mar 11, 2021 at 10:59 PM Mason DaBanks @.***> wrote:

On Thu, Mar 11, 2021 at 4:46 PM Brian K @.***> wrote:

Either I am dramatically misunderstanding the problem, or this conversation has gotten quite sidetracked from the original goal.

It looks like we can do a very good approximation of Suspense with existing infrastructure.

This lets us write code like:

// parent.svelte

// child.svelte

{#await suspend(data) then item} { item } {/await}

without the consumer passing data around. The child component, or any of their children components, can dictate when loading is finished.

There are two limitations I've found so far that would require Svelte itself to makes changes to overcome:

1.

Wrapping Container The above component has to wrap its default slot in a div in order to control visibility. @brucou https://github.com/brucou mentioned this as a issue in the opening post but I think it merits more focus than it's gotten lost in the subsequent discussion. 2.

Intro Animations Because we're rendering inside of hidden container, any intro animations will play out before the container is shown.

Both of these could be solved by adding a hidden attribute to , mimicking the hidden attribute in HTML < https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/hidden . If set to true, the slot content could be rendered off screen into a DocumentFragment or equivalent and skip all transitions. When the attribute changes to false (also the default), we could attach the fragment to the appropriate spot in the DOM and kick off those transitions that have been delayed.

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub <https://github.com/sveltejs/svelte/issues/3203#issuecomment-797153885 , or unsubscribe < https://github.com/notifications/unsubscribe-auth/ARJ6A4OMMZK3CNSNZAPENVTTDFI6PANCNFSM4H7A363A

.

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/sveltejs/svelte/issues/3203#issuecomment-797281302, or unsubscribe https://github.com/notifications/unsubscribe-auth/ARJ6A4NLP3UVC3UORQ2T74LTDG3ZLANCNFSM4H7A363A .

ryansolid commented 3 years ago

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.


  1. Suspense the placeholder. This is what those libraries implemented and more or less is what @ADantes is illustrating. In the case of a reactive library using context makes a ton of sense (as really the only way to connect the dots when we aren't doing full top down re-rendering) and one could just implement that and call it a day. This is all Vue or Preact is doing. One could implement this and get a similar experience.

  1. Transitions. This is where the conversation went off a bit. Suspense the placeholder is great for initial loads but some of those early demos showed other behaviors without really defining them. Transitions in React are where a change set is isolated and not committed until all promises that are initiated due to that change that are read under a Suspense boundary completely resolve. Ie.. if someone transitions to the next tab the actual state change of the tab change is held from the outside view until all the data is loaded for the new tab and we can update everything at the same time.

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.


  1. Ok so clearly we can implement number 1 and call it a day like Vue or Preact. But the other discussion was interesting to see if it could be modeled in Svelte. It is probably unnecessary. But React had good reason to go this way as this same concurrent rendering in their case attached scheduling is what is going to be powering their HTML streaming and Async server rendering that is in their upcoming Server Components. Now I've talked with a few authors of other libraries and they are positive (as am I) that this is possible without concurrent rendering. Mostly that the scheduling piece can be viewed as a 3rd feature. We talked a bit about it above but that's the part that others have eluded to not really being necessary in a performant system like Svelte. If you aren't scheduling huge amounts of diffing work I mean why add this overhead, it's a solution asking for a problem.

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 have await.

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?

WaltzingPenguin commented 3 years ago

I'm assuming #2 and #3 are referring to React's page on Concurrent UI Patterns? I took a stab at implementing the main features from their demo there. While it isn't production worthy, I did walk away with a very different idea of what Svelte's blocking issues are.

My biggest take away is that Svelte already has a primitive to show two different versions of a DOM tree at the same time: {#key}. While the out transition is playing, we still have a tree that is reactive, displays animations, and is acting on a copy of the stale data. That's the hardest part already done and finished.

Limitations

As noted above, that demo isn't production worthy and I'm pretty sure Svelte would need new features to make it so. Here's a few issues it has:

This entire demo does feel like an abuse of the current transition system. My point isn't that this should be a final API, merely that the underlying components needed to build a good API are mostly already present. The new parts required, while not easy, don't look particularly ground breaking and have prior art.

WaltzingPenguin commented 3 years ago

A weekend of tinkering, and I have something that just more or less just straight up works:

Warts

Edit: Now that I have this working, I can't find a good use case for the Transition component. Every example I've found of React's useTransition, which is what it mimics, has effectively been just for routing purposes. We could implement a router using just Suspense and without half of the weird edge cases this Transition component creates. I'm still heavily in favor of a <Suspense> equivalent though.

stale[bot] commented 3 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] commented 2 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

frederikhors commented 2 years ago

Nope

WaltzingPenguin commented 2 years ago

<Suspense> and <SuspenseList> can be mostly implemented in user land. npm library here: https://www.npmjs.com/package/@svelte-drama/suspense There are some limitations that cannot be worked around, to the best of my knowledge, without core changes. Chiefly, orchestrating with intro/outro transitions and rendering during SSR.

Including something like this in Svelte core would have the advantage of making it much easier to do async SSR in general; one of the current obstacles is determining when a page is "complete" and ready to be sent, but the <Suspense> components have a known loading and complete state.

useTransition looks like it can also be implemented in userland with similar caveats. My last stab at it is here: https://svelte.dev/repl/4517e0bcf9d5488a9db74fb38f3df218?version=3.44.3 I'm not convinced useTransition is a useful tool for Svelte though. In every case I've looked at, useTransition is properly some form of routing and covered by frameworks like SvelteKit, or can be duplicated through RxJS inspired streams (see https://svelte.dev/repl/8ab0d78cc9c84582a693ef9d520f461f?version=3.44.3).

ryansolid commented 2 years ago

I'm not convinced useTransition is a useful tool for Svelte though. In every case I've looked at, useTransition is properly some form of routing and covered by frameworks like SvelteKit ....

I think this is fair. The navigation story is like 90% page/tab navigation. So in those scenarios there are ways for the router to render offscreen. Transitions are are sort of like the academic solution for purity. I implemented them both ways in Solid over time. Originally just using Suspense and tapping into control flow and eventually moving to forked realities and merging. Since I had that I went the full way to doing interruptible concurrent rendering. The time-slicing element is interesting but I haven't had many applications for it yet.

People will point to our performance as a reason not to bother with concurrent rendering and they aren't far off. This whole thing is about asynchronous consistency. So it is all in an effort to prevent glitches and tearing. Without framework intervention it is hard to do since scheduling will slice things the other way and not hold executing side effects. I don't think this would be easy to do with RxJS or any external driver in a generic way. It isn't about scheduling a slice of reactive updates. You really do need to basically clone things. But the problem might be too narrow to warrant a generic solution.

It also doesn't help the expense of side effects. It's funny it is always demo'd with like expensive visuals or 3d but it can't help you render faster. At some point you take the hit, and if you want to avoid tearing you take the full brunt of it. This whole mechanism is for expensive pure computations. Think $: variable in Svelte. You need to do some pretty heavy stuff for this to be your bottleneck. It turns out making 3D models is one which is why it is in 3D demos. Now you can build everything in the background without interrupting the animation, but if what you were drawing was too expensive in the first place you are out of luck.

I love how cool it demos, but I think it takes a real specific sort of problem for this because, it isn't the DOM cost which is often the heaviest thing when doing large navigation. DOM creation and attachment doesn't get interrupted. It's if the user code is doing some really expensive stuff and blocking. Is that worthy of systematic solution? I don't know.

jdgamble555 commented 1 year ago

Did anyone from the Svelte team every comment on this issue since 2018? Would be great to get something like this implemented natively. 😀

J

distor-sil3nt commented 1 year ago

Is there any plans for including <Suspense> in Svelte? This is a great (though still experimental) feature in Vue which would add a lot of value for rendering with async child components.

intrnl commented 10 months ago

Now that we're in the land of signals alongside Solid.js and all, any thoughts on Suspense from the Svelte team?

Specifically with regards to the ability of being able to conditionally pause effects/renders/updates from a component subtree, this is where I've found Suspense to be the most beneficial with.

Take for example, mobile app navigations, they are stacked and ideally you don't want to rerender any views that aren't currently on screen, but you can't unmount them or else you lose all the states within them, not unless they're stored within a global state (which creates additional complexity). This example is very much real, this is how Suspense is being used within React Native apps, and could end up benefitting Svelte Native too.

Other sorts of navigations like tabs, they all benefit from being able to keep offscreen components mounted, yet frozen such that UI updates don't affect them until they are no longer frozen.

Personally I think Suspense is a very beneficial thing to have, just not in a manner that React thinks it's beneficial for.

njacob1001 commented 9 months ago

This would be a nice performance enhancement! other frameworks like Angular are introducing new approaches to get this feature on its primary tools

stefa168 commented 1 week ago

It would be amazing to see the Suspense mechanisms come to Svelte 5! +1 for this :)