diff --git a/Cargo.lock b/Cargo.lock index 9ee1f740c66..72de516da99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + [[package]] name = "async-trait" version = "0.1.89" @@ -187,6 +193,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + [[package]] name = "base64" version = "0.22.1" @@ -2909,6 +2921,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2981,6 +3004,17 @@ dependencies = [ "lock_api", ] +[[package]] +name = "split-wasm" +version = "0.1.0" +dependencies = [ + "async-once-cell", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew", +] + [[package]] name = "ssr-e2e" version = "0.1.0" @@ -3880,6 +3914,28 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm_split_helpers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a114b3073258dd5de3d812cdd048cca6842342755e828a14dbf15f843f2d1b84" +dependencies = [ + "async-once-cell", + "wasm_split_macros", +] + +[[package]] +name = "wasm_split_macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56481f8ed1a9f9ae97ea7b08a5e2b12e8adf9a7818a6ba952b918e09c7be8bf0" +dependencies = [ + "base16", + "quote", + "sha2", + "syn 2.0.117", +] + [[package]] name = "web-sys" version = "0.3.91" @@ -4234,6 +4290,7 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" name = "yew" version = "0.23.0" dependencies = [ + "async-once-cell", "base64ct", "bincode 2.0.0-rc.3", "console_error_panic_hook", @@ -4254,6 +4311,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", + "wasm_split_helpers", "web-sys", "yew-macro", ] diff --git a/examples/split-wasm/.cargo/config.toml b/examples/split-wasm/.cargo/config.toml new file mode 100644 index 00000000000..b65cc8fb321 --- /dev/null +++ b/examples/split-wasm/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.'cfg(target_arch = "wasm32")'] +rustflags=["-Clink-args=--emit-relocs"] diff --git a/examples/split-wasm/Cargo.toml b/examples/split-wasm/Cargo.toml new file mode 100644 index 00000000000..0e7ac495cc9 --- /dev/null +++ b/examples/split-wasm/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "split-wasm" +version = "0.1.0" +authors = [] +edition = "2021" +license = "MIT OR Apache-2.0" + +[dependencies] +async-once-cell = "0.5.3" +yew = { path = "../../packages/yew", features = ["csr"] } +wasm-bindgen = "*" +wasm-bindgen-futures = "*" + +[dependencies.web-sys] +version = "0.3" +features = ["HtmlInputElement"] diff --git a/examples/split-wasm/build.sh b/examples/split-wasm/build.sh new file mode 100755 index 00000000000..4f7b6b3d399 --- /dev/null +++ b/examples/split-wasm/build.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -e +shopt -s extglob + +CARGO="cargo" +WASM_BINDGEN=~/.cache/trunk/"$(cargo tree --package wasm-bindgen --depth=0 --format="{p}" -e normal | sed -e 's/ v/-/g')/wasm-bindgen" +echo "$WASM_BINDGEN" +WASM_OPT="$(ls -dv1 ~/.cache/trunk/wasm-opt-version_* | tail -n 1)"/bin/wasm-opt # will select most recently installed :) + +PROFILE="release" +THIS_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd) +TARGET_DIR=$(cd -- "$THIS_DIR"/../../target/ &> /dev/null && pwd) +OPT=1 + +$CARGO build --target wasm32-unknown-unknown \ + $(case $PROFILE in "debug") ;; "release") echo "--release" ;; *) echo '--profile "${PROFILE}"' ;; esac) + +mkdir -p dist/ +GLOBIGNORE=".:.." +rm -rf dist/* +mkdir dist/.stage +( + wasm_split_cli --verbose "$TARGET_DIR/wasm32-unknown-unknown/${PROFILE}/split-wasm.wasm" "$THIS_DIR"/dist/.stage/ \ + > "$THIS_DIR"/dist/.stage/split.log +) +echo "running wasm-bindgen" +$WASM_BINDGEN dist/.stage/main.wasm --out-dir dist/.stage --no-demangle --target web --keep-lld-exports --no-typescript +if [ "$OPT" == 1 ] ; then + echo "running wasm-opt" + for wasm in dist/.stage/!(main).wasm ; do + $WASM_OPT -Os "$wasm" -o dist/"$(basename -- "$wasm")" + done +else + for wasm in dist/.stage/!(main).wasm ; do + mv "$wasm" dist/"$(basename -- "$wasm")" + done +fi +echo "moving to dist dir" +mv dist/.stage/*.!(wasm) dist +#rmdir dist/.stage +cp index.html dist/ + diff --git a/examples/split-wasm/index.html b/examples/split-wasm/index.html new file mode 100644 index 00000000000..6d96bbcd761 --- /dev/null +++ b/examples/split-wasm/index.html @@ -0,0 +1,28 @@ + + + + + + + Yew • Split WASM + + + + + + + + + + + diff --git a/examples/split-wasm/src/main.rs b/examples/split-wasm/src/main.rs new file mode 100644 index 00000000000..4e6c354bc56 --- /dev/null +++ b/examples/split-wasm/src/main.rs @@ -0,0 +1,20 @@ +use std::cell::Cell; + +use wasm_bindgen::prelude::wasm_bindgen; + +// You can use a global variable from javascript, or a static +// and even thread local variable without any changes. +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(thread_local_v2, js_name = "globalFoo")] + static GLOBAL_FOO: u32; +} +thread_local! { + static COUNTER: Cell = const { Cell::new(0) }; +} + +mod yew; + +pub fn main() { + yew::main(); +} diff --git a/examples/split-wasm/src/yew.rs b/examples/split-wasm/src/yew.rs new file mode 100644 index 00000000000..6f428c74f2c --- /dev/null +++ b/examples/split-wasm/src/yew.rs @@ -0,0 +1,70 @@ +use std::future::{pending, Future}; + +use web_sys::HtmlInputElement; +use yew::lazy::declare_lazy_component; +use yew::prelude::*; +use yew::suspense::Suspension; +use yew::Renderer; + +use super::{COUNTER, GLOBAL_FOO}; + +// --------------------------------------------------------------------------- +// A counter component — the one we'll load lazily. +// Uses use_state, which triggers re-renders via the scope it was created with. +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Properties)] +pub struct CounterProps { + pub label: AttrValue, +} + +#[component] +fn Counter(props: &CounterProps) -> Html { + let count = use_state(|| 0_i32); + let onclick = { + let count = count.clone(); + Callback::from(move |_| count.set(*count + 1)) + }; + let global_foo = GLOBAL_FOO.with(|f| *f); + let render_counter = COUNTER.with(|cnt| { + let c = cnt.get(); + cnt.set(c + 1); + c + }); + + html! { +
+

{"This component is loaded from a separate bundle, render count: "}{render_counter}

+

{"Here is a number loaded from (shared) memory: "}{global_foo}

+

{ &props.label }

+

{ *count }

+ +
+ } +} + +declare_lazy_component!(Counter as LazyAddition in lazy_addition); + +#[component] +fn Pending() -> HtmlResult { + Err(Suspension::from_future(pending()).into()) +} + +#[component] +fn App() -> Html { + let toggle = use_state(|| false); + let show = *toggle; + html! { + <> + ().checked())} /> + + {"not yet loaded"}

}}> + if show { } else { } +
+ + } +} + +pub fn main() { + let _ = Renderer::::new().render(); +} diff --git a/packages/yew/Cargo.toml b/packages/yew/Cargo.toml index 36c4f538147..6ec4197879a 100644 --- a/packages/yew/Cargo.toml +++ b/packages/yew/Cargo.toml @@ -34,6 +34,8 @@ serde = { workspace = true, features = ["derive"] } tracing = "0.1.44" tokise = "0.2.0" rustversion.workspace = true +wasm_split_helpers = "0.2.0" +async-once-cell = "0.5.3" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures.workspace = true diff --git a/packages/yew/src/html/component/mod.rs b/packages/yew/src/html/component/mod.rs index c9bb5ad1de5..47d7f34bb06 100644 --- a/packages/yew/src/html/component/mod.rs +++ b/packages/yew/src/html/component/mod.rs @@ -53,6 +53,12 @@ impl Context { &self.props } + /// The component's props as an Rc + #[inline] + pub(crate) fn rc_props(&self) -> &Rc { + &self.props + } + #[cfg(feature = "hydration")] pub(crate) fn creation_mode(&self) -> RenderMode { self.creation_mode diff --git a/packages/yew/src/html/component/scope.rs b/packages/yew/src/html/component/scope.rs index 88ec635377b..3ceceaa2d43 100644 --- a/packages/yew/src/html/component/scope.rs +++ b/packages/yew/src/html/component/scope.rs @@ -390,7 +390,7 @@ mod feat_csr_ssr { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; - use crate::html::component::lifecycle::UpdateRunner; + use crate::html::component::lifecycle::{RenderRunner, UpdateRunner}; use crate::scheduler::{self, Shared}; #[derive(Debug)] @@ -473,6 +473,16 @@ mod feat_csr_ssr { scheduler::start(); } + #[inline] + pub(crate) fn schedule_render(&self) { + scheduler::push_component_render( + self.id, + Box::new(RenderRunner { + state: self.state.clone(), + }), + ); + } + #[inline] pub(super) fn arch_send_message(&self, msg: T) where diff --git a/packages/yew/src/lazy.rs b/packages/yew/src/lazy.rs new file mode 100644 index 00000000000..05b62488f84 --- /dev/null +++ b/packages/yew/src/lazy.rs @@ -0,0 +1,236 @@ +//! Implements lazy fetching of components + +// A simple wrapper is easy to implement. This module exists to support message passing and more +// involved logic + +use std::cell::RefCell; +use std::future::Future; +use std::rc::Rc; + +use crate::html::Scope; +use crate::scheduler::Shared; +use crate::suspense::Suspension; +use crate::virtual_dom::VComp; +use crate::{BaseComponent, Context}; + +type ScopeRef = Shared>>; + +#[derive(Debug)] +struct CompVTableImpl { + /// The way to create a component from properties and a way to reference it later. + /// It is important that we return a structure that already captures the vtable to + /// the component's functionality (Mountable), so that the linker doesn't see that + /// the main module references C's BaseComponent impl. + wrap_html: fn(Rc, ScopeRef) -> VComp, +} +/// Component vtable for a component. +/// +/// Return `LazyVTable::::vtable()` from your implementation of +/// [`LazyComponent::fetch`] after resolving it. +pub struct LazyVTable { + imp: &'static CompVTableImpl, +} +impl std::fmt::Debug for LazyVTable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LazyVTable") + .field("vtable", &(self.imp as *const _)) + .finish() + } +} +impl Clone for LazyVTable { + fn clone(&self) -> Self { + *self + } +} +impl Copy for LazyVTable {} + +impl LazyVTable { + /// Returns the singleton vtable for a component. + /// + /// Return this from [`LazyComponent::fetch`] for your lazy component. + pub fn vtable() -> LazyVTable { + fn wrap_html( + props: Rc, + scope_ref: Shared>>, + ) -> VComp { + VComp::new_with_ref(props, scope_ref) + } + LazyVTable { + imp: &const { + CompVTableImpl { + wrap_html: wrap_html::, + } + }, + } + } +} +/// Implement this trait to support lazily loading a component. +/// +/// Used in conjunction with the [`Lazy`] component. +pub trait LazyComponent: 'static { + /// The component that is lazily being fetched + type Underlying: BaseComponent; + /// Fetch the component's impl + fn fetch() -> impl Future> + Send; +} + +enum LazyState { + Pending(Suspension), + #[allow(unused)] // Only constructed with feature csr or ssr + Created(LazyVTable), +} + +impl std::fmt::Debug for LazyState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Pending(arg0) => f.debug_tuple("Pending").field(arg0).finish(), + Self::Created(arg0) => f.debug_tuple("Created").field(arg0).finish(), + } + } +} + +/// Wrapper for a lazily fetched component +/// +/// This component suspends as long as the underlying component is still being fetched, +/// then behaves as the underlying component itself. +pub struct Lazy { + inner_scope: ScopeRef, + // messages sent to the component before the inner_scope is set are buffered + message_buffer: RefCell::Message>>, + state: Shared>, +} + +impl std::fmt::Debug for Lazy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Lazy") + .field("inner_scope", &self.inner_scope) + .field("message_buffer", &"...") + .field("state", &self.state) + .finish() + } +} + +impl BaseComponent for Lazy { + type Message = ::Message; + type Properties = ::Properties; + + fn create(ctx: &Context) -> Self { + let host_scope = ctx.link().clone(); + let state = Rc::>::new_cyclic(move |state| { + let state = state.clone(); + let suspension = Suspension::from_future(async move { + // Ignore error in case receiver was dropped + let vtable = C::fetch().await; + #[cfg(any(feature = "ssr", feature = "csr"))] + if let Some(state) = state.upgrade() { + *state.borrow_mut() = LazyState::Created(vtable); + // force a re-render with this new state (without a message exchange) + host_scope.schedule_render(); + } + let _ = (host_scope, state, vtable); + }); + RefCell::new(LazyState::Pending(suspension)) + }); + Self { + inner_scope: Rc::default(), + message_buffer: RefCell::default(), + state, + } + } + + fn update(&mut self, _: &Context, msg: Self::Message) -> bool { + if let Some(inner) = self.inner_scope.borrow().as_ref() { + inner.send_message(msg); + } else { + self.message_buffer.borrow_mut().push(msg); + } + false + } + + fn changed(&mut self, _: &Context, _old_props: &Self::Properties) -> bool { + true + } + + fn view(&self, ctx: &Context) -> crate::HtmlResult { + match &*self.state.borrow() { + LazyState::Pending(suspension) => Err(suspension.clone().into()), + LazyState::Created(lazy_vtable) => { + let comp = + (lazy_vtable.imp.wrap_html)(ctx.rc_props().clone(), self.inner_scope.clone()); + Ok(comp.into()) + } + } + } + + fn rendered(&mut self, _: &Context, first_render: bool) { + if first_render { + let inner = self.inner_scope.borrow(); + let inner = inner.as_ref().expect("lazy component to have rendered"); + inner.send_message_batch(std::mem::take(&mut *self.message_buffer.borrow_mut())); + } else { + #[cfg(debug_assertions)] + assert!( + self.message_buffer.borrow().is_empty(), + "no message in buffer after first render" + ); + } + } + + fn destroy(&mut self, _: &Context) {} + + fn prepare_state(&self) -> Option { + None + } +} + +/// Make a component accessible as a lazily loaded component in a separate wasm module +#[doc(hidden)] +#[macro_export] +macro_rules! __declare_lazy_component { + ($comp:ty as $lazy_name:ident in $module:ident) => { + struct Proxy; + impl $crate::lazy::LazyComponent for Proxy { + type Underlying = $comp; + + async fn fetch() -> $crate::lazy::LazyVTable { + #[$crate::lazy::wasm_split::wasm_split($module, wasm_split_path = $crate::lazy::wasm_split)] + fn split_fetch() -> $crate::lazy::LazyVTable<$comp> { + $crate::lazy::LazyVTable::<$comp>::vtable() + } + struct F( + ::std::option::Option< + ::std::pin::Pin< + ::std::boxed::Box< + dyn ::std::future::Future> + + ::std::marker::Send, + >, + >, + >, + ); + impl Future for F { + type Output = $crate::lazy::LazyVTable<$comp>; + + fn poll( + mut self: ::std::pin::Pin<&mut Self>, + cx: &mut ::std::task::Context<'_>, + ) -> ::std::task::Poll { + self.0 + .get_or_insert_with(|| ::std::boxed::Box::pin(split_fetch())) + .as_mut() + .poll(cx) + } + } + static CACHE: $crate::lazy::LazyCell<$crate::lazy::LazyVTable<$comp>, F> = + $crate::lazy::LazyCell::new(F(None)); + *::std::pin::Pin::static_ref(&CACHE).await.get_ref() + } + } + type $lazy_name = $crate::lazy::Lazy; + }; +} +#[doc(hidden)] +pub use ::async_once_cell::Lazy as LazyCell; +#[doc(hidden)] +pub use ::wasm_split_helpers as wasm_split; + +pub use crate::__declare_lazy_component as declare_lazy_component; diff --git a/packages/yew/src/lib.rs b/packages/yew/src/lib.rs index e6aff0a953b..6f4ee76c7c2 100644 --- a/packages/yew/src/lib.rs +++ b/packages/yew/src/lib.rs @@ -276,6 +276,7 @@ pub mod context; mod dom_bundle; pub mod functional; pub mod html; +pub mod lazy; pub mod platform; pub mod scheduler; mod sealed; diff --git a/packages/yew/src/scheduler.rs b/packages/yew/src/scheduler.rs index 0a710b95419..1081fb26895 100644 --- a/packages/yew/src/scheduler.rs +++ b/packages/yew/src/scheduler.rs @@ -211,6 +211,13 @@ mod feat_hydration { pub(crate) use feat_hydration::*; /// Execute any pending [Runnable]s +#[cfg(any( + test, + feature = "test", + not(target_arch = "wasm32"), + target_os = "wasi", + feature = "not_browser_env" +))] pub(crate) fn start_now() { #[tracing::instrument(level = tracing::Level::DEBUG)] fn scheduler_loop() { diff --git a/packages/yew/src/virtual_dom/vcomp.rs b/packages/yew/src/virtual_dom/vcomp.rs index 0dbc9ce7443..dba93b5301a 100644 --- a/packages/yew/src/virtual_dom/vcomp.rs +++ b/packages/yew/src/virtual_dom/vcomp.rs @@ -14,11 +14,12 @@ use super::Key; use crate::dom_bundle::Fragment; #[cfg(feature = "csr")] use crate::dom_bundle::{BSubtree, DomSlot, DynamicDomSlot}; -use crate::html::BaseComponent; +#[cfg(any(feature = "ssr", feature = "csr"))] +use crate::html::AnyScope; #[cfg(feature = "csr")] use crate::html::Scoped; -#[cfg(any(feature = "ssr", feature = "csr"))] -use crate::html::{AnyScope, Scope}; +use crate::html::{BaseComponent, Scope}; +use crate::scheduler::Shared; #[cfg(feature = "ssr")] use crate::{feat_ssr::VTagKind, platform::fmt::BufWriter}; @@ -92,11 +93,25 @@ pub(crate) trait Mountable { pub(crate) struct PropsWrapper { props: Rc, + scope_ref: Option>>>, } impl PropsWrapper { pub fn new(props: Rc) -> Self { - Self { props } + Self { + props, + scope_ref: None, + } + } + + pub fn new_with_ref( + props: Rc, + scope_ref: Shared>>, + ) -> Self { + Self { + props, + scope_ref: Some(scope_ref), + } } } @@ -104,6 +119,7 @@ impl Mountable for PropsWrapper { fn copy(&self) -> Box { let wrapper: PropsWrapper = PropsWrapper { props: Rc::clone(&self.props), + scope_ref: self.scope_ref.clone(), }; Box::new(wrapper) } @@ -128,6 +144,9 @@ impl Mountable for PropsWrapper { slot: DomSlot, ) -> (Box, DynamicDomSlot) { let scope: Scope = Scope::new(Some(parent_scope.clone())); + if let Some(scope_ref) = self.scope_ref { + *scope_ref.borrow_mut() = Some(scope.clone()); + } let own_slot = scope.mount_in_place(root.clone(), parent, slot, self.props); (Box::new(scope), own_slot) @@ -248,6 +267,19 @@ impl VComp { _marker: 0, } } + + /// Attach a ref into which the scope of the child will be written when it is mounted + pub(crate) fn new_with_ref( + props: Rc, + scope_ref: Shared>>, + ) -> Self { + VComp { + type_id: TypeId::of::(), + mountable: Box::new(PropsWrapper::::new_with_ref(props, scope_ref)), + key: None, + _marker: 0, + } + } } impl PartialEq for VComp {