Skip to content

Commit

Permalink
Reimplement SSR streaming with FuturesUnordered (#738)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
lukechu10 authored Oct 24, 2024
1 parent b15fd1a commit ce9a6fe
Show file tree
Hide file tree
Showing 13 changed files with 424 additions and 211 deletions.
106 changes: 105 additions & 1 deletion docs/next/guide/resources-and-suspense.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,108 @@ title: Async Resources

# Async Resources and Suspense

> Note: this page is currently a stub. Help us write it!
In any real non-trivial app, you probably need to fetch data from somewhere.
This is where resources and suspense come in.

The resources API provides a simple interface for fetching and refreshing async
data and suspense makes it easy to render fallbacks while the data is loading.

## The Resources API

To create a new resource, call the `create_isomorphic_resource` function.

```rust
use sycamore::prelude::*;
use sycamore::web::create_isomorphic_resource;

struct Data {
// Define the data format here.
}

async fn fetch_data() -> Data {
// Perform, for instance, an HTTP request to an API endpoint.
}

let resource = create_isomorphic_resource(fetch_data);
```

A `Resource<T>` is a wrapper around a `Signal<Option<T>>`. The value is
initially set to `None` while the data is loading. It is then set to `Some(...)`
containing the value of the loaded data. This makes it convenient to display the
data in your view.

```rust
view! {
(if let Some(data) = resource.get_clone() {
view! {
...
}
} else {
view! {}
})
}
```

Note that `create_isomorphic_resource`, as the name suggests, runs both on the
client and on the server. If you only want data-fetching to happen on the
client, you can use `create_client_resource` which will never load data on the
server.

Right now, we do not yet have a `create_server_resource` function which only
runs on the server because this requires some form of data-serializaation and
server-integration which we have not fully worked out yet.

### Refreshing Resources

Resources can also have dependencies, just like memos. However, since resources
are async, we cannot track reactive dependencies like we would in a synchronous
context. Instead, we have to explicitly specify which dependencies the resource
depends on. This can be accomplished with the `on(...)` utility function.

```rust
let id = create_signal(12345);
let resource = create_resource(on(id, move || async move {
fetch_user(id).await
}));
```

Under the hood, `on(...)` simply creates a closure that first accesses `id` and
then constructs the future. This makes it so that we access the signal
synchronously first before performing any asynchronous tasks.

## Suspense

With async data, we do not want to show the UI until it is ready. This problem
is solved by the `Suspense` component and related APIs. When a `Suspense`
component is created, it automatically creates a new _suspense boundary_. Any
asynchronous data accessed underneath this boundary will automatically be
tracked. This includes accessing resources using the resources API.

Using `Suspense` lets us set a fallback view to display while we are loading the
asynchronous data. For example:

```rust
view! {
Suspense(fallback=move || view! { LoadingSpinner {} }) {
(if let Some(data) = resource.get_clone() {
view! {
...
}
} else {
view! {}
})
}
}
```

Since we are accessing `resource` under the suspense boundary, our `Suspense`
component will display the fallback until the resource is loaded.

## Transition

Resources can also be refreshed when one of its dependencies changes. This will
cause the surrounding suspense boundary to be triggered again.

This is sometimes undesired. To prevent this, just replace `Suspense` with
`Transition`. This component will continue to show the old view until the new
data has been loaded in, providing a smoother experience.
72 changes: 71 additions & 1 deletion docs/next/server-side-rendering/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,74 @@ title: SSR Streaming

# SSR Streaming

> Note: this page is current a stub. Help us write it!
Not only does Sycamore support server side rendering, Sycamore also supports
server side streaming. What this means is that Sycamore can start sending HTML
over to the client before all the data has been loaded, making for a better user
experience.

## Different SSR modes

There are 3 different SSR rendering modes.

### Sync mode

This is the default mode and is used when calling `render_to_string`.

In sync mode, data is never fetched on the server side and the suspense fallback
is always rendered.

**Advantages**:

- Fast time-to-first-byte (TTFB) and first-contentful-paint (FCP), since we
don't need to perform any data-fetching.
- Simpler programming model, since we don't need to worry about data-fetching on
the server.

**Disadvantages**:

- Actual data will likely take longer to load, since the browser needs to first
start running the WASM binary before figuring out that more HTTP requests are
required.
- Worse SEO since data is only loaded after the initial HTML is rendered.

### Blocking mode

The server already knows which data is needed for rendering a certain page. So
why not just perform data-fetching directly on the server? This is what blocking
mode does.

You can use blocking mode by calling `render_to_string_await_suspense`. Blocking
mode means that the server will wait for all suspense to resolve before sending
the HTML.

**Advantages**:

- Faster time to loading data, since the server does all the data-fetching.
- Better SEO since content is rendered to static HTML.

**Disadvantages**:

- Slow time-to-first-byte (TTFB) and first-contentful-paint (FCP) since we must
wait for data to load before we receive any HTML.
- Slightly higher complexity since we must worry about serializing our data to
be hydrated on the client.

### Streaming mode

Streaming mode is somewhat of a compromise between sync and blocking mode. In
streaming mode, the server starts sending HTML to the client immediately, which
contains the fallback shell. Once data loads on the server, the new HTML is sent
down the same HTTP connection and automatically replaces the fallback.

You can use streaming mode by calling `render_to_string_stream`.

**Advantages**:

- Fast time-to-first-byte (TTFB) and first-contentful-paint (FCP) since the
server starts streaming HTML immediately.
- Better SEO for the same reasons as blocking mode.

**Disadvantages**:

- Slightly higher complexity since we must worry about serialiing our data to be
hydrated on the client, similarly to blocking mode.
2 changes: 1 addition & 1 deletion examples/ssr-suspense/index.html
Original file line number Diff line number Diff line change
@@ -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>
<!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>
2 changes: 2 additions & 0 deletions packages/sycamore-futures/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub fn spawn_local(fut: impl Future<Output = ()> + 'static) {
/// If the scope is destroyed before the future is completed, it is aborted immediately. This
/// ensures that it is impossible to access any values referencing the scope after they are
/// destroyed.
#[cfg_attr(debug_assertions, track_caller)]
pub fn spawn_local_scoped(fut: impl Future<Output = ()> + 'static) {
let scoped = ScopedFuture::new_in_current_scope(fut);
spawn_local(scoped);
Expand All @@ -69,6 +70,7 @@ impl<T: Future> Future for ScopedFuture<T> {
}

impl<T: Future> ScopedFuture<T> {
#[cfg_attr(debug_assertions, track_caller)]
pub fn new_in_current_scope(f: T) -> Self {
let (abortable, handle) = abortable(f);
on_cleanup(move || handle.abort());
Expand Down
5 changes: 3 additions & 2 deletions packages/sycamore-futures/src/suspense.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ impl SuspenseScope {
global
.all_tasks_remaining
.update(|vec| vec.push(tasks_remaining));
// TODO: remove self from global if scope is disposed.
Self {
tasks_remaining,
parent: parent.map(create_signal),
Expand Down Expand Up @@ -155,8 +156,8 @@ pub fn create_detatched_suspense_scope<T>(f: impl FnOnce() -> T) -> (T, Suspense
///
/// Returns a tuple containing the return value of the function and the created suspense scope.
///
/// If this is called inside another call to [`await_suspense`], this suspense will wait until the
/// parent suspense is resolved.
/// If this is called inside another call to [`create_suspense_scope`], this suspense will wait
/// until the parent suspense is resolved.
pub fn create_suspense_scope<T>(f: impl FnOnce() -> T) -> (T, SuspenseScope) {
let parent = try_use_context::<SuspenseScope>();
let scope = SuspenseScope::new(parent);
Expand Down
1 change: 1 addition & 0 deletions packages/sycamore-reactive/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ fn provide_context_in_node<T: 'static>(id: NodeId, value: T) {
}

/// Tries to get a context value of the given type. If no context is found, returns `None`.
#[cfg_attr(debug_assertions, track_caller)]
pub fn try_use_context<T: Clone + 'static>() -> Option<T> {
let root = Root::global();
let nodes = root.nodes.borrow();
Expand Down
1 change: 1 addition & 0 deletions packages/sycamore-reactive/src/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ pub fn create_child_scope(f: impl FnOnce()) -> NodeHandle {
/// child_scope.dispose(); // Executes the on_cleanup callback.
/// # });
/// ```
#[cfg_attr(debug_assertions, track_caller)]
pub fn on_cleanup(f: impl FnOnce() + 'static) {
let root = Root::global();
if !root.current_node.get().is_null() {
Expand Down
2 changes: 1 addition & 1 deletion packages/sycamore-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! - `hydrate` - Enables hydration support in DOM node. By default, hydration is disabled to reduce
//! binary size.
//!
//! - `suspense` - Enables suspense support.
//! - `suspense` - Enables suspense and resources support.
//!
//! - `wasm-bindgen-interning` (_default_) - Enables interning for `wasm-bindgen` strings. This
//! improves performance at a slight cost in binary size. If you want to minimize the size of the
Expand Down
10 changes: 1 addition & 9 deletions packages/sycamore-web/src/node/ssr_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ pub enum SsrNode {
Dynamic {
view: Arc<Mutex<View<Self>>>,
},
SuspenseMarker {
key: u32,
},
}

impl From<SsrNode> for View<SsrNode> {
Expand Down Expand Up @@ -63,7 +60,7 @@ impl ViewNode for SsrNode {
create_effect({
let text = text.clone();
move || {
let mut value = f();
let mut value = Some(f());
let value: &mut Option<String> =
(&mut value as &mut dyn Any).downcast_mut().unwrap();
*text.lock().unwrap() = value.take().unwrap();
Expand Down Expand Up @@ -259,11 +256,6 @@ pub(crate) fn render_recursive(node: &SsrNode, buf: &mut String) {
SsrNode::Dynamic { view } => {
render_recursive_view(&view.lock().unwrap(), buf);
}
SsrNode::SuspenseMarker { key } => {
buf.push_str("<!--sycamore-suspense-");
buf.push_str(&key.to_string());
buf.push_str("-->");
}
}
}

Expand Down
Loading

0 comments on commit ce9a6fe

Please sign in to comment.