Skip to content

Commit ffc7298

Browse files
committed
key prop, docs, and tests
1 parent ff1c9ec commit ffc7298

File tree

8 files changed

+129
-45
lines changed

8 files changed

+129
-45
lines changed

packages/iocraft-macros/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ proc-macro = true
1111
proc-macro2 = "1.0.86"
1212
quote = "1.0.37"
1313
syn = { version = "2.0.77", features = ["full"] }
14+
uuid = { version = "1.10.0", features = ["v4"] }
1415

1516
[dev-dependencies]
1617
iocraft = { path = "../iocraft" }

packages/iocraft-macros/src/lib.rs

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ use syn::{
1313
spanned::Spanned,
1414
token::{Brace, Comma, Paren},
1515
DeriveInput, Error, Expr, FieldValue, FnArg, GenericParam, Ident, ItemFn, ItemStruct, Lifetime,
16-
Lit, Pat, Result, Token, Type, TypePath,
16+
Lit, Member, Pat, Result, Token, Type, TypePath,
1717
};
18+
use uuid::Uuid;
1819

1920
enum ParsedElementChild {
2021
Element(ParsedElement),
@@ -72,22 +73,36 @@ impl ToTokens for ParsedElement {
7273
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
7374
let ty = &self.ty;
7475

76+
let decl_key = Uuid::new_v4().as_u128();
77+
78+
let key = self
79+
.props
80+
.iter()
81+
.find_map(|FieldValue { member, expr, .. }| match member {
82+
Member::Named(ident) if ident == "key" => Some(quote!((#decl_key, #expr))),
83+
_ => None,
84+
})
85+
.unwrap_or_else(|| quote!(#decl_key));
86+
7587
let props = self
7688
.props
7789
.iter()
78-
.map(|FieldValue { member, expr, .. }| match expr {
79-
Expr::Lit(lit) => match &lit.lit {
80-
Lit::Int(lit) if lit.suffix() == "pct" => {
81-
let value = lit.base10_parse::<f32>().unwrap();
82-
quote!(#member: ::iocraft::Percent(#value).into())
83-
}
84-
Lit::Float(lit) if lit.suffix() == "pct" => {
85-
let value = lit.base10_parse::<f32>().unwrap();
86-
quote!(#member: ::iocraft::Percent(#value).into())
87-
}
90+
.filter_map(|FieldValue { member, expr, .. }| match member {
91+
Member::Named(ident) if ident == "key" => None,
92+
_ => Some(match expr {
93+
Expr::Lit(lit) => match &lit.lit {
94+
Lit::Int(lit) if lit.suffix() == "pct" => {
95+
let value = lit.base10_parse::<f32>().unwrap();
96+
quote!(#member: ::iocraft::Percent(#value).into())
97+
}
98+
Lit::Float(lit) if lit.suffix() == "pct" => {
99+
let value = lit.base10_parse::<f32>().unwrap();
100+
quote!(#member: ::iocraft::Percent(#value).into())
101+
}
102+
_ => quote!(#member: (#expr).into()),
103+
},
88104
_ => quote!(#member: (#expr).into()),
89-
},
90-
_ => quote!(#member: (#expr).into()),
105+
}),
91106
})
92107
.collect::<Vec<_>>();
93108

@@ -107,7 +122,7 @@ impl ToTokens for ParsedElement {
107122
{
108123
type Props<'a> = <#ty as ::iocraft::ElementType>::Props<'a>;
109124
let mut _iocraft_element = ::iocraft::Element::<#ty>{
110-
key: core::default::Default::default(),
125+
key: ::iocraft::ElementKey::new(#key),
111126
props: Props{
112127
#(#props,)*
113128
..core::default::Default::default()

packages/iocraft-macros/tests/element.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,13 @@ fn comment() {
135135
};
136136
assert_eq!(e.props.children.len(), 1);
137137
}
138+
139+
#[test]
140+
fn key() {
141+
let e = element! {
142+
MyContainer(key: "foo") {
143+
MyContainer
144+
}
145+
};
146+
assert_eq!(e.props.children.len(), 1);
147+
}

packages/iocraft/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ crossterm = { version = "0.28.1", features = ["event-stream"] }
99
futures = "0.3.30"
1010
taffy = { version = "0.5.2", default-features = false, features = ["flexbox", "taffy_tree"] }
1111
iocraft-macros = { path = "../iocraft-macros" }
12-
uuid = { version = "1.10.0", features = ["v4"] }
13-
derive_more = { version = "1.0.0", features = ["debug", "display"] }
1412
bitflags = "2.6.0"
1513
unicode-width = "0.1.13"
1614
textwrap = "0.16.1"
1715
generational-box = "0.5.6"
16+
any_key = "0.1.1"
17+
uuid = { version = "1.10.0", features = ["v4"] }
1818

1919
[dev-dependencies]
2020
indoc = "2"

packages/iocraft/src/element.rs

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ use crate::{
33
props::AnyProps,
44
render, terminal_render_loop, Canvas, Terminal,
55
};
6+
use any_key::AnyHash;
67
use crossterm::{terminal, tty::IsTty};
78
use std::{
9+
fmt::Debug,
810
future::Future,
11+
hash::Hash,
912
io::{self, stderr, stdout, Write},
1013
os::fd::AsRawFd,
14+
rc::Rc,
1115
};
1216

1317
/// Used by the `element!` macro to extend a collection with elements.
@@ -54,19 +58,13 @@ where
5458

5559
/// Used to identify an element within the scope of its parent. This is used to minimize the number
5660
/// of times components are destroyed and recreated from render-to-render.
57-
#[derive(Clone, Hash, PartialEq, Eq, Debug, derive_more::Display)]
58-
pub struct ElementKey(uuid::Uuid);
59-
60-
impl Default for ElementKey {
61-
fn default() -> Self {
62-
Self::new()
63-
}
64-
}
61+
#[derive(Clone, Hash, PartialEq, Eq, Debug)]
62+
pub struct ElementKey(Rc<Box<dyn AnyHash>>);
6563

6664
impl ElementKey {
67-
/// Constructs a new, random element key.
68-
pub fn new() -> Self {
69-
Self(uuid::Uuid::new_v4())
65+
/// Constructs a new key.
66+
pub fn new<K: Debug + Hash + Eq + 'static>(key: K) -> Self {
67+
Self(Rc::new(Box::new(key)))
7068
}
7169
}
7270

packages/iocraft/src/hooks/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@
2727
//! }
2828
//! }
2929
//! ```
30+
//!
31+
//! # Rules of Hooks
32+
//!
33+
//! Usage of hooks is subject to the same sorts of rules as [React hooks](https://react.dev/reference/rules/rules-of-hooks).
34+
//!
35+
//! They must be called in the same order every time, so calling them in any sort of conditional or
36+
//! loop is not allowed. If you break the rules of hooks, you can expect a panic.
3037
3138
mod use_context;
3239
pub use use_context::*;

packages/iocraft/src/lib.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,28 @@ mod flattened_exports {
133133
/// }
134134
/// }
135135
/// # }
136+
/// ```
137+
///
138+
/// If you're rendering a dynamic UI, you will want to ensure that when adding multiple
139+
/// elements via an iterator a unique key is specified for each one. Otherwise, the elements
140+
/// may not correctly maintain their state across renders. This is done using the special `key`
141+
/// property, which can be given to any element:
142+
///
143+
/// ```
144+
/// # use iocraft::prelude::*;
145+
/// # struct User { id: i32, name: String }
146+
/// # fn my_element(users: Vec<User>) -> Element<'static, Box> {
147+
/// element! {
148+
/// Box {
149+
/// #(users.iter().map(|user| element! {
150+
/// Box(key: user.id, flex_direction: FlexDirection::Column) {
151+
/// Text(content: format!("Hello, {}!", user.name))
152+
/// }
153+
/// }))
154+
/// }
155+
/// }
156+
/// # }
157+
/// ```
136158
pub use iocraft_macros::element;
137159

138160
pub use iocraft_macros::*;

packages/iocraft/src/render.rs

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use crate::{
22
canvas::{Canvas, CanvasSubviewMut},
33
component::{ComponentHelperExt, Components, InstantiatedComponent},
44
context::{Context, ContextStack, SystemContext},
5-
element::ElementExt,
5+
element::{ElementExt, ElementKey},
66
props::AnyProps,
77
terminal::{Terminal, TerminalEvents},
88
};
@@ -12,10 +12,10 @@ use std::{
1212
any::Any,
1313
cell::{Ref, RefMut},
1414
collections::HashMap,
15-
io::{self, Write},
16-
mem,
15+
io, mem,
1716
};
1817
use taffy::{AvailableSpace, Layout, NodeId, Point, Size, Style, TaffyTree};
18+
use uuid::Uuid;
1919

2020
pub(crate) struct UpdateContext<'a> {
2121
terminal: Option<&'a mut Terminal>,
@@ -120,13 +120,16 @@ impl<'a, 'b, 'c> ComponentUpdater<'a, 'b, 'c> {
120120
.with_context(context, |component_context_stack| {
121121
let mut used_components = HashMap::with_capacity(self.children.components.len());
122122

123+
let mut child_node_ids = Vec::new();
124+
123125
for mut child in children {
124126
let mut component: InstantiatedComponent =
125127
match self.children.components.remove(child.key()) {
126128
Some(component)
127129
if component.component().type_id()
128130
== child.helper().component_type_id() =>
129131
{
132+
child_node_ids.push(component.node_id());
130133
component
131134
}
132135
_ => {
@@ -138,23 +141,25 @@ impl<'a, 'b, 'c> ComponentUpdater<'a, 'b, 'c> {
138141
LayoutEngineNodeContext::default(),
139142
)
140143
.expect("we should be able to add the node");
141-
self.context
142-
.layout_engine
143-
.add_child(self.node_id, new_node_id)
144-
.expect("we should be able to add the child");
144+
child_node_ids.push(new_node_id);
145145
let h = child.helper();
146146
InstantiatedComponent::new(new_node_id, child.props_mut(), h)
147147
}
148148
};
149149
component.update(self.context, component_context_stack, child.props_mut());
150-
if used_components
151-
.insert(child.key().clone(), component)
152-
.is_some()
153-
{
154-
panic!("duplicate key for sibling components: {}", child.key());
150+
151+
let mut child_key = child.key().clone();
152+
while used_components.contains_key(&child_key) {
153+
child_key = ElementKey::new(Uuid::new_v4().as_u128());
155154
}
155+
used_components.insert(child_key, component);
156156
}
157157

158+
self.context
159+
.layout_engine
160+
.set_children(self.node_id, &child_node_ids)
161+
.expect("we should be able to set the children");
162+
158163
for (_, component) in self.children.components.drain() {
159164
self.context
160165
.layout_engine
@@ -375,7 +380,6 @@ impl<'a> Tree<'a> {
375380
break;
376381
}
377382
}
378-
write!(term, "\r\n")?;
379383
Ok(())
380384
}
381385
}
@@ -411,21 +415,45 @@ mod tests {
411415
use macro_rules_attribute::apply;
412416
use smol_macros::test;
413417

418+
#[derive(Default, Props)]
419+
struct MyInnerComponentProps {
420+
label: String,
421+
}
422+
423+
#[component]
424+
fn MyInnerComponent(
425+
mut hooks: Hooks,
426+
props: &MyInnerComponentProps,
427+
) -> impl Into<AnyElement<'static>> {
428+
let mut counter = hooks.use_state(|| 0);
429+
counter += 1;
430+
element! {
431+
Text(content: format!("render count ({}): {}", props.label, counter))
432+
}
433+
}
434+
414435
#[component]
415436
fn MyComponent(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
416437
let mut system = hooks.use_context_mut::<SystemContext>();
417-
let mut counter = hooks.use_state(|| 0);
438+
let mut tick = hooks.use_state(|| 0);
418439

419440
hooks.use_future(async move {
420-
counter += 1;
441+
tick += 1;
421442
});
422443

423-
if counter == 1 {
444+
if tick == 1 {
424445
system.exit();
425446
}
426447

427448
element! {
428-
Text(content: format!("count: {}", counter))
449+
Box(flex_direction: FlexDirection::Column) {
450+
Text(content: format!("tick: {}", tick))
451+
MyInnerComponent(label: "a")
452+
// without a key, these next elements may not be re-used across renders
453+
#((0..2).map(|i| element! { MyInnerComponent(label: format!("b{}", i)) }))
454+
// with a key, these next elements will definitely be re-used across renders
455+
#((0..2).map(|i| element! { MyInnerComponent(key: i, label: format!("c{}", i)) }))
456+
}
429457
}
430458
}
431459

@@ -435,7 +463,10 @@ mod tests {
435463
.await
436464
.unwrap();
437465
let actual = canvases.iter().map(|c| c.to_string()).collect::<Vec<_>>();
438-
let expected = vec!["count: 0\n", "count: 1\n"];
466+
let expected = vec![
467+
"tick: 0\nrender count (a): 1\nrender count (b0): 1\nrender count (b1): 1\nrender count (c0): 1\nrender count (c1): 1\n",
468+
"tick: 1\nrender count (a): 2\nrender count (b0): 2\nrender count (b1): 1\nrender count (c0): 2\nrender count (c1): 2\n",
469+
];
439470
assert_eq!(actual, expected);
440471
}
441472
}

0 commit comments

Comments
 (0)