-
-
Notifications
You must be signed in to change notification settings - Fork 599
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: modular
SharedContext
for hydration
- Loading branch information
Showing
5 changed files
with
317 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,7 @@ members = [ | |
"or_poisoned", | ||
|
||
# core | ||
"hydration_context", | ||
"leptos", | ||
"leptos_dom", | ||
"leptos_config", | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
[package] | ||
name = "hydration_context" | ||
edition = "2021" | ||
version.workspace = true | ||
|
||
[dependencies] | ||
or_poisoned = { workspace = true } | ||
futures = "0.3" | ||
serde = { version = "1", features = ["derive"] } | ||
wasm-bindgen = { version = "0.2", optional = true } | ||
js-sys = { version = "0.3", optional = true } | ||
|
||
[features] | ||
browser = ["dep:wasm-bindgen", "dep:js-sys"] | ||
|
||
[package.metadata.docs.rs] | ||
all-features = true | ||
rustdoc-args = ["--cfg", "docsrs"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
use super::{SerializedDataId, SharedContext}; | ||
use crate::{PinnedFuture, PinnedStream}; | ||
use core::fmt::Debug; | ||
use js_sys::Array; | ||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; | ||
use wasm_bindgen::prelude::wasm_bindgen; | ||
|
||
#[wasm_bindgen] | ||
extern "C" { | ||
static __RESOLVED_RESOURCES: Array; | ||
} | ||
|
||
#[derive(Default)] | ||
/// The shared context that should be used in the browser while hydrating. | ||
pub struct HydrateSharedContext { | ||
id: AtomicUsize, | ||
is_hydrating: AtomicBool, | ||
} | ||
|
||
impl HydrateSharedContext { | ||
/// Creates a new shared context for hydration in the browser. | ||
pub fn new() -> Self { | ||
Self { | ||
id: AtomicUsize::new(0), | ||
is_hydrating: AtomicBool::new(true), | ||
} | ||
} | ||
|
||
/// Creates a new shared context for hydration in the browser. | ||
/// | ||
/// This defaults to a mode in which the app is not hydrated, but allows you to opt into | ||
/// hydration for certain portions using [`SharedContext::set_is_hydrating`]. | ||
pub fn new_islands() -> Self { | ||
Self { | ||
id: AtomicUsize::new(0), | ||
is_hydrating: AtomicBool::new(false), | ||
} | ||
} | ||
} | ||
|
||
impl Debug for HydrateSharedContext { | ||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
f.debug_struct("HydrateSharedContext").finish() | ||
} | ||
} | ||
|
||
impl SharedContext for HydrateSharedContext { | ||
fn next_id(&self) -> SerializedDataId { | ||
let id = self.id.fetch_add(1, Ordering::Relaxed); | ||
SerializedDataId(id) | ||
} | ||
|
||
fn write_async(&self, _id: SerializedDataId, _fut: PinnedFuture<String>) {} | ||
|
||
fn read_data(&self, id: &SerializedDataId) -> Option<String> { | ||
__RESOLVED_RESOURCES.get(id.0 as u32).as_string() | ||
} | ||
|
||
fn await_data(&self, _id: &SerializedDataId) -> Option<String> { | ||
todo!() | ||
} | ||
|
||
fn pending_data(&self) -> Option<PinnedStream<String>> { | ||
None | ||
} | ||
|
||
fn get_is_hydrating(&self) -> bool { | ||
self.is_hydrating.load(Ordering::Relaxed) | ||
} | ||
|
||
fn set_is_hydrating(&self, is_hydrating: bool) { | ||
self.is_hydrating.store(true, Ordering::Relaxed) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
//! Isomorphic web applications that run on the server to render HTML, then add interactivity in | ||
//! the client, need to accomplish two tasks: | ||
//! 1. Send HTML from the server, so that the client can "hydrate" it in the browser by adding | ||
//! event listeners and setting up other interactivity. | ||
//! 2. Send data that was loaded on the server to the client, so that the client "hydrates" with | ||
//! the same data with which the server rendered HTML. | ||
//! | ||
//! This crate helps with the second part of this process. It provides a [`SharedContext`] type | ||
//! that allows you to store data on the server, and then extract the same data in the client. | ||
|
||
#![deny(missing_docs)] | ||
#![forbid(unsafe_code)] | ||
|
||
#[cfg(feature = "browser")] | ||
#[cfg_attr(docsrs, doc(cfg(feature = "browser")))] | ||
mod hydrate; | ||
mod ssr; | ||
use futures::Stream; | ||
#[cfg(feature = "browser")] | ||
pub use hydrate::*; | ||
use serde::{Deserialize, Serialize}; | ||
pub use ssr::*; | ||
use std::{fmt::Debug, future::Future, pin::Pin}; | ||
|
||
/// Type alias for a boxed [`Future`]. | ||
pub type PinnedFuture<T> = Pin<Box<dyn Future<Output = T> + Send + Sync>>; | ||
/// Type alias for a boxed [`Future`] that is `!Send`. | ||
pub type PinnedLocalFuture<T> = Pin<Box<dyn Future<Output = T>>>; | ||
/// Type alias for a boxed [`Stream`]. | ||
pub type PinnedStream<T> = Pin<Box<dyn Stream<Item = T> + Send + Sync>>; | ||
|
||
#[derive( | ||
Clone, Debug, PartialEq, Eq, Hash, Default, Deserialize, Serialize, | ||
)] | ||
#[serde(transparent)] | ||
/// A unique identifier for a piece of data that will be serialized | ||
/// from the server to the client. | ||
pub struct SerializedDataId(usize); | ||
|
||
/// Information that will be shared between the server and the client. | ||
pub trait SharedContext: Debug { | ||
/// Returns the next in a series of IDs that is unique to a particular request and response. | ||
/// | ||
/// This should not be used as a global unique ID mechanism. It is specific to the process | ||
/// of serializing and deserializing data from the server to the browser as part of an HTTP | ||
/// response. | ||
fn next_id(&self) -> SerializedDataId; | ||
|
||
/// The given [`Future`] should resolve with some data that can be serialized | ||
/// from the server to the client. This will be polled as part of the process of | ||
/// building the HTTP response, *not* when it is first created. | ||
/// | ||
/// In browser implementations, this should be a no-op. | ||
fn write_async(&self, id: SerializedDataId, fut: PinnedFuture<String>); | ||
|
||
/// Reads the current value of some data from the shared context, if it has been | ||
/// sent from the server. This returns the serialized data as a `String` that should | ||
/// be deserialized using [`Serializable::de`]. | ||
/// | ||
/// On the server and in client-side rendered implementations, this should | ||
/// always return [`None`]. | ||
fn read_data(&self, id: &SerializedDataId) -> Option<String>; | ||
|
||
/// Returns a [`Future`] that resolves with a `String` that should | ||
/// be deserialized using [`Serializable::de`] once the given piece of server | ||
/// data has resolved. | ||
/// | ||
/// On the server and in client-side rendered implementations, this should | ||
/// return a [`Future`] that is immediately ready with [`None`]. | ||
fn await_data(&self, id: &SerializedDataId) -> Option<String>; | ||
|
||
/// Returns some [`Stream`] of HTML that contains JavaScript `<script>` tags defining | ||
/// all values being serialized from the server to the client, with their serialized values | ||
/// and any boilerplate needed to notify a running application that they exist; or `None`. | ||
/// | ||
/// In browser implementations, this return `None`. | ||
fn pending_data(&self) -> Option<PinnedStream<String>>; | ||
|
||
/// Returns `true` if you are currently in a part of the application tree that should be | ||
/// hydrated. | ||
/// | ||
/// For example, in an app with "islands," this should be `true` inside islands and | ||
/// false elsewhere. | ||
fn get_is_hydrating(&self) -> bool; | ||
|
||
/// Sets whether you are currently in a part of the application tree that should be hydrated. | ||
/// | ||
/// For example, in an app with "islands," this should be `true` inside islands and | ||
/// false elsewhere. | ||
fn set_is_hydrating(&self, is_hydrating: bool); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
use super::{SerializedDataId, SharedContext}; | ||
use crate::{PinnedFuture, PinnedStream}; | ||
use futures::{ | ||
stream::{self, FuturesUnordered}, | ||
StreamExt, | ||
}; | ||
use or_poisoned::OrPoisoned; | ||
use std::{ | ||
fmt::{Debug, Write}, | ||
mem, | ||
sync::{ | ||
atomic::{AtomicBool, AtomicUsize, Ordering}, | ||
RwLock, | ||
}, | ||
}; | ||
|
||
#[derive(Default)] | ||
/// The shared context that should be used on the server side. | ||
pub struct SsrSharedContext { | ||
id: AtomicUsize, | ||
is_hydrating: AtomicBool, | ||
sync_buf: RwLock<Vec<ResolvedData>>, | ||
async_buf: RwLock<Vec<(SerializedDataId, PinnedFuture<String>)>>, | ||
} | ||
|
||
impl SsrSharedContext { | ||
/// Creates a new shared context for rendering HTML on the server. | ||
pub fn new() -> Self { | ||
Self { | ||
is_hydrating: AtomicBool::new(true), | ||
..Default::default() | ||
} | ||
} | ||
|
||
/// Creates a new shared context for rendering HTML on the server in "islands" mode. | ||
/// | ||
/// This defaults to a mode in which the app is not hydrated, but allows you to opt into | ||
/// hydration for certain portions using [`SharedContext::set_is_hydrating`]. | ||
pub fn new_islands() -> Self { | ||
Self { | ||
is_hydrating: AtomicBool::new(false), | ||
..Default::default() | ||
} | ||
} | ||
} | ||
|
||
impl Debug for SsrSharedContext { | ||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
f.debug_struct("SsrSharedContext") | ||
.field("id", &self.id) | ||
.field("is_hydrating", &self.is_hydrating) | ||
.field("sync_buf", &self.sync_buf) | ||
.field("async_buf", &self.async_buf.read().or_poisoned().len()) | ||
.finish() | ||
} | ||
} | ||
|
||
impl SharedContext for SsrSharedContext { | ||
fn next_id(&self) -> SerializedDataId { | ||
let id = self.id.fetch_add(1, Ordering::Relaxed); | ||
SerializedDataId(id) | ||
} | ||
|
||
fn write_async(&self, id: SerializedDataId, fut: PinnedFuture<String>) { | ||
self.async_buf.write().or_poisoned().push((id, fut)) | ||
} | ||
|
||
fn pending_data(&self) -> Option<PinnedStream<String>> { | ||
let sync_data = mem::take(&mut *self.sync_buf.write().or_poisoned()); | ||
let async_data = mem::take(&mut *self.async_buf.write().or_poisoned()); | ||
|
||
// 1) initial, synchronous setup chunk | ||
let mut initial_chunk = String::new(); | ||
// resolved synchronous resources | ||
initial_chunk.push_str("__RESOLVED_RESOURCES=["); | ||
for resolved in sync_data { | ||
resolved.write_to_buf(&mut initial_chunk); | ||
initial_chunk.push(','); | ||
} | ||
initial_chunk.push_str("];"); | ||
|
||
// pending async resources | ||
initial_chunk.push_str("__PENDING_RESOURCES=["); | ||
for (id, _) in &async_data { | ||
write!(&mut initial_chunk, "{},", id.0).unwrap(); | ||
} | ||
initial_chunk.push_str("];"); | ||
|
||
// resolvers | ||
initial_chunk.push_str("__RESOURCE_RESOLVERS=[];"); | ||
|
||
// 2) async resources as they resolve | ||
let async_data = async_data | ||
.into_iter() | ||
.map(|(id, data)| async move { | ||
let data = data.await; | ||
format!("__RESOLVED_RESOURCES[{}] = {data:?};", id.0) | ||
}) | ||
.collect::<FuturesUnordered<_>>(); | ||
|
||
let stream = | ||
stream::once(async move { initial_chunk }).chain(async_data); | ||
Some(Box::pin(stream)) | ||
} | ||
|
||
fn read_data(&self, _id: &SerializedDataId) -> Option<String> { | ||
None | ||
} | ||
|
||
fn await_data(&self, _id: &SerializedDataId) -> Option<String> { | ||
None | ||
} | ||
|
||
fn get_is_hydrating(&self) -> bool { | ||
self.is_hydrating.load(Ordering::Relaxed) | ||
} | ||
|
||
fn set_is_hydrating(&self, is_hydrating: bool) { | ||
self.is_hydrating.store(is_hydrating, Ordering::Relaxed) | ||
} | ||
} | ||
|
||
#[derive(Debug)] | ||
struct ResolvedData(SerializedDataId, String); | ||
|
||
impl ResolvedData { | ||
pub fn write_to_buf(&self, buf: &mut String) { | ||
let ResolvedData(id, ser) = self; | ||
// escapes < to prevent it being interpreted as another opening HTML tag | ||
let ser = ser.replace('<', "\\u003c"); | ||
write!(buf, "{}: {:?}", id.0, ser).unwrap(); | ||
} | ||
} |