Suspense - some thoughts #746
Replies: 3 comments 3 replies
-
Thanks for bringing this up - I definitely agree a proper RFC is needed, but I haven't gotten time to properly hash it out yet. A practical challenge is that the usage patterns, some maybe questionable, may have been widely used in production (e.g. by frameworks like Nuxt) and may limit how much we can change around the API surface. That said, looking through these issues is indeed a necessary process before we actually stabilize it. This is a good starting point, I'll give it a deeper look and provide feedback later. |
Beta Was this translation helpful? Give feedback.
-
It may be fixed via vuejs/core#12042
this issue no longer be reproduced with the latest version, It has been fixed via vuejs/core#10055 Here are some issues related to Suspense that need to be discussed:
|
Beta Was this translation helpful? Give feedback.
-
For a lot of projects, I tend to use the native vue router, never just Vue in isolation. I have a couple projects/cases where I wasn't sure if Suspense would work, and it didn't pan out. Not sure if those will ever be something that the component should cover, but to me - based on the promise and naming - it sounded logical that it would. 1. Loading the latest newsWe had a scenario where a "latest news" page was offloaded as an async component via Vue router. Inside, we would call the API to get the latest posts/news, and show them to the user. This meant that we had multiple loading and error states:
Both steps had to share the same loading skeleton, as it otherwise would look off to the user. We ended up using a combination of Suspense, internal loading states and re-rendering the skeleton from multiple places, but it was always a bit funky. In my mind, the way Suspense should've worked is do both: load the async component, and wait for the 2. Loading data + auth boundariesIn another app, we have to load a bunch of data, and eventually switch to the new route once we authenticated and authorized the user, and loaded the required components, as well as data from an external API. The way we approached this was by using route guards, as we wanted to be able to cancel the route change if there was an error (auth-related, or otherwise). The flow is as follows:
To this day, there was no way to use Suspense for this, as it has no connection to the Vue router. The way the route guards work also is detached from other things, so we ended up building a system that kind of worked, but still causes flickering when Vue took its time to present the data (see vuejs/router#2037 for more). Personally, I feel like there are many cases where I don't know if Suspense might be worth using, or even the right job. Is suspense only meant to be for Vue component rendering, or should/could it be used as a more general "async state" rendering handler? |
Beta Was this translation helpful? Give feedback.
-
Suspense
Suspense has been in an experimental state for over 4 years now.
I'm still hopeful that there will be an RFC prior to it reaching stable status, but writing an RFC for Suspense is a major undertaking. It may also take some time to gather feedback and iterate on the design. I worry that this may lead to the RFC being skipped.
I've attempted to provide some feedback on the feature here. This isn't an RFC in the usual sense, but perhaps it will act as a starting point for interested parties to share their thoughts, allowing for some clarity to emerge prior to an actual RFC.
References
Some key PRs in the history of Suspense:
<Suspense suspensible>
The documentation:
Why do we need an RFC?
I have lots of concerns about Suspense, but my main problem with it is simply that I don't understand it. I can't pinpoint exactly why, and without an RFC I find it very difficult to know what the motivation was behind some of the design decisions. Much of how Suspense behaves seems arbitrary and unintuitive, but perhaps that's just because I'm missing some key ideas behind the feature.
I have, of course, read the documentation. For those who are unaware, I actually wrote the original version of the Suspense documentation. It's not that I struggle to understand what it does, I just don't understand why we've ended up with that design.
I'm sure some people will feel that we've had long enough to discuss Suspense and we don't need further delay with an RFC. I disagree. Without an RFC there's never been a proper forum for the community to provide feedback on the design. Piecemeal bug reports are no substitute.
In the past, Evan has also suggested that Suspense would go through the RFC process. e.g. See:
While those were written some years ago, the design of Suspense hasn't really changed in the interim. Bugs have been fixed, but the bigger problems (those that would be addressed in an RFC) still remain.
I also think it's important not to place too much reliance on Nuxt's use of Suspense. I'm sure the Nuxt team can provide great feedback on the feature, but their use case is not the general use case. They can control how Suspense is used in a Nuxt application, dodging some of the sharp edges.
I'll attempt to outline some specific concerns about Suspense below, but I think my feedback comes with a couple of caveats.
Firstly, I think my feedback would look very different if I were responding to an RFC, rather than preempting one. I feel like I'm reacting to a document I haven't seen.
Secondly, my own experience with Suspense is limited. I occasionally think I've encountered a suitable use case, get frustrated that it's either too buggy or too restrictive to work in my use case, then give up and go back to doing things Vue-2-style. My experiences with others using it on Vue Land haven't been particularly encouraging either. I don't doubt we can fix these problems, but it has limited my ability to gain first-hand experience with using the feature. Most of my feedback is purely hypothetical.
General thoughts
Components being 'in memory'
Suspense renders components 'in memory', only populating the DOM once all async dependencies are resolved.
Various processes need to be kept on hold while this is happening. Scheduler jobs that run during the
post
phase are paused until the DOM is populated. This includes watchers withflush: 'post'
, lifecycle hooks such asmounted
, and even things like populating template refs andTeleport
that utilise the scheduler queue. This has been implemented for a while. If components are unmounted before everything resolves, these pending processes also need to be cancelled.But during this phase, while the components exist only in memory, reactivity continues to exist. Reactive dependencies can change, triggering watchers (with
flush: 'pre'
) and component rendering. This might be due to data being loaded by the components themselves, or it could be global data (e.g. Pinia) or routing changes coming from Vue Router.There are some similarities here with KeepAlive. That also keeps a component in memory, and also has potential problems with reactivity triggering effects within those components.
It isn't clear to me that KeepAlive handles this correctly either.
vuejs/router#626 is a common and long-standing problem with KeepAlive and RouterView. As noted by Daniel Roe in this comment, that problem also applies to Suspense. There's also some interesting discussion about KeepAlive in vuejs/core#5386, some of which may carry across to Suspense.
KeepAlive provides
activated
anddeactivated
hooks, which do allow for some degree of manual control. It may be possible to pause component-based reactivity usinggetCurrentScope().pause()
, but I've not seen that attempted in practice. Even without it, with enough effort you can workaround most of the problems.I do wonder whether a more general mechanism for rendering in-memory could be useful.
v-if
completely removes children.v-show
hides them, but keeps them in the DOM and fully awake. KeepAlive allows for removing them from the DOM, but doesn't pause the effects. It feels there could be more options here, with in-DOM/in-memory and paused/awake being potentially independent of each other.The design of KeepAlive comes from a long time ago, when Vue was in a very different place. I wonder whether we'd implement it the same way if we were designing it from scratch now. Using a renderless component is not necessarily bad, but it's not clear that it should be the lowest-level mechanism available. e.g. Consider the way VueUse exposes composables as renderless components: https://vueuse.org/guide/components.html. The components are useful, but there's something more fundamental behind that.
Returning to Suspense, as far as I'm aware there isn't an equivalent of
activated
anddeactivated
. To some extent this makes sense, as components can only ever transition from in-memory to in-DOM once (at least with the current design). But this does make it more difficult for component authors to write components in a way that will work in all scenarios. For component libraries in particular, where it isn't clear where components will be used, both KeepAlive and Suspense pose similar challenges. Code runs in a different order and DOM nodes aren't necessarily there when you'd expect them to be. e.g.nextTick
is no longer enough to ensure everything is actually present in the current document.But there are some important differences between Suspense and KeepAlive. For Suspense, it might not be desirable to pause reactivity. The in-memory component isn't really 'asleep', it's just waiting on something else to finish before it can 'resolve' and complete the mounting process. Whatever it's waiting for might be relying on watchers or rendering updates to be able to move on to the next stage.
That said, if a component is relying on reactivity being active while Suspense is pending, it could be problematic in an SSR environment, where reactivity is disabled.
There are also some similarities between Suspense and Transition/TransitionGroup. Specifically, when a child is being unmounted, it needs to be kept in the DOM, but in an inert state. It shouldn't have any reactivity, or respond to user interactions.
SSR
I'm aware that SSR is an important use case for Suspense, but I don't know exactly how that all fits together. It isn't explained in the Vue docs, either for Suspense or SSR.
For example, is the server expected to render the fallback content for the Suspense, or should it wait for all descendants to resolve? If it's the latter, what would happen if the user interacts with the page (outside the
<Suspense>
) while some components are loading, potentially changing what needs to be rendered?I've also read that Suspense is related to
serverPrefetch
, but it isn't entirely clear to me how the two fit together.Specific feedback
Vue Router
Suspense doesn't currently have proper integration with lazy loaded components in Vue Router. It takes a bit of hoop-jumping to get that all working correctly. Nuxt can hide a lot of this pain from its users, but outside of a meta-framework this is a significant challenge to using Suspense effectively.
This issue seems to indicate that integration with Vue Router isn't planned:
That seems strange, as lazy loaded routes are (in my experience) the most common way of splitting Vue applications into chunks. If Suspense is intended to handle async loading scenarios automatically then this seems like it should be something it can handle. The idea of fallback content seems significantly less useful if it doesn't apply to loading route components. If the intention is not to support this, I think the full rationale needs explaining somewhere, as it really isn't clear to me why this wouldn't be supported.
I understand that it might not be possible to support it via the current
<RouterView v-slot="{ Component }">
mechanism, as the route needs to resolve first, but that doesn't mean it couldn't be supported in some other way. I touch on this again later (See Manually triggering Suspense).Handling layouts and other nesting
This section is a bit meandering, but hopefully it helps to illustrate how working with
<Suspense>
is currently a bit of a struggle.The exact positioning of the
<Suspense>
in the template isn't always clear. Async descendants don't really care, so long as the<Suspense>
is somewhere up the ancestry chain. So where exactly should the<Suspense>
be placed?There are two things we might consider to decide where to place it:
default
slot, those components must be the root node of the slot.fallback
content.At first glance, it might seem like these two considerations align nicely, as we'd want to show both in the same place:
But for a non-trivial application it often doesn't work out that way.
In practice, we usually have some kind of layout or other boilerplate that goes around the dynamic component and that we'd like to share, but which we don't want to show if the fallback is active. Something like this:
The specifics of the
<Layout>
component here don't really matter, the key thing to understand is that it interferes with the requirement to replace the root node of the<Suspense>
.Here's a Playground for that scenario:
Clicking the button leads to everything jumping, because the
<component :is="Comp" />
is removed immediately. To avoid that happening, we need to introduce another<Suspense>
component:Here's that in a Playground:
That fixes the jitter, but now we have another problem. Clicking the button doesn't initially do anything, at least not visually. We need some sort of feedback for the user.
Perhaps we could show the fallback content?
<Suspense timeout="0">
, or some other smalltimeout
value, might be tempting, but it won't work. The outer<Suspense>
won't trigger the fallback unless we replace the root node.What about using a
key
on<Layout>
?That could work, but it does force us to throw away the existing
Layout
component instance and create a new one. If theLayout
is stateful then we'd like to keep the same instance while the fallback is showing. That doesn't seem to be possible.Another problem with using a
key
is that it makes it more difficult to split up responsibilities across multiple files. Consider this code:If the dynamic component is coming from a
<slot />
, how would we know when to change thekey
?(Side note: The
<Suspense suspensible>
part doesn't seem to work with a slot either.)OK, so maybe showing the fallback content isn't the right thing to do here anyway. Maybe we should show some kind of load mask or a separate loading indicator instead? Suspense has events for this kind of thing. Let's give that a try:
We can then use
state
to control our loading indicator. Here's that in a Playground:It does work, hurray! But...
We've had to put the events on the inner
<Suspense suspensible>
, not the outer<Suspense>
. That's not really where we'd want to put them. In this example, where everything is in the same file, it works fine, but what if we wanted the dynamic component somewhere deeper in our component tree? We'd not only need to wrap it in<Suspense suspensible>
, but we'd also need to find a way to wire up all these events to pass the state up the tree to wherever the loading indicator is. We'll also need to handle cases where we have multiple dynamic children, each with their own<Suspense suspensible>
wrapper, each triggering the same loading indicator.For anyone who has tried to implement a loading indicator without Suspense, this may all sound very familiar. But there are a couple of important differences, one good, one less so.
The benefit is that we can show the outgoing component while we wait for the incoming component to finish loading. This is nice and it'd be difficult to achieve without Suspense.
The downside is that every time we want to handle loading like this we need to handle it in two components: in the child (where we use
async setup
) and in its parent (where we wire up the events and<Suspense suspensible>
). Something feels off. There's a form of coupling between the parent and child, and while there are arguably multiple concerns involved, it doesn't feel like they're currently divided up in quite the right way.Perhaps there should be some way for the events to propagate up to the outer
<Suspense>
automatically? There are similarities here between<Suspense>
andonErrorCaptured
. Both provide a mechanism for handling something in a single, ancestral location, rather than needing to worry about it in every component.I think the requirement to replace the root node of the
default
slot may be overly restrictive. That often seems to be where the pain starts with Suspense.async setup
It isn't clear to me why
async setup
needs an ancestor<Suspense>
.If a component with
async setup
is created after the initial resolve of the Suspense, and if the component is not the root child, then the two don't seem to impact each other. The ancestor<Suspense>
is mandatory, but it doesn't appear to do anything.I know this isn't a new idea, but there should probably be a mechanism for a component with
async setup
to specify loading and error handling options of its own, similar todefineAsyncComponent
.Manually triggering Suspense
Suspense is automatically triggered by
async setup
anddefineAsyncComponent
. But there doesn't seem to be a way to trigger it manually.Consider this example:
The
Dummy
component here is a workaround. As we can't manually tell theSuspense
to wait, we artificially introduce a child withasync setup
that we can control. Here we're using that component in the same template as<Suspense>
, but the same idea would work at any depth.If we were in a child instead, couldn't we just use
await router.isReady()
directly insidesetup
, rather than introducing a dummy component? Maybe, but that would block the rendering of that child component, which may not be what we want. Its own children may need to load data, and we'd rather get on with it.This isn't specifically a Vue Router thing, more generally we don't have any direct control over Suspense. If we want Suspense to wait, but don't want to block our own rendering, we need to do some trickery with a dummy component:
Notice how the Playground loads all the data in roughly 1 second. Removing the dummy components and using top-level
await
would significantly slow things down:Manual control could potentially go well beyond what's shown in those examples.
I suspect manual control would be needed to allow lazy loaded routes to support Suspense. More generally, I think it might allow for better integration with third-party component libraries.
Integration with component libraries is a bigger topic, but the current design of Suspense doesn't seem to play nicely in that context, as such libraries usually can't guarantee that they'll be used inside Suspense. Which leads us onto...
Detecting Suspense
I'm not aware of an easy way for a component to test whether it's inside Suspense. As I touched on earlier, some of the challenges component libraries face are similar problems to when they're used inside KeepAlive, but in that scenario they can use
activated
anddeactivated
hooks to try to deduce how they're being used.defineAsyncComponent
defineAsyncComponent
allows various options to be specified, such asloadingComponent
. When used inside Suspense, these will be ignored unlesssuspensible: false
is set.If the Suspense is in a pending state then this seems reasonable, as the Suspense is responsible for handling the DOM during loading.
But what about when an async component is used when Suspense is not pending? Would it not make sense to use the
loadingComponent
in that context?Here's a Playground to demonstrate that case:
While we can use
suspensible: false
to control this, that's easier said than done if we still want to benefit from the fallback slot during the initial render. In real code, the async component could be deep in the component tree, and we'd need to know whether the Suspense is pending to set thesuspensible
setting correctly.Beta Was this translation helpful? Give feedback.
All reactions