Skip to content

Commit ce9a6fe

Browse files
authored
Reimplement SSR streaming with FuturesUnordered (#738)
* Drastically simply render_to_string_await_suspense * Use FuturesUnordered for streaming * Update expect test * Add create_isomorphic_resource * Add unit test for resources * Add docs on resources * Add some preliminary SSR streaming docs
1 parent b15fd1a commit ce9a6fe

File tree

13 files changed

+424
-211
lines changed

13 files changed

+424
-211
lines changed

docs/next/guide/resources-and-suspense.md

+105-1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,108 @@ title: Async Resources
44

55
# Async Resources and Suspense
66

7-
> Note: this page is currently a stub. Help us write it!
7+
In any real non-trivial app, you probably need to fetch data from somewhere.
8+
This is where resources and suspense come in.
9+
10+
The resources API provides a simple interface for fetching and refreshing async
11+
data and suspense makes it easy to render fallbacks while the data is loading.
12+
13+
## The Resources API
14+
15+
To create a new resource, call the `create_isomorphic_resource` function.
16+
17+
```rust
18+
use sycamore::prelude::*;
19+
use sycamore::web::create_isomorphic_resource;
20+
21+
struct Data {
22+
// Define the data format here.
23+
}
24+
25+
async fn fetch_data() -> Data {
26+
// Perform, for instance, an HTTP request to an API endpoint.
27+
}
28+
29+
let resource = create_isomorphic_resource(fetch_data);
30+
```
31+
32+
A `Resource<T>` is a wrapper around a `Signal<Option<T>>`. The value is
33+
initially set to `None` while the data is loading. It is then set to `Some(...)`
34+
containing the value of the loaded data. This makes it convenient to display the
35+
data in your view.
36+
37+
```rust
38+
view! {
39+
(if let Some(data) = resource.get_clone() {
40+
view! {
41+
...
42+
}
43+
} else {
44+
view! {}
45+
})
46+
}
47+
```
48+
49+
Note that `create_isomorphic_resource`, as the name suggests, runs both on the
50+
client and on the server. If you only want data-fetching to happen on the
51+
client, you can use `create_client_resource` which will never load data on the
52+
server.
53+
54+
Right now, we do not yet have a `create_server_resource` function which only
55+
runs on the server because this requires some form of data-serializaation and
56+
server-integration which we have not fully worked out yet.
57+
58+
### Refreshing Resources
59+
60+
Resources can also have dependencies, just like memos. However, since resources
61+
are async, we cannot track reactive dependencies like we would in a synchronous
62+
context. Instead, we have to explicitly specify which dependencies the resource
63+
depends on. This can be accomplished with the `on(...)` utility function.
64+
65+
```rust
66+
let id = create_signal(12345);
67+
let resource = create_resource(on(id, move || async move {
68+
fetch_user(id).await
69+
}));
70+
```
71+
72+
Under the hood, `on(...)` simply creates a closure that first accesses `id` and
73+
then constructs the future. This makes it so that we access the signal
74+
synchronously first before performing any asynchronous tasks.
75+
76+
## Suspense
77+
78+
With async data, we do not want to show the UI until it is ready. This problem
79+
is solved by the `Suspense` component and related APIs. When a `Suspense`
80+
component is created, it automatically creates a new _suspense boundary_. Any
81+
asynchronous data accessed underneath this boundary will automatically be
82+
tracked. This includes accessing resources using the resources API.
83+
84+
Using `Suspense` lets us set a fallback view to display while we are loading the
85+
asynchronous data. For example:
86+
87+
```rust
88+
view! {
89+
Suspense(fallback=move || view! { LoadingSpinner {} }) {
90+
(if let Some(data) = resource.get_clone() {
91+
view! {
92+
...
93+
}
94+
} else {
95+
view! {}
96+
})
97+
}
98+
}
99+
```
100+
101+
Since we are accessing `resource` under the suspense boundary, our `Suspense`
102+
component will display the fallback until the resource is loaded.
103+
104+
## Transition
105+
106+
Resources can also be refreshed when one of its dependencies changes. This will
107+
cause the surrounding suspense boundary to be triggered again.
108+
109+
This is sometimes undesired. To prevent this, just replace `Suspense` with
110+
`Transition`. This component will continue to show the old view until the new
111+
data has been loaded in, providing a smoother experience.

docs/next/server-side-rendering/streaming.md

+71-1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,74 @@ title: SSR Streaming
44

55
# SSR Streaming
66

7-
> Note: this page is current a stub. Help us write it!
7+
Not only does Sycamore support server side rendering, Sycamore also supports
8+
server side streaming. What this means is that Sycamore can start sending HTML
9+
over to the client before all the data has been loaded, making for a better user
10+
experience.
11+
12+
## Different SSR modes
13+
14+
There are 3 different SSR rendering modes.
15+
16+
### Sync mode
17+
18+
This is the default mode and is used when calling `render_to_string`.
19+
20+
In sync mode, data is never fetched on the server side and the suspense fallback
21+
is always rendered.
22+
23+
**Advantages**:
24+
25+
- Fast time-to-first-byte (TTFB) and first-contentful-paint (FCP), since we
26+
don't need to perform any data-fetching.
27+
- Simpler programming model, since we don't need to worry about data-fetching on
28+
the server.
29+
30+
**Disadvantages**:
31+
32+
- Actual data will likely take longer to load, since the browser needs to first
33+
start running the WASM binary before figuring out that more HTTP requests are
34+
required.
35+
- Worse SEO since data is only loaded after the initial HTML is rendered.
36+
37+
### Blocking mode
38+
39+
The server already knows which data is needed for rendering a certain page. So
40+
why not just perform data-fetching directly on the server? This is what blocking
41+
mode does.
42+
43+
You can use blocking mode by calling `render_to_string_await_suspense`. Blocking
44+
mode means that the server will wait for all suspense to resolve before sending
45+
the HTML.
46+
47+
**Advantages**:
48+
49+
- Faster time to loading data, since the server does all the data-fetching.
50+
- Better SEO since content is rendered to static HTML.
51+
52+
**Disadvantages**:
53+
54+
- Slow time-to-first-byte (TTFB) and first-contentful-paint (FCP) since we must
55+
wait for data to load before we receive any HTML.
56+
- Slightly higher complexity since we must worry about serializing our data to
57+
be hydrated on the client.
58+
59+
### Streaming mode
60+
61+
Streaming mode is somewhat of a compromise between sync and blocking mode. In
62+
streaming mode, the server starts sending HTML to the client immediately, which
63+
contains the fallback shell. Once data loads on the server, the new HTML is sent
64+
down the same HTTP connection and automatically replaces the fallback.
65+
66+
You can use streaming mode by calling `render_to_string_stream`.
67+
68+
**Advantages**:
69+
70+
- Fast time-to-first-byte (TTFB) and first-contentful-paint (FCP) since the
71+
server starts streaming HTML immediately.
72+
- Better SEO for the same reasons as blocking mode.
73+
74+
**Disadvantages**:
75+
76+
- Slightly higher complexity since we must worry about serialiing our data to be
77+
hydrated on the client, similarly to blocking mode.

examples/ssr-suspense/index.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<!DOCTYPE html><html data-hk="0.0"><head data-hk="0.1"><meta charset="utf-8" data-hk="0.2"><meta name="viewport" content="width=device-width, initial-scale=1" data-hk="0.3"><script>window.__sycamore_ssr_mode='blocking'</script></head><body data-hk="0.4"><no-ssr data-hk="0.6"></no-ssr><suspense-start data-key="1" data-hk="0.5"></suspense-start><!--/--><!--/--><!--/--><p data-hk="1.0">Suspensed component</p><p>Server only content</p><!--/--><!--/--><!--/--><suspense-end data-key="1"></suspense-end></body></html>
1+
<!DOCTYPE html><html data-hk="0.0"><head data-hk="0.1"><meta charset="utf-8" data-hk="0.2"><meta name="viewport" content="width=device-width, initial-scale=1" data-hk="0.3"><script>window.__sycamore_ssr_mode='blocking'</script></head><body data-hk="0.4"><suspense-start data-key="1" data-hk="0.5"></suspense-start><no-ssr data-hk="0.6"></no-ssr><!--/--><!--/--><p data-hk="1.0">Suspensed component</p><p>Server only content</p><!--/--><!--/--></body></html>

packages/sycamore-futures/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ pub fn spawn_local(fut: impl Future<Output = ()> + 'static) {
4646
/// If the scope is destroyed before the future is completed, it is aborted immediately. This
4747
/// ensures that it is impossible to access any values referencing the scope after they are
4848
/// destroyed.
49+
#[cfg_attr(debug_assertions, track_caller)]
4950
pub fn spawn_local_scoped(fut: impl Future<Output = ()> + 'static) {
5051
let scoped = ScopedFuture::new_in_current_scope(fut);
5152
spawn_local(scoped);
@@ -69,6 +70,7 @@ impl<T: Future> Future for ScopedFuture<T> {
6970
}
7071

7172
impl<T: Future> ScopedFuture<T> {
73+
#[cfg_attr(debug_assertions, track_caller)]
7274
pub fn new_in_current_scope(f: T) -> Self {
7375
let (abortable, handle) = abortable(f);
7476
on_cleanup(move || handle.abort());

packages/sycamore-futures/src/suspense.rs

+3-2
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ impl SuspenseScope {
4040
global
4141
.all_tasks_remaining
4242
.update(|vec| vec.push(tasks_remaining));
43+
// TODO: remove self from global if scope is disposed.
4344
Self {
4445
tasks_remaining,
4546
parent: parent.map(create_signal),
@@ -155,8 +156,8 @@ pub fn create_detatched_suspense_scope<T>(f: impl FnOnce() -> T) -> (T, Suspense
155156
///
156157
/// Returns a tuple containing the return value of the function and the created suspense scope.
157158
///
158-
/// If this is called inside another call to [`await_suspense`], this suspense will wait until the
159-
/// parent suspense is resolved.
159+
/// If this is called inside another call to [`create_suspense_scope`], this suspense will wait
160+
/// until the parent suspense is resolved.
160161
pub fn create_suspense_scope<T>(f: impl FnOnce() -> T) -> (T, SuspenseScope) {
161162
let parent = try_use_context::<SuspenseScope>();
162163
let scope = SuspenseScope::new(parent);

packages/sycamore-reactive/src/context.rs

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ fn provide_context_in_node<T: 'static>(id: NodeId, value: T) {
6868
}
6969

7070
/// Tries to get a context value of the given type. If no context is found, returns `None`.
71+
#[cfg_attr(debug_assertions, track_caller)]
7172
pub fn try_use_context<T: Clone + 'static>() -> Option<T> {
7273
let root = Root::global();
7374
let nodes = root.nodes.borrow();

packages/sycamore-reactive/src/root.rs

+1
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ pub fn create_child_scope(f: impl FnOnce()) -> NodeHandle {
367367
/// child_scope.dispose(); // Executes the on_cleanup callback.
368368
/// # });
369369
/// ```
370+
#[cfg_attr(debug_assertions, track_caller)]
370371
pub fn on_cleanup(f: impl FnOnce() + 'static) {
371372
let root = Root::global();
372373
if !root.current_node.get().is_null() {

packages/sycamore-web/src/lib.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! - `hydrate` - Enables hydration support in DOM node. By default, hydration is disabled to reduce
99
//! binary size.
1010
//!
11-
//! - `suspense` - Enables suspense support.
11+
//! - `suspense` - Enables suspense and resources support.
1212
//!
1313
//! - `wasm-bindgen-interning` (_default_) - Enables interning for `wasm-bindgen` strings. This
1414
//! improves performance at a slight cost in binary size. If you want to minimize the size of the

packages/sycamore-web/src/node/ssr_node.rs

+1-9
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,6 @@ pub enum SsrNode {
3030
Dynamic {
3131
view: Arc<Mutex<View<Self>>>,
3232
},
33-
SuspenseMarker {
34-
key: u32,
35-
},
3633
}
3734

3835
impl From<SsrNode> for View<SsrNode> {
@@ -63,7 +60,7 @@ impl ViewNode for SsrNode {
6360
create_effect({
6461
let text = text.clone();
6562
move || {
66-
let mut value = f();
63+
let mut value = Some(f());
6764
let value: &mut Option<String> =
6865
(&mut value as &mut dyn Any).downcast_mut().unwrap();
6966
*text.lock().unwrap() = value.take().unwrap();
@@ -259,11 +256,6 @@ pub(crate) fn render_recursive(node: &SsrNode, buf: &mut String) {
259256
SsrNode::Dynamic { view } => {
260257
render_recursive_view(&view.lock().unwrap(), buf);
261258
}
262-
SsrNode::SuspenseMarker { key } => {
263-
buf.push_str("<!--sycamore-suspense-");
264-
buf.push_str(&key.to_string());
265-
buf.push_str("-->");
266-
}
267259
}
268260
}
269261

0 commit comments

Comments
 (0)