yewstack / yew

Rust / Wasm framework for creating reliable and efficient web applications
https://yew.rs
Apache License 2.0
30.24k stars 1.41k forks source link

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

Open futursolo opened 2 years ago

futursolo commented 2 years ago

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:

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.

futursolo commented 2 years ago

Alternative Proposal

The previously proposed StatefulSwitch approach has some limitations:

  1. It only works on route level.
  2. The state type is shared between different route variants.

To avoid these limitations, a new crate, tentatively named yew-link, is introduced.

LinkedState

A LinkedState trait is added to declare a linked state:

trait LinkedState: Serializable + Deserializable {
    #[cfg(feature = "ssr")]
    type Context: 'static;
    type Input: 'static + PartialEq + Serializable + Deserializable;
    type Error: 'static + Error + Serializable + Deserializable;

    #[cfg(feature = "ssr")]
    fn get_linked_state(&self, ctx: &Self::Context, input: &Self::Input) -> Box<dyn Future<Output = Result<Self, Self::Error>>>;
}

// We need a #[linked_state] so that it automatically:
// - Adds `#[async_trait(?Send)]`
// - Strips `Context` and `get_linked_state ` from CSR bundle
#[linked_state]
impl LinkedState for MyState {
    type Context = ServerContext;
    type Input = ();
    type Error = Infallible;

    async fn get_linked_state(&self, ctx: &Self::Context, input: &Self::Input) -> Result<Self, Self::Error> {
        todo!("implementation omitted")
    }
}

RemoteLinkProvider

This is a context provider that links linked states to a remote endpoint.

#[function_component]
fn App() -> Html {
    html! {
        <RemoteLinkProvider endpoint="https://api.my-server.com/my-stateful-route-endpoint">
            // ... children
        </RemoteLinkProvider>
    }
}

LocalLinkProvider

Similar to RemoteLinkProvider, but resolves linked states locally. This is usually used in SSR.

LinkedStateService

A tower service to create an endpoint to resolve linked states.

use_linked_state

To link a state, developers can use the use_linked_state hook.

This can be used in any component that is a child of a LinkProvider. It is not limited at route level.


#[hook]
pub fn use_linked_state<T>(input: T::Input) -> SuspensionResult<Result<T, T::Error>>
    where
    T: LinkedState + 'static
{
    todo!("implementation omitted")
}

The initial linked state is carried to the client side with use_prepared_state / use_transitive_state and any subsequent states are fetched by the LinkProvider.

#[derive(Properties, PartialEq)]
pub struct MyCompProps {
    pub page_id: u32,
}

#[function_component]
pub fn MyComp(props: &MyCompProps) -> HtmlResult {
    let page_content = use_linked_state::<PageState>(props.page_id)?.unwrap();

    <div>{page_content.inner}</div>
}