Skip to content

Send states created during SSR alongside SSR artifact to be used with client-side rendering hydration #2649

@futursolo

Description

@futursolo

This issue outlines a proposal to add a set of hooks that can be used to carry states / artifacts created during the server-side rendering to the client side for hydration and subsequent rendering and a new crate that links a state to the server.

yew

These hooks are usually not used by end-users but:

  • http clients (or other data fetching clients)
  • state management libraries
  • yew-router
  • css-in-rust libraries

use_prepared_state

usage:

let state = use_prepared_state!(async |deps: &Deps| -> ReturnType { ... }, deps)?;

This hook executes an async closure that creates a state from deps and returns a SuspensionResult<Option<Rc<ReturnType>>>.

(I am aware that async closure is not stable, but proc macro can rewrite it. We need to extract the return type from the closure for client side rendering as the closure itself is not present in the client side bundle.)

Both the return type and deps are sent to client-side with serde.
(I am currently leaning towards bincode as its smaller than serde_json and used by yew-agent.)

During client-side rendering hydration, it will return Ok(None) if:

  1. The component is not hydrated.
  2. The deps passed during the hydration do not match deps associated with the server-side rendered result.

The deps is needed so that if the deps used to generate state changes it can automatically invalidate the server-side rendered state.

State SSR CSR
Loading Err(Suspension) Err(Suspension)
Loaded (SSR) Ok(Some(Rc<ReturnType>)) -
Loaded (CSR, deps == server_side_deps) - Ok(Some(Rc<ReturnType>))
Loaded (CSR, deps != server_side_deps) - Ok(None)

This is a macro-based hook so that the content inside the closure can be stripped from client side rendering bundle automatically.

This can be used to collect a state created during the server-side rendering and ensures that during the hydration, the application will receive the same value.

use_transitive_state

let state = use_transitive_state!(|deps: &Deps| -> ReturnType { ... }, deps)?;

Similar to use_prepared_state, but the closure is run after the server-side rendering of current component is finished but before destroy (effect stage). During server-side rendering, the component never sees the state (always return Ok(None)).

This is used to carry cache of an http client or states for a state management library so that they can collect all states created during the server-side rendering to be sent to the client side for hydration after its content is created.

State SSR CSR
Loading - Err(Suspension)
Loaded (CSR, deps == server_side_deps) - Ok(Some(Rc<ReturnType>))
Loaded (CSR, deps != server_side_deps) - Ok(None)

yew-router

getServerSideProps in Next.js is opinionated about the server environment (node or edge), protocol and requires an http client.

I wish a client agnostic, protocol agnostic getServerSideProps that is available in all supported rust platform can be established here.
However, we may provide a reference implementation about how it is handled.
(e.g.: tower service + gloo-net + bincode)

StatefulRoutable

A StatefulRoutable trait is added:

trait StatefulRoutable: Routable + Serializable + Deserializable {
    #[cfg(feature = "ssr")]
    type Context: 'static;
    type State: 'static + Serializable + Deserializable;

    #[cfg(feature = "ssr")]
    fn get_route_state(&self, ctx: &Self::Context) -> Box<dyn Future<Output = Self::State>>;
}

// We need a #[stateful_routable] so that it automatically:
// - Adds `#[async_trait(?Send)]`
// - Strips `Context` and `get_route_state` from CSR bundle
#[stateful_routable]
impl StatefulRoutable for Route {
    type Context = ServerContext;
    type State = RouteState;

    async fn get_route_state(&self, ctx: &Self::Context) -> Self::State {
        todo!("implementation omitted")
    }
}

Users can then implement a server that sends the route state to the client side.

We can also provide a tower service to help users to implement an endpoint.

StatefulSwitch

A stateful routable is combined with a StatefulSwitch (should be declared inside a ):

// equivalent to: async fn (route: &MyStatefulRoute) -> Result<MyStatefulRoute::State, MyStatefulRoute>
// redirects on `Err(Route)`
// a closure here allows hook handles to be captured
// (such as a set handle to a global error state)
let fetch_state = move |route: &MyStatefulRoute| async move {
    let state = HttpEndpoint::new("https://api.my-server.com/my-stateful-route-endpoint")
        .read(route)
        .await
        // error handling omitted.
        // In actual application, this closure should also handle errors and redirect the user to an error page
        .unwrap();

    Ok(state)
};

let render = move |route: &MyStatefulRoute, state: &MyStatefulRoute::State| {
    // implementation omitted.
};

html! {
    <Suspense {fallback}>
        // The initial state is carried to the client side with `use_prepared_state`
        // and any subsequent state is fetched with fetch_state.
        <StatefulSwitch<MyStatefulRoute> {fetch_state} {render} />
    </Suspense>
}

Caveats

In this implementation, for all StatefulRoutable variants, the State type is shared.

However, it may be better to map each Routable variant to a different state type.
i.e.: Route::MyAccount -> MyAccountState, Route::Article -> ArticleState

I am not sure whether this is possible with current Rust typing system.

Metadata

Metadata

Assignees

Labels

A-yewArea: The main yew crateA-yew-routerArea: The yew-router cratefeature-requestA feature request

Type

No type

Projects

Status

In Progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions