From c1b990b27c34e5d6b95ec78e07394b3806b75dc1 Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Tue, 19 Jan 2021 08:56:22 -0500 Subject: [PATCH] Feat: add hooks --- packages/hooks/src/hooks/mod.rs | 68 ++++ packages/hooks/src/hooks/use_context.rs | 469 ++++++++++++++++++++++++ packages/hooks/src/hooks/use_effect.rs | 320 ++++++++++++++++ packages/hooks/src/hooks/use_reducer.rs | 203 ++++++++++ packages/hooks/src/hooks/use_ref.rs | 98 +++++ packages/hooks/src/hooks/use_state.rs | 142 +++++++ 6 files changed, 1300 insertions(+) create mode 100644 packages/hooks/src/hooks/mod.rs create mode 100644 packages/hooks/src/hooks/use_context.rs create mode 100644 packages/hooks/src/hooks/use_effect.rs create mode 100644 packages/hooks/src/hooks/use_reducer.rs create mode 100644 packages/hooks/src/hooks/use_ref.rs create mode 100644 packages/hooks/src/hooks/use_state.rs diff --git a/packages/hooks/src/hooks/mod.rs b/packages/hooks/src/hooks/mod.rs new file mode 100644 index 000000000..c8e67d08f --- /dev/null +++ b/packages/hooks/src/hooks/mod.rs @@ -0,0 +1,68 @@ +mod use_context; +mod use_effect; +mod use_reducer; +mod use_ref; +mod use_state; + +pub use use_context::*; +pub use use_effect::*; +pub use use_reducer::*; +pub use use_ref::*; +pub use use_state::*; + +use crate::{HookUpdater, CURRENT_HOOK}; +use std::cell::RefCell; +use std::ops::DerefMut; +use std::rc::Rc; + +pub fn use_hook () + 'static>( + initializer: impl FnOnce() -> InternalHook, + runner: impl FnOnce(&mut InternalHook, HookUpdater) -> Output, + tear_down: Tear, +) -> Output { + // Extract current hook + let updater = CURRENT_HOOK.with(|hook_state_holder| { + let mut hook_state_holder = hook_state_holder + .try_borrow_mut() + .expect("Nested hooks not supported"); + + let mut hook_state = hook_state_holder + .as_mut() + .expect("No current hook. Hooks can only be called inside function components"); + + // Determine which hook position we're at and increment for the next hook + let hook_pos = hook_state.counter; + hook_state.counter += 1; + + // Initialize hook if this is the first call + if hook_pos >= hook_state.hooks.len() { + let initial_state = Rc::new(RefCell::new(initializer())); + hook_state.hooks.push(initial_state.clone()); + hook_state.destroy_listeners.push(Box::new(move || { + let mut is = initial_state.borrow_mut(); + let ihook = is.deref_mut(); + tear_down(ihook); + })); + } + + let hook = hook_state + .hooks + .get(hook_pos) + .expect("Not the same number of hooks. Hooks must not be called conditionally") + .clone(); + + HookUpdater { + hook, + process_message: hook_state.process_message.clone(), + } + }); + + // Execute the actual hook closure we were given. Let it mutate the hook state and let + // it create a callback that takes the mutable hook state. + let mut hook = updater.hook.borrow_mut(); + let hook: &mut InternalHook = hook + .downcast_mut() + .expect("Incompatible hook type. Hooks must always be called in the same order"); + + runner(hook, updater.clone()) +} diff --git a/packages/hooks/src/hooks/use_context.rs b/packages/hooks/src/hooks/use_context.rs new file mode 100644 index 000000000..6c8148b52 --- /dev/null +++ b/packages/hooks/src/hooks/use_context.rs @@ -0,0 +1,469 @@ +// Naming this file use_context could be confusing. Not least to the IDE. +use crate::{get_current_scope, use_hook}; +use std::any::TypeId; +use std::cell::RefCell; +use std::rc::{Rc, Weak}; +use std::{iter, mem}; +use yew::html; +use yew::html::{AnyScope, Scope}; +use yew::{Children, Component, ComponentLink, Html, Properties}; + +type ConsumerCallback = Box)>; +type UseContextOutput = Option>; + +struct UseContext { + provider_scope: Option>>, + current_context: Option>, + callback: Option>>, +} + +pub fn use_context() -> UseContextOutput { + let scope = get_current_scope() + .expect("No current Scope. `use_context` can only be called inside function components"); + + use_hook( + // Initializer + move || { + let provider_scope = find_context_provider_scope::(&scope); + let current_context = + with_provider_component(&provider_scope, |comp| Rc::clone(&comp.context)); + + UseContext { + provider_scope, + current_context, + callback: None, + } + }, + // Runner + |hook, updater| { + // setup a listener for the context provider to update us + let listener = move |ctx: Rc| { + updater.callback(move |state: &mut UseContext| { + state.current_context = Some(ctx); + true + }); + }; + hook.callback = Some(Rc::new(Box::new(listener))); + + // Subscribe to the context provider with our callback + let weak_cb = Rc::downgrade(hook.callback.as_ref().unwrap()); + with_provider_component(&hook.provider_scope, |comp| { + comp.subscribe_consumer(weak_cb) + }); + + // Return the current state + hook.current_context.clone() + }, + // Cleanup + |hook| { + if let Some(cb) = hook.callback.take() { + drop(cb); + } + }, + ) +} + +#[derive(Clone, PartialEq, Properties)] +pub struct ContextProviderProps { + pub context: T, + pub children: Children, +} + +pub struct ContextProvider { + context: Rc, + children: Children, + consumers: RefCell>>>, +} + +impl ContextProvider { + /// Add the callback to the subscriber list to be called whenever the context changes. + /// The consumer is unsubscribed as soon as the callback is dropped. + fn subscribe_consumer(&self, mut callback: Weak>) { + // consumers re-subscribe on every render. Try to keep the subscriber list small by reusing dead slots. + let mut consumers = self.consumers.borrow_mut(); + for cb in consumers.iter_mut() { + if cb.strong_count() == 0 { + mem::swap(cb, &mut callback); + return; + } + } + + // no slot to reuse, this is a new consumer + consumers.push(callback); + } + + /// Notify all subscribed consumers and remove dropped consumers from the list. + fn notify_consumers(&mut self) { + let context = &self.context; + self.consumers.borrow_mut().retain(|cb| { + if let Some(cb) = cb.upgrade() { + cb(Rc::clone(context)); + true + } else { + false + } + }); + } +} + +impl Component for ContextProvider { + type Message = (); + type Properties = ContextProviderProps; + + fn create(props: Self::Properties, _link: ComponentLink) -> Self { + Self { + children: props.children, + context: Rc::new(props.context), + consumers: RefCell::new(Vec::new()), + } + } + + fn update(&mut self, _msg: Self::Message) -> bool { + true + } + + fn change(&mut self, props: Self::Properties) -> bool { + let should_render = if self.children == props.children { + false + } else { + self.children = props.children; + true + }; + + let new_context = Rc::new(props.context); + if self.context != new_context { + self.context = new_context; + self.notify_consumers(); + } + + should_render + } + + fn view(&self) -> Html { + html! { <>{ self.children.clone() } } + } +} + +fn find_context_provider_scope( + scope: &AnyScope, +) -> Option>> { + let expected_type_id = TypeId::of::>(); + iter::successors(Some(scope), |scope| scope.get_parent()) + .filter(|scope| scope.get_type_id() == &expected_type_id) + .cloned() + .map(AnyScope::downcast::>) + .next() +} + +fn with_provider_component( + provider_scope: &Option>>, + f: F, +) -> Option +where + T: Clone + PartialEq, + F: FnOnce(&ContextProvider) -> R, +{ + provider_scope + .as_ref() + .and_then(|scope| scope.get_component().map(|comp| f(&*comp))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::{use_effect, use_ref, use_state}; + use crate::util::*; + use crate::{FunctionComponent, FunctionProvider}; + use wasm_bindgen_test::*; + use yew::prelude::*; + + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + fn use_context_scoping_works() { + #[derive(Clone, Debug, PartialEq)] + struct ExampleContext(String); + struct UseContextFunctionOuter {} + struct UseContextFunctionInner {} + struct ExpectNoContextFunction {} + type UseContextComponent = FunctionComponent; + type UseContextComponentInner = FunctionComponent; + type ExpectNoContextComponent = FunctionComponent; + impl FunctionProvider for ExpectNoContextFunction { + type TProps = (); + + fn run(_props: &Self::TProps) -> Html { + if use_context::().is_some() { + yew::services::ConsoleService::log(&format!( + "Context should be None here, but was {:?}!", + use_context::().unwrap() + )); + }; + return html! { +
+ }; + } + } + impl FunctionProvider for UseContextFunctionOuter { + type TProps = (); + + fn run(_props: &Self::TProps) -> Html { + type ExampleContextProvider = ContextProvider; + return html! { +
+ +
{"ignored"}
+
+ + + +
{"ignored"}
+
+ +
+
+ +
{"ignored"}
+
+ +
+ }; + } + } + impl FunctionProvider for UseContextFunctionInner { + type TProps = (); + + fn run(_props: &Self::TProps) -> Html { + let context = use_context::(); + return html! { +
{ &context.unwrap().0 }
+ }; + } + } + + let app: App = yew::App::new(); + app.mount(yew::utils::document().get_element_by_id("output").unwrap()); + let result: String = obtain_result_by_id("result"); + assert_eq!("correct", result); + } + + #[wasm_bindgen_test] + fn use_context_works_with_multiple_types() { + #[derive(Clone, Debug, PartialEq)] + struct ContextA(u32); + #[derive(Clone, Debug, PartialEq)] + struct ContextB(u32); + + struct Test1Function; + impl FunctionProvider for Test1Function { + type TProps = (); + + fn run(_props: &Self::TProps) -> Html { + assert_eq!(use_context::(), Some(Rc::new(ContextA(2)))); + assert_eq!(use_context::(), Some(Rc::new(ContextB(1)))); + + return html! {}; + } + } + type Test1 = FunctionComponent; + + struct Test2Function; + impl FunctionProvider for Test2Function { + type TProps = (); + + fn run(_props: &Self::TProps) -> Html { + assert_eq!(use_context::(), Some(Rc::new(ContextA(0)))); + assert_eq!(use_context::(), Some(Rc::new(ContextB(1)))); + + return html! {}; + } + } + type Test2 = FunctionComponent; + + struct Test3Function; + impl FunctionProvider for Test3Function { + type TProps = (); + + fn run(_props: &Self::TProps) -> Html { + assert_eq!(use_context::(), Some(Rc::new(ContextA(0)))); + assert_eq!(use_context::(), None); + + return html! {}; + } + } + type Test3 = FunctionComponent; + + struct Test4Function; + impl FunctionProvider for Test4Function { + type TProps = (); + + fn run(_props: &Self::TProps) -> Html { + assert_eq!(use_context::(), None); + assert_eq!(use_context::(), None); + + return html! {}; + } + } + type Test4 = FunctionComponent; + + struct TestFunction; + impl FunctionProvider for TestFunction { + type TProps = (); + + fn run(_props: &Self::TProps) -> Html { + type ContextAProvider = ContextProvider; + type ContextBProvider = ContextProvider; + + return html! { +
+ + + + + + + + + + +
+ }; + } + } + type TestComponent = FunctionComponent; + + let app: App = yew::App::new(); + app.mount(yew::utils::document().get_element_by_id("output").unwrap()); + } + + #[wasm_bindgen_test] + fn use_context_update_works() { + #[derive(Clone, Debug, PartialEq)] + struct MyContext(String); + + #[derive(Clone, Debug, PartialEq, Properties)] + struct RenderCounterProps { + id: String, + children: Children, + } + + struct RenderCounterFunction; + impl FunctionProvider for RenderCounterFunction { + type TProps = RenderCounterProps; + + fn run(props: &Self::TProps) -> Html { + let counter = use_ref(|| 0); + *counter.borrow_mut() += 1; + log::info!("Render counter {:?}", counter); + return html! { + <> +
+ { format!("total: {}", counter.borrow()) } +
+ { props.children.clone() } + + }; + } + } + type RenderCounter = FunctionComponent; + + #[derive(Clone, Debug, PartialEq, Properties)] + struct ContextOutletProps { + id: String, + #[prop_or_default] + magic: usize, + } + struct ContextOutletFunction; + impl FunctionProvider for ContextOutletFunction { + type TProps = ContextOutletProps; + + fn run(props: &Self::TProps) -> Html { + let counter = use_ref(|| 0); + *counter.borrow_mut() += 1; + + let ctx = use_context::>().expect("context not passed down"); + log::info!("============"); + log::info!("ctx is {:#?}", ctx); + log::info!("magic is {:#?}", props.magic); + log::info!("outlet counter is {:#?}", ctx); + log::info!("============"); + + return html! { + <> +
{ format!("magic: {}\n", props.magic) }
+
+ { format!("current: {}, total: {}", ctx.0, counter.borrow()) } +
+ + }; + } + } + type ContextOutlet = FunctionComponent; + + struct TestFunction; + impl FunctionProvider for TestFunction { + type TProps = (); + + fn run(_props: &Self::TProps) -> Html { + type MyContextProvider = ContextProvider>; + + let (ctx, set_ctx) = use_state(|| MyContext("hello".into())); + let rendered = use_ref(|| 0); + + // this is used to force an update specific to test-2 + let (magic_rc, set_magic) = use_state(|| 0); + let magic: usize = *magic_rc; + + use_effect(move || { + let count = *rendered.borrow(); + match count { + 0 => { + set_ctx(MyContext("world".into())); + *rendered.borrow_mut() += 1; + } + 1 => { + // force test-2 to re-render. + set_magic(1); + *rendered.borrow_mut() += 1; + } + 2 => { + set_ctx(MyContext("hello world!".into())); + *rendered.borrow_mut() += 1; + } + _ => (), + }; + || {} + }); + + return html! { + + + + + + + }; + } + } + type TestComponent = FunctionComponent; + + wasm_logger::init(wasm_logger::Config::default()); + let app: App = yew::App::new(); + app.mount(yew::utils::document().get_element_by_id("output").unwrap()); + + // 1 initial render + 3 update steps + assert_eq!(obtain_result_by_id("test-0"), "total: 4"); + + // 1 initial + 2 context update + assert_eq!( + obtain_result_by_id("test-1"), + "current: hello world!, total: 3" + ); + + // 1 initial + 1 context update + 1 magic update + 1 context update + assert_eq!( + obtain_result_by_id("test-2"), + "current: hello world!, total: 4" + ); + } +} diff --git a/packages/hooks/src/hooks/use_effect.rs b/packages/hooks/src/hooks/use_effect.rs new file mode 100644 index 000000000..b209ab57a --- /dev/null +++ b/packages/hooks/src/hooks/use_effect.rs @@ -0,0 +1,320 @@ +use crate::use_hook; +use std::{borrow::Borrow, rc::Rc}; + +struct UseEffect { + destructor: Option>, +} + +/// This hook is used for hooking into the component's lifecycle. +/// +/// # Example +/// ```rust +/// # use yew_functional::{function_component, use_effect, use_state}; +/// # use yew::prelude::*; +/// # use std::rc::Rc; +/// # +/// #[function_component(UseEffect)] +/// fn effect() -> Html { +/// let (counter, set_counter) = use_state(|| 0); +/// +/// let counter_one = counter.clone(); +/// use_effect(move || { +/// // Make a call to DOM API after component is rendered +/// yew::utils::document().set_title(&format!("You clicked {} times", counter_one)); +/// +/// // Perform the cleanup +/// || yew::utils::document().set_title(&format!("You clicked 0 times")) +/// }); +/// +/// let onclick = { +/// let counter = Rc::clone(&counter); +/// Callback::from(move |_| set_counter(*counter + 1)) +/// }; +/// +/// html! { +/// +/// } +/// } +/// ``` +pub fn use_effect(callback: impl FnOnce() -> Destructor + 'static) +where + Destructor: FnOnce() + 'static, +{ + let callback = Box::new(callback); + use_hook( + move || { + let effect: UseEffect = UseEffect { destructor: None }; + effect + }, + |_, updater| { + // Run on every render + updater.post_render(move |state: &mut UseEffect| { + if let Some(de) = state.destructor.take() { + de(); + } + let new_destructor = callback(); + state.destructor.replace(Box::new(new_destructor)); + false + }); + }, + |hook| { + if let Some(destructor) = hook.destructor.take() { + destructor() + } + }, + ) +} + +struct UseEffectDeps { + destructor: Option>, + deps: Rc, +} + +/// This hook is similar to [`use_effect`] but it accepts dependencies. +/// +/// Whenever the dependencies are changed, the effect callback is called again. +/// To detect changes, dependencies must implement `PartialEq`. +/// Note that the destructor also runs when dependencies change. +pub fn use_effect_with_deps(callback: Callback, deps: Dependents) +where + Callback: FnOnce(&Dependents) -> Destructor + 'static, + Destructor: FnOnce() + 'static, + Dependents: PartialEq + 'static, +{ + let deps = Rc::new(deps); + let deps_c = deps.clone(); + + use_hook( + move || { + let destructor: Option> = None; + UseEffectDeps { + destructor, + deps: deps_c, + } + }, + move |_, updater| { + updater.post_render(move |state: &mut UseEffectDeps| { + if state.deps != deps { + if let Some(de) = state.destructor.take() { + de(); + } + let new_destructor = callback(deps.borrow()); + state.deps = deps; + state.destructor.replace(Box::new(new_destructor)); + } else if state.destructor.is_none() { + state + .destructor + .replace(Box::new(callback(state.deps.borrow()))); + } + false + }); + }, + |hook| { + if let Some(destructor) = hook.destructor.take() { + destructor() + } + }, + ); +} + +#[cfg(test)] +mod tests { + use crate::hooks::{use_effect_with_deps, use_ref, use_state}; + use crate::util::*; + use crate::{FunctionComponent, FunctionProvider}; + use std::ops::Deref; + use std::ops::DerefMut; + use std::rc::Rc; + use wasm_bindgen_test::*; + use yew::prelude::*; + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + fn use_effect_works_many_times() { + struct UseEffectFunction {} + impl FunctionProvider for UseEffectFunction { + type TProps = (); + + fn run(_: &Self::TProps) -> Html { + let (counter, set_counter) = use_state(|| 0); + let counter_clone = counter.clone(); + + use_effect_with_deps( + move |_| { + if *counter_clone < 4 { + set_counter(*counter_clone + 1); + } + || {} + }, + *counter, + ); + + return html! { +
+ {"The test result is"} +
{counter}
+ {"\n"} +
+ }; + } + } + + type UseEffectComponent = FunctionComponent; + let app: App = yew::App::new(); + app.mount(yew::utils::document().get_element_by_id("output").unwrap()); + let result = obtain_result(); + assert_eq!(result.as_str(), "4"); + } + + #[wasm_bindgen_test] + fn use_effect_works_once() { + struct UseEffectFunction {} + impl FunctionProvider for UseEffectFunction { + type TProps = (); + + fn run(_: &Self::TProps) -> Html { + let (counter, set_counter) = use_state(|| 0); + let counter_clone = counter.clone(); + + use_effect_with_deps( + move |_| { + set_counter(*counter_clone + 1); + || panic!("Destructor should not have been called") + }, + (), + ); + + return html! { +
+ {"The test result is"} +
{counter}
+ {"\n"} +
+ }; + } + } + type UseEffectComponent = FunctionComponent; + let app: App = yew::App::new(); + app.mount(yew::utils::document().get_element_by_id("output").unwrap()); + let result = obtain_result(); + assert_eq!(result.as_str(), "1"); + } + + #[wasm_bindgen_test] + fn use_effect_refires_on_dependency_change() { + struct UseEffectFunction {} + impl FunctionProvider for UseEffectFunction { + type TProps = (); + + fn run(_: &Self::TProps) -> Html { + let number_ref = use_ref(|| 0); + let number_ref_c = number_ref.clone(); + let number_ref2 = use_ref(|| 0); + let number_ref2_c = number_ref2.clone(); + let arg = *number_ref.borrow_mut().deref_mut(); + let (_, set_counter) = use_state(|| 0); + use_effect_with_deps( + move |dep| { + let mut ref_mut = number_ref_c.borrow_mut(); + let inner_ref_mut = ref_mut.deref_mut(); + if *inner_ref_mut < 1 { + *inner_ref_mut += 1; + assert_eq!(dep, &0); + } else { + assert_eq!(dep, &1); + } + set_counter(10); // we just need to make sure it does not panic + move || { + set_counter(11); + *number_ref2_c.borrow_mut().deref_mut() += 1; + } + }, + arg, + ); + return html! { +
+ {"The test result is"} +
{*number_ref.borrow_mut().deref_mut()}{*number_ref2.borrow_mut().deref_mut()}
+ {"\n"} +
+ }; + } + } + type UseEffectComponent = FunctionComponent; + let app: App = yew::App::new(); + app.mount(yew::utils::document().get_element_by_id("output").unwrap()); + let result: String = obtain_result(); + + assert_eq!(result.as_str(), "11"); + } + + #[wasm_bindgen_test] + fn use_effect_destroys_on_component_drop() { + struct UseEffectFunction {} + struct UseEffectWrapper {} + #[derive(Properties, Clone)] + struct WrapperProps { + destroy_called: Rc, + } + impl PartialEq for WrapperProps { + fn eq(&self, _other: &Self) -> bool { + false + } + } + #[derive(Properties, Clone)] + struct FunctionProps { + effect_called: Rc, + destroy_called: Rc, + } + impl PartialEq for FunctionProps { + fn eq(&self, _other: &Self) -> bool { + false + } + } + type UseEffectComponent = FunctionComponent; + type UseEffectWrapperComponent = FunctionComponent; + impl FunctionProvider for UseEffectFunction { + type TProps = FunctionProps; + + fn run(props: &Self::TProps) -> Html { + let effect_called = props.effect_called.clone(); + let destroy_called = props.destroy_called.clone(); + use_effect_with_deps( + move |_| { + effect_called(); + move || destroy_called() + }, + (), + ); + return html! {}; + } + } + impl FunctionProvider for UseEffectWrapper { + type TProps = WrapperProps; + + fn run(props: &Self::TProps) -> Html { + let (show, set_show) = use_state(|| true); + if *show { + let effect_called: Rc = Rc::new(move || set_show(false)); + return html! { + + }; + } else { + return html! { +
{"EMPTY"}
+ }; + } + } + } + let app: App = yew::App::new(); + let destroy_counter = Rc::new(std::cell::RefCell::new(0)); + let destroy_counter_c = destroy_counter.clone(); + app.mount_with_props( + yew::utils::document().get_element_by_id("output").unwrap(), + WrapperProps { + destroy_called: Rc::new(move || *destroy_counter_c.borrow_mut().deref_mut() += 1), + }, + ); + assert_eq!(1, *destroy_counter.borrow().deref()); + } +} diff --git a/packages/hooks/src/hooks/use_reducer.rs b/packages/hooks/src/hooks/use_reducer.rs new file mode 100644 index 000000000..b99c85c40 --- /dev/null +++ b/packages/hooks/src/hooks/use_reducer.rs @@ -0,0 +1,203 @@ +use crate::use_hook; +use std::rc::Rc; + +struct UseReducer { + current_state: Rc, +} + +/// This hook is an alternative to [`use_state`]. It is used to handle component's state and is used +/// when complex actions needs to be performed on said state. +/// +/// For lazy initialization, consider using [`use_reducer_with_init`] instead. +/// +/// # Example +/// ```rust +/// # use yew_functional::{function_component, use_reducer}; +/// # use yew::prelude::*; +/// # use std::rc::Rc; +/// # use std::ops::DerefMut; +/// # +/// #[function_component(UseReducer)] +/// fn reducer() -> Html { +/// /// reducer's Action +/// enum Action { +/// Double, +/// Square, +/// } +/// +/// /// reducer's State +/// struct CounterState { +/// counter: i32, +/// } +/// +/// let ( +/// counter, // the state +/// // function to update the state +/// // as the same suggests, it dispatches the values to the reducer function +/// dispatch +/// ) = use_reducer( +/// // the reducer function +/// |prev: Rc, action: Action| CounterState { +/// counter: match action { +/// Action::Double => prev.counter * 2, +/// Action::Square => prev.counter * prev.counter, +/// } +/// }, +/// // initial state +/// CounterState { counter: 1 }, +/// ); +/// +/// let double_onclick = { +/// let dispatch = Rc::clone(&dispatch); +/// Callback::from(move |_| dispatch(Action::Double)) +/// }; +/// let square_onclick = Callback::from(move |_| dispatch(Action::Square)); +/// +/// html! { +/// <> +///
{ counter.counter }
+/// +/// +/// +/// +/// } +/// } +/// ``` +pub fn use_reducer( + reducer: Reducer, + initial_state: State, +) -> (Rc, Rc) +where + Reducer: Fn(Rc, Action) -> State + 'static, +{ + use_reducer_with_init(reducer, initial_state, |a| a) +} + +/// [`use_reducer`] but with init argument. +/// +/// This is useful for lazy initialization where it is beneficial not to perform expensive +/// computation up-front +/// +/// # Example +/// ```rust +/// # use yew_functional::{function_component, use_reducer_with_init}; +/// # use yew::prelude::*; +/// # use std::rc::Rc; +/// # +/// #[function_component(UseReducerWithInit)] +/// fn reducer_with_init() -> Html { +/// struct CounterState { +/// counter: i32, +/// } +/// let (counter, dispatch) = use_reducer_with_init( +/// |prev: Rc, action: i32| CounterState { +/// counter: prev.counter + action, +/// }, +/// 0, +/// |initial: i32| CounterState { +/// counter: initial + 10, +/// }, +/// ); +/// +/// html! { +/// <> +///
{counter.counter}
+/// +/// +/// +/// } +/// } +/// ``` +pub fn use_reducer_with_init< + Reducer, + Action: 'static, + State: 'static, + InitialState: 'static, + InitFn: 'static, +>( + reducer: Reducer, + initial_state: InitialState, + init: InitFn, +) -> (Rc, Rc) +where + Reducer: Fn(Rc, Action) -> State + 'static, + InitFn: Fn(InitialState) -> State, +{ + let init = Box::new(init); + let reducer = Rc::new(reducer); + use_hook( + move || UseReducer { + current_state: Rc::new(init(initial_state)), + }, + |s, updater| { + let setter: Rc = Rc::new(move |action: Action| { + let reducer = reducer.clone(); + // We call the callback, consumer the updater + // Required to put the type annotations on Self so the method knows how to downcast + updater.callback(move |state: &mut UseReducer| { + let new_state = reducer(state.current_state.clone(), action); + state.current_state = Rc::new(new_state); + true + }); + }); + + let current = s.current_state.clone(); + (current, setter) + }, + |_| {}, + ) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::hooks::use_effect_with_deps; + use crate::util::*; + use crate::{FunctionComponent, FunctionProvider}; + use wasm_bindgen_test::*; + use yew::prelude::*; + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + fn use_reducer_works() { + struct UseReducerFunction {} + impl FunctionProvider for UseReducerFunction { + type TProps = (); + fn run(_: &Self::TProps) -> Html { + struct CounterState { + counter: i32, + } + let (counter, dispatch) = use_reducer_with_init( + |prev: std::rc::Rc, action: i32| CounterState { + counter: prev.counter + action, + }, + 0, + |initial: i32| CounterState { + counter: initial + 10, + }, + ); + + use_effect_with_deps( + move |_| { + dispatch(1); + || {} + }, + (), + ); + return html! { +
+ {"The test result is"} +
{counter.counter}
+ {"\n"} +
+ }; + } + } + type UseReducerComponent = FunctionComponent; + let app: App = yew::App::new(); + app.mount(yew::utils::document().get_element_by_id("output").unwrap()); + let result = obtain_result(); + + assert_eq!(result.as_str(), "11"); + } +} diff --git a/packages/hooks/src/hooks/use_ref.rs b/packages/hooks/src/hooks/use_ref.rs new file mode 100644 index 000000000..8474852f9 --- /dev/null +++ b/packages/hooks/src/hooks/use_ref.rs @@ -0,0 +1,98 @@ +use crate::use_hook; +use std::{cell::RefCell, rc::Rc}; + +/// This hook is used for obtaining a mutable reference to a stateful value. +/// Its state persists across renders. +/// +/// It is important to note that you do not get notified of state changes. +/// If you need the component to be re-rendered on state change, consider using [`use_state`]. +/// +/// # Example +/// ```rust +/// # use yew_functional::{function_component, use_state, use_ref}; +/// # use yew::prelude::*; +/// # use std::rc::Rc; +/// # use std::cell::RefCell; +/// # use std::ops::{Deref, DerefMut}; +/// # +/// #[function_component(UseRef)] +/// fn ref_hook() -> Html { +/// let (message, set_message) = use_state(|| "".to_string()); +/// let message_count = use_ref(|| 0); +/// +/// let onclick = Callback::from(move |e| { +/// let window = yew::utils::window(); +/// +/// if *message_count.borrow_mut() > 3 { +/// window.alert_with_message("Message limit reached"); +/// } else { +/// *message_count.borrow_mut() += 1; +/// window.alert_with_message("Message sent"); +/// } +/// }); +/// +/// let onchange = Callback::from(move |e| { +/// if let ChangeData::Value(value) = e { +/// set_message(value) +/// } +/// }); +/// +/// html! { +///
+/// +/// +///
+/// } +/// } +/// ``` +pub fn use_ref(initial_value: impl FnOnce() -> T + 'static) -> Rc> { + use_hook( + || Rc::new(RefCell::new(initial_value())), + |state, _| state.clone(), + |_| {}, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + hooks::use_state, + util::*, + {FunctionComponent, FunctionProvider}, + }; + use std::ops::DerefMut; + use wasm_bindgen_test::*; + use yew::prelude::*; + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + fn use_ref_works() { + struct UseRefFunction {} + impl FunctionProvider for UseRefFunction { + type TProps = (); + + fn run(_: &Self::TProps) -> Html { + let ref_example = use_ref(|| 0); + *ref_example.borrow_mut().deref_mut() += 1; + let (counter, set_counter) = use_state(|| 0); + if *counter < 5 { + set_counter(*counter + 1) + } + return html! { +
+ {"The test output is: "} +
{*ref_example.borrow_mut().deref_mut() > 4}
+ {"\n"} +
+ }; + } + } + type UseRefComponent = FunctionComponent; + let app: App = yew::App::new(); + app.mount(yew::utils::document().get_element_by_id("output").unwrap()); + + let result = obtain_result(); + assert_eq!(result.as_str(), "true"); + } +} diff --git a/packages/hooks/src/hooks/use_state.rs b/packages/hooks/src/hooks/use_state.rs new file mode 100644 index 000000000..357c2be9d --- /dev/null +++ b/packages/hooks/src/hooks/use_state.rs @@ -0,0 +1,142 @@ +use crate::use_hook; +use std::rc::Rc; + +struct UseState { + current: Rc, +} + +/// This hook is used to mange state in a function component. +/// +/// # Example +/// ```rust +/// # use yew_functional::{function_component, use_state, use_ref}; +/// # use yew::prelude::*; +/// # use std::rc::Rc; +/// # +/// #[function_component(UseState)] +/// fn state() -> Html { +/// let ( +/// counter, // the returned state +/// set_counter // setter to update the state +/// ) = use_state(|| 0); +/// let onclick = { +/// let counter = Rc::clone(&counter); +/// Callback::from(move |_| set_counter(*counter + 1)) +/// }; +/// +/// html! { +///
+/// +///

+/// { "Current value: " } +/// { counter } +///

+///
+/// } +/// } +/// ``` +pub fn use_state T + 'static>( + initial_state_fn: F, +) -> (Rc, Rc) { + use_hook( + // Initializer + move || UseState { + current: Rc::new(initial_state_fn()), + }, + // Runner + move |hook, updater| { + let setter: Rc<(dyn Fn(T))> = Rc::new(move |new_val: T| { + updater.callback(move |st: &mut UseState| { + st.current = Rc::new(new_val); + true + }) + }); + + let current = hook.current.clone(); + (current, setter) + }, + // Teardown + |_| {}, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::use_effect_with_deps; + use crate::util::*; + use crate::{FunctionComponent, FunctionProvider}; + use wasm_bindgen_test::*; + use yew::prelude::*; + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + fn use_state_works() { + struct UseStateFunction {} + impl FunctionProvider for UseStateFunction { + type TProps = (); + + fn run(_: &Self::TProps) -> Html { + let (counter, set_counter) = use_state(|| 0); + if *counter < 5 { + set_counter(*counter + 1) + } + return html! { +
+ {"Test Output: "} +
{*counter}
+ {"\n"} +
+ }; + } + } + type UseComponent = FunctionComponent; + let app: App = yew::App::new(); + app.mount(yew::utils::document().get_element_by_id("output").unwrap()); + let result = obtain_result(); + assert_eq!(result.as_str(), "5"); + } + + #[wasm_bindgen_test] + fn multiple_use_state_setters() { + struct UseStateFunction {} + impl FunctionProvider for UseStateFunction { + type TProps = (); + + fn run(_: &Self::TProps) -> Html { + let (counter, set_counter_in_use_effect) = use_state(|| 0); + let counter = *counter; + // clone without manually wrapping with Rc + let set_counter_in_another_scope = set_counter_in_use_effect.clone(); + use_effect_with_deps( + move |_| { + // 1st location + set_counter_in_use_effect(counter + 1); + || {} + }, + (), + ); + let another_scope = move || { + if counter < 11 { + // 2nd location + set_counter_in_another_scope(counter + 10) + } + }; + another_scope(); + return html! { +
+ {"Test Output: "} + // expected output +
{counter}
+ {"\n"} +
+ }; + } + } + type UseComponent = FunctionComponent; + let app: App = yew::App::new(); + app.mount(yew::utils::document().get_element_by_id("output").unwrap()); + let result = obtain_result(); + assert_eq!(result.as_str(), "11"); + } +}