@threlte/extras
<Suspense>
The component <Suspense>
allows you to orchestrate the loading of resources
inside (nested) child components. The implementation roughly follows a subset of the concept
established by the React Suspense
API and has certain limitations.
The idea is to allow a parent component to make decisions based on the loading state of child components. The parent component can then decide to show a loading indicator or a fallback component while the child component is loading.
<script lang="ts">
import { Canvas } from '@threlte/core'
import Scene from './Scene.svelte'
</script>
<div>
<Canvas>
<Scene />
</Canvas>
</div>
<style>
div {
height: 100%;
background-color: rgb(47 125 198 / 0.2);
}
</style>
<script lang="ts">
import { T, useThrelte } from '@threlte/core'
import { Suspense, Text } from '@threlte/extras'
import { Color } from 'three'
import Spaceship from './Spaceship.svelte'
import StarsEmitter from './StarsEmitter.svelte'
const { size, scene } = useThrelte()
scene.background = new Color('black')
let zoom = $size.width / 50
$: zoom = $size.width / 50
</script>
<T.OrthographicCamera
position={[-40, 25, 40]}
makeDefault
{zoom}
on:create={({ ref }) => {
ref.lookAt(0, 0, -8)
}}
/>
<T.DirectionalLight
position={[5, 10, 3]}
intensity={1}
/>
<Suspense final>
<Text
position.z={-8}
slot="fallback"
text="Loading"
fontSize={1}
color="white"
anchorX="50%"
anchorY="50%"
on:create={({ ref }) => {
ref.lookAt(-40, 25, 40)
}}
/>
<Text
slot="error"
position.z={-8}
let:errors
text={errors.map((e) => e).join(', ')}
fontSize={1}
color="white"
anchorX="50%"
anchorY="50%"
on:create={({ ref }) => {
ref.lookAt(-40, 25, 40)
}}
/>
<StarsEmitter />
<Spaceship
name="Bob"
position={[-12, 0, 3]}
/>
<Spaceship
name="Challenger"
position={[10, 5, 6]}
/>
<Spaceship
name="Dispatcher"
position={[8, 3, -23]}
/>
<Spaceship
name="Executioner"
position={[12, -4, 6]}
/>
<Spaceship
name="Imperial"
position={[-1, 0, -21]}
/>
<Spaceship
name="Insurgent"
position={[-13, 1, -21]}
/>
<Spaceship
name="Omen"
position={[-9, -5, 13]}
/>
<Spaceship
name="Pancake"
position={[-9, -3, -9]}
/>
<Spaceship
name="Spitfire"
position={[1, 0, 1]}
/>
<Spaceship
name="Striker"
position={[8, -1, -10]}
/>
<Spaceship
name="Zenith"
position={[-1, 0, 13]}
/>
</Suspense>
<script lang="ts">
import { T } from '@threlte/core'
import { Float, useGltf, useSuspense } from '@threlte/extras'
import type { SpaceshipProps } from './Spaceship.svelte'
type $$Props = SpaceshipProps
export let name: $$Props['name']
const suspend = useSuspense()
const gltf = suspend(useGltf(`/models/spaceships/${name}.gltf`))
</script>
{#await gltf then { scene }}
<T.Group {...$$restProps}>
<Float
floatIntensity={3}
speed={3}
>
<T is={scene} />
</Float>
</T.Group>
{/await}
import type { Props } from '@threlte/core'
import type { SvelteComponent } from 'svelte'
import type { Group } from 'three'
export type SpaceshipProps = Props<Group> & {
name:
| 'Bob'
| 'Challenger'
| 'Dispatcher'
| 'Executioner'
| 'Imperial'
| 'Insurgent'
| 'Omen'
| 'Pancake'
| 'Spitfire'
| 'Striker'
| 'Zenith'
}
export default class Spaceship extends SvelteComponent<SpaceshipProps, {}, {}> {}
<script lang="ts">
import { useTask } from '@threlte/core'
// your script goes here
import { Instance } from '@threlte/extras'
import { Color } from 'three'
let positionX = Math.random() * 100 - 50
let positionY = Math.random() * 100 - 50
let positionZ = 100
const colors = ['#FFF09E', '#B8DFFF', '#CADBFF', '#FFEBBE']
// random element from array
const color = new Color(colors[Math.floor(Math.random() * colors.length)])
useTask((delta) => {
const f = 1 / 60 / delta
positionZ -= 10 * f
})
</script>
<Instance
{color}
position.x={positionX}
position.y={positionY}
position.z={positionZ}
/>
<script lang="ts">
import { T, useTask } from '@threlte/core'
import { InstancedMesh } from '@threlte/extras'
import Star from './Star.svelte'
type Star = {
id: string
spawnedAtFrame: number
}
const makeUniqueId = () => {
return Math.random().toString(36).substring(2, 9)
}
let stars: Star[] = []
/**
* spawnRate is the number of stars to spawn per frame
*/
const spawnRate = 2
const lifetimeInFrames = 40
let frame = 0
useTask(() => {
frame += 1
for (let i = 0; i < spawnRate; i++) {
stars.push({
id: makeUniqueId(),
spawnedAtFrame: frame
})
}
stars.forEach((star) => {
if (frame - star.spawnedAtFrame > lifetimeInFrames) {
// remove star from array
stars.splice(stars.indexOf(star), 1)
}
})
stars = stars
})
</script>
<InstancedMesh>
<T.BoxGeometry args={[0.04, 0.04, 30]} />
<T.MeshBasicMaterial
color="white"
transparent
opacity={0.2}
/>
{#each stars as star (star.id)}
<Star />
{/each}
</InstancedMesh>
Usage
In a child component
Let’s have a look at a simple component that loads a model with the hook useGltf
.
<script>
import { T } from '@threlte/core'
import { useGltf } from '@threlte/extras'
const gltf = useGltf('model.gltf')
</script>
{#await gltf then { scene }}
<T is={scene}>
{/await}
We can make that component suspense-ready by using the hook useSuspense
and passing the promise returned by useGltf
to it.
<script>
import { T } from '@threlte/core'
import { useGltf, useSuspense } from '@threlte/extras'
const suspend = useSuspense()
const gltf = suspend(useGltf('model.gltf'))
</script>
{#await gltf then { scene }}
<T is={scene}>
{/await}
Suspense-ready
Suspense-ready means it has the ability to hit a <Suspense>
boundary if there is any. If there’s no <Suspense>
boundary, the component will behave as usual.
In a parent component
Now we implement the component “Model.svelte” in a parent component:
<script>
import Model from './Model.svelte'
</script>
<Model />
To let the parent component decide what to do while the model component is loading, we wrap the child component in a <Suspense>
component and show a fallback component while the child component is loading.
<script>
import Model from './Model.svelte'
import Fallback from './Fallback.svelte'
import { Suspense } from '@threlte/extras'
</script>
<Suspense>
<Fallback slot="fallback" />
<Model />
</Suspense>
Error Boundary
In contrast to the React Suspense API, the <Suspense>
component also acts as an
error boundary for async requests made in child components wrapped in suspend
.
The slot "error"
will be mounted as soon as an error is thrown in a child component
wrapped in suspend
and the slot prop let:errors
can be used to display a meaningful
error message.
UX considerations
The <Suspense>
component is a great tool to improve the user experience of your application. However, it is important to consider the following points:
- The
<Suspense>
component will only ever show one of the available slots at any time:
- The slot
"error"
will be shown as soon as an error is thrown. - The slot
"fallback"
will be shown as long as a child component is suspended. - The default slot will be shown as long as no child component is suspended and no error has been thrown.
- Umounting a component that suspends rendering through awaiting a resource or throwing an error will discard its suspend state.
- Directly citing the React Suspense API:
Don’t put a Suspense boundary around every component. Suspense boundaries should not be more granular than the loading sequence that you want the user to experience.
- A
<Suspense>
component can be re-suspended if a child component invokessuspend
again. The propertyfinal
on the component<Suspense>
can be used to prevent this behavior.
Limitations
- It’s important to keep in mind that while the contents of the default slot are not visible, they
are still mounted and the component is otherwise fully functional. This means that any side
effects (for instance
useTask
hooks) that are invoked in a suspense-ready child component will be executed immediately.
<script>
import { T, useTask } from '@threlte/core'
import { useGltf, useSuspense } from '@threlte/extras'
const suspend = useSuspense()
const gltf = suspend(useGltf('model.gltf'))
useTask(() => {
// This will be executed immediately
})
</script>
{#await gltf then { scene }}
<T is={scene}>
{/await}
- Also, in contrast to the React Suspense API, the
<Suspense>
component does not support showing a stale version of a child component while it is loading.