diff --git a/Cargo.lock b/Cargo.lock index 56323cdbe2..4c4dd6b80a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6675,6 +6675,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "perry-ffi", + "perry-runtime", "perry-ui", "perry-ui-testkit", ] diff --git a/changelog.d/9825-solid-native-renderer.md b/changelog.d/9825-solid-native-renderer.md new file mode 100644 index 0000000000..18328f3940 --- /dev/null +++ b/changelog.d/9825-solid-native-renderer.md @@ -0,0 +1 @@ +- Add `perry-solid`, a Solid universal-renderer bridge for native stacks, text, buttons, spacers, and dividers, with hyperscript authoring, reactive properties, keyed widget moves, and owner disposal. Add a counter/list example, a Node/native release fixture, and a macOS Geisterhand smoke test. Correct macOS indexed stack insertion and retained layout metadata, match the compiler's reorder arguments to the native floating-point ABI, and implement Windows child reordering. Solid JSX compilation remains a separate stage of #4644. diff --git a/changelog.d/9865-solid-jsx.md b/changelog.d/9865-solid-jsx.md new file mode 100644 index 0000000000..9db293fc1b --- /dev/null +++ b/changelog.d/9865-solid-jsx.md @@ -0,0 +1 @@ +- Add opt-in `perry.jsx: "solid"` compilation for native Solid JSX, with reactive properties and children, components, keyed control flow, conditional widget identity, spreads, references, and fragments. Provide JSX types and examples in `perry-solid`, and compare native compilation with Solid's official universal JSX transform in the release fixture. diff --git a/crates/perry-dispatch/src/ui_table/part_a.rs b/crates/perry-dispatch/src/ui_table/part_a.rs index 93eb4dccd4..19c6a4b086 100644 --- a/crates/perry-dispatch/src/ui_table/part_a.rs +++ b/crates/perry-dispatch/src/ui_table/part_a.rs @@ -934,7 +934,7 @@ pub(crate) const PERRY_UI_TABLE_PART_A: &[MethodRow] = &[ MethodRow { method: "widgetReorderChild", runtime: "perry_ui_widget_reorder_child", - args: &[ArgKind::Widget, ArgKind::I64Raw, ArgKind::I64Raw], + args: &[ArgKind::Widget, ArgKind::F64, ArgKind::F64], ret: ReturnKind::Void, }, MethodRow { diff --git a/crates/perry-hir/src/lib.rs b/crates/perry-hir/src/lib.rs index 5e416db78c..e1fa2b3308 100644 --- a/crates/perry-hir/src/lib.rs +++ b/crates/perry-hir/src/lib.rs @@ -25,6 +25,7 @@ pub(crate) mod lower_patterns; pub(crate) mod lower_types; pub mod monomorph; pub mod native_profile; +pub mod solid_jsx; pub mod stable_hash; pub mod type_alias_resolve; pub mod types; diff --git a/crates/perry-hir/src/solid_jsx.rs b/crates/perry-hir/src/solid_jsx.rs new file mode 100644 index 0000000000..67910401de --- /dev/null +++ b/crates/perry-hir/src/solid_jsx.rs @@ -0,0 +1,518 @@ +//! Solid universal JSX expansion before ordinary closure/accessor HIR lowering. +//! +//! Native nodes are constructed once. Property getters and child accessors keep +//! signal reads inside Solid effects; component render-prop functions stay values. + +use std::collections::BTreeSet; + +use swc_common::{Spanned, DUMMY_SP}; +use swc_ecma_ast as ast; +use swc_ecma_visit::{Visit, VisitMut, VisitMutWith, VisitWith}; + +/// Expand JSX for an explicitly selected universal renderer. Returns `None` +/// without cloning when the module contains no JSX. +pub fn lower_solid_jsx(module: &ast::Module, runtime: &str) -> Option { + #[derive(Default)] + struct Names { + names: BTreeSet, + jsx: bool, + } + impl Visit for Names { + fn visit_ident(&mut self, ident: &ast::Ident) { + self.names.insert(ident.sym.to_string()); + } + fn visit_jsx_element(&mut self, element: &ast::JSXElement) { + self.jsx = true; + element.visit_children_with(self); + } + fn visit_jsx_fragment(&mut self, fragment: &ast::JSXFragment) { + self.jsx = true; + fragment.visit_children_with(self); + } + } + let mut names = Names::default(); + module.visit_with(&mut names); + if !names.jsx { + return None; + } + let prefix = (0..) + .map(|n| format!("__perry_solid_{n}_")) + .find(|prefix| !names.names.iter().any(|name| name.starts_with(prefix))) + .expect("finite source identifiers leave a free helper prefix"); + let mut lowering = SolidJsx { + prefix, + next: 0, + helpers: BTreeSet::new(), + }; + let mut result = module.clone(); + result.visit_mut_with(&mut lowering); + if lowering.helpers.is_empty() { + return Some(result); + } + let specifiers = lowering + .helpers + .iter() + .map(|name| { + ast::ImportSpecifier::Named(ast::ImportNamedSpecifier { + span: DUMMY_SP, + local: ident(&format!("{}{name}", lowering.prefix)), + imported: Some(ast::ModuleExportName::Ident(ident(name))), + is_type_only: false, + }) + }) + .collect(); + result.body.insert( + 0, + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(ast::ImportDecl { + span: DUMMY_SP, + specifiers, + src: Box::new(ast::Str { + span: DUMMY_SP, + value: runtime.into(), + raw: None, + }), + type_only: false, + with: None, + phase: Default::default(), + })), + ); + Some(result) +} + +struct SolidJsx { + prefix: String, + next: usize, + helpers: BTreeSet, +} + +fn ident(name: &str) -> ast::Ident { + ast::Ident::new(name.into(), DUMMY_SP, Default::default()) +} + +fn string(value: &str) -> ast::Expr { + ast::Expr::Lit(ast::Lit::Str(ast::Str { + span: DUMMY_SP, + value: value.into(), + raw: None, + })) +} + +fn call(callee: ast::Expr, args: Vec) -> ast::Expr { + ast::Expr::Call(ast::CallExpr { + callee: ast::Callee::Expr(Box::new(callee)), + args: args.into_iter().map(|expr| expr.into()).collect(), + ..Default::default() + }) +} + +fn arrow(value: ast::Expr) -> ast::Expr { + ast::Expr::Arrow(ast::ArrowExpr { + body: Box::new(ast::BlockStmtOrExpr::Expr(Box::new(value))), + ..Default::default() + }) +} + +fn statement(expr: ast::Expr) -> ast::Stmt { + ast::Stmt::Expr(ast::ExprStmt { + span: expr.span(), + expr: Box::new(expr), + }) +} + +fn binding(name: ast::Ident, value: ast::Expr) -> ast::Stmt { + ast::Stmt::Decl(ast::Decl::Var(Box::new(ast::VarDecl { + kind: ast::VarDeclKind::Const, + decls: vec![ast::VarDeclarator { + span: DUMMY_SP, + name: ast::Pat::Ident(name.into()), + init: Some(Box::new(value)), + definite: false, + }], + ..Default::default() + }))) +} + +fn block_expr(mut statements: Vec, result: ast::Expr) -> ast::Expr { + statements.push(ast::Stmt::Return(ast::ReturnStmt { + span: DUMMY_SP, + arg: Some(Box::new(result)), + })); + call( + ast::Expr::Arrow(ast::ArrowExpr { + body: Box::new(ast::BlockStmtOrExpr::BlockStmt(ast::BlockStmt { + stmts: statements, + ..Default::default() + })), + ..Default::default() + }), + vec![], + ) +} + +fn property(name: &str, value: ast::Expr, getter: bool) -> ast::PropOrSpread { + let key = ast::PropName::Str(ast::Str { + span: DUMMY_SP, + value: name.into(), + raw: None, + }); + let prop = if getter { + ast::Prop::Getter(ast::GetterProp { + span: value.span(), + key, + type_ann: None, + body: Some(ast::BlockStmt { + stmts: vec![ast::Stmt::Return(ast::ReturnStmt { + span: value.span(), + arg: Some(Box::new(value)), + })], + ..Default::default() + }), + }) + } else { + ast::Prop::KeyValue(ast::KeyValueProp { + key, + value: Box::new(value), + }) + }; + ast::PropOrSpread::Prop(Box::new(prop)) +} + +fn object(props: Vec) -> ast::Expr { + ast::Expr::Object(ast::ObjectLit { + span: DUMMY_SP, + props, + }) +} + +fn array(elements: Vec) -> ast::Expr { + ast::Expr::Array(ast::ArrayLit { + span: DUMMY_SP, + elems: elements.into_iter().map(|expr| Some(expr.into())).collect(), + }) +} + +fn is_static_value(expr: &ast::Expr) -> bool { + matches!( + expr, + ast::Expr::Lit(_) | ast::Expr::Arrow(_) | ast::Expr::Fn(_) + ) +} + +fn contains_jsx(expr: &ast::Expr) -> bool { + struct Find(bool); + impl Visit for Find { + fn visit_jsx_element(&mut self, _: &ast::JSXElement) { + self.0 = true; + } + fn visit_jsx_fragment(&mut self, _: &ast::JSXFragment) { + self.0 = true; + } + } + let mut find = Find(false); + expr.visit_with(&mut find); + find.0 +} + +fn boolean(expr: ast::Expr) -> ast::Expr { + ast::Expr::Unary(ast::UnaryExpr { + span: expr.span(), + op: ast::UnaryOp::Bang, + arg: Box::new(ast::Expr::Unary(ast::UnaryExpr { + span: expr.span(), + op: ast::UnaryOp::Bang, + arg: Box::new(expr), + })), + }) +} + +impl SolidJsx { + fn helper(&mut self, name: &str, args: Vec) -> ast::Expr { + self.helpers.insert(name.to_string()); + call( + ast::Expr::Ident(ident(&format!("{}{name}", self.prefix))), + args, + ) + } + + fn temporary(&mut self) -> ast::Ident { + let name = ident(&format!("{}node_{}", self.prefix, self.next)); + self.next += 1; + name + } + + fn expression(&mut self, mut expression: ast::Expr) -> ast::Expr { + expression.visit_mut_with(self); + expression + } + + fn getter_expression(&mut self, mut expression: ast::Expr) -> ast::Expr { + let condition = match &mut expression { + ast::Expr::Cond(cond) if contains_jsx(&cond.cons) || contains_jsx(&cond.alt) => { + Some(&mut cond.test) + } + ast::Expr::Bin(binary) + if binary.op == ast::BinaryOp::LogicalAnd && contains_jsx(&binary.right) => + { + Some(&mut binary.left) + } + _ => None, + }; + if let Some(condition) = condition { + let test = self.expression(*condition.clone()); + let memo = self.helper("memo", vec![arrow(boolean(test))]); + *condition = Box::new(call(memo, vec![])); + } + self.expression(expression) + } + + fn child_accessor(&mut self, expr: ast::Expr) -> ast::Expr { + // A truthy-to-truthy update must retain an existing conditional branch. + // Track the condition's boolean value separately from the branch factory. + let mut expr = expr; + let condition = match &mut expr { + ast::Expr::Cond(cond) if contains_jsx(&cond.cons) || contains_jsx(&cond.alt) => { + Some(&mut cond.test) + } + ast::Expr::Bin(binary) + if binary.op == ast::BinaryOp::LogicalAnd && contains_jsx(&binary.right) => + { + Some(&mut binary.left) + } + _ => None, + }; + let mut setup = Vec::new(); + if let Some(condition) = condition { + let value = self.expression(*condition.clone()); + let memo = self.helper("memo", vec![arrow(boolean(value))]); + let name = self.temporary(); + setup.push(binding(name.clone(), memo)); + *condition = Box::new(call(ast::Expr::Ident(name), vec![])); + } + let accessor = arrow(self.expression(expr)); + if setup.is_empty() { + accessor + } else { + block_expr(setup, accessor) + } + } + + fn element_name(&mut self, name: &ast::JSXElementName) -> (ast::Expr, bool) { + match name { + ast::JSXElementName::Ident(name) if name.sym.starts_with(char::is_lowercase) => { + (string(&name.sym), true) + } + ast::JSXElementName::Ident(name) => (ast::Expr::Ident(name.clone()), false), + ast::JSXElementName::JSXMemberExpr(member) => (Self::member(member), false), + ast::JSXElementName::JSXNamespacedName(name) => { + (string(&format!("{}:{}", name.ns.sym, name.name.sym)), true) + } + } + } + + fn member(member: &ast::JSXMemberExpr) -> ast::Expr { + ast::Expr::Member(ast::MemberExpr { + span: member.span, + obj: Box::new(match &member.obj { + ast::JSXObject::Ident(name) => ast::Expr::Ident(name.clone()), + ast::JSXObject::JSXMemberExpr(parent) => Self::member(parent), + }), + prop: ast::MemberProp::Ident(member.prop.clone()), + }) + } + + fn attribute_value(&mut self, value: &ast::JSXAttrValue) -> ast::Expr { + match value { + ast::JSXAttrValue::Str(value) => ast::Expr::Lit(ast::Lit::Str(value.clone())), + ast::JSXAttrValue::JSXExprContainer(container) => match &container.expr { + ast::JSXExpr::Expr(expr) => self.getter_expression(*expr.clone()), + ast::JSXExpr::JSXEmptyExpr(_) => ast::Expr::Ident(ident("undefined")), + }, + ast::JSXAttrValue::JSXElement(element) => self.element(element), + ast::JSXAttrValue::JSXFragment(fragment) => self.fragment(fragment), + } + } + + fn ref_value(&mut self, value: ast::Expr) -> ast::Expr { + let target = ast::AssignTarget::try_from(Box::new(value.clone())).ok(); + let node = self.temporary(); + let current = self.temporary(); + let invoke = call( + ast::Expr::Ident(current.clone()), + vec![ast::Expr::Ident(node.clone())], + ); + let action = if let Some(target) = target { + let assign = ast::Expr::Assign(ast::AssignExpr { + span: DUMMY_SP, + op: ast::AssignOp::Assign, + left: target, + right: Box::new(ast::Expr::Ident(node.clone())), + }); + ast::Expr::Cond(ast::CondExpr { + span: DUMMY_SP, + test: Box::new(ast::Expr::Bin(ast::BinExpr { + span: DUMMY_SP, + op: ast::BinaryOp::EqEqEq, + left: Box::new(ast::Expr::Unary(ast::UnaryExpr { + span: DUMMY_SP, + op: ast::UnaryOp::TypeOf, + arg: Box::new(ast::Expr::Ident(current.clone())), + })), + right: Box::new(string("function")), + })), + cons: Box::new(invoke), + alt: Box::new(assign), + }) + } else { + invoke + }; + let callback = ast::Expr::Arrow(ast::ArrowExpr { + body: Box::new(ast::BlockStmtOrExpr::BlockStmt(ast::BlockStmt { + stmts: vec![binding(current, value), statement(action)], + ..Default::default() + })), + ..Default::default() + }); + // Universal `use` invokes its callback untracked. Evaluating both the + // reference expression and its callback there avoids replaying refs when + // they happen to read a signal during widget construction. + let untracked = self.helper("use", vec![callback, ast::Expr::Ident(node.clone())]); + ast::Expr::Arrow(ast::ArrowExpr { + params: vec![ast::Pat::Ident(node.into())], + body: Box::new(ast::BlockStmtOrExpr::Expr(Box::new(untracked))), + ..Default::default() + }) + } + + fn child(&mut self, child: &ast::JSXElementChild, native: bool) -> Option { + match child { + ast::JSXElementChild::JSXText(text) => { + let text = crate::jsx::normalize_jsx_text(&text.value); + (!text.is_empty()).then(|| string(&text)) + } + ast::JSXElementChild::JSXElement(element) => Some(self.element(element)), + ast::JSXElementChild::JSXFragment(fragment) => Some(self.fragment(fragment)), + ast::JSXElementChild::JSXExprContainer(container) => match &container.expr { + ast::JSXExpr::JSXEmptyExpr(_) => None, + ast::JSXExpr::Expr(expr) => { + let value = *expr.clone(); + Some( + if native + && !is_static_value(&value) + && !matches!( + value, + ast::Expr::JSXElement(_) | ast::Expr::JSXFragment(_) + ) + { + self.child_accessor(value) + } else { + if native { + self.expression(value) + } else { + self.getter_expression(value) + } + }, + ) + } + }, + ast::JSXElementChild::JSXSpreadChild(child) => { + let expr = self.expression(*child.expr.clone()); + Some(if native { arrow(expr) } else { expr }) + } + } + } + + fn element(&mut self, element: &ast::JSXElement) -> ast::Expr { + let (name, native) = self.element_name(&element.opening.name); + let mut chunks = Vec::new(); + let mut props = Vec::new(); + let mut has_spread = false; + for attribute in &element.opening.attrs { + match attribute { + ast::JSXAttrOrSpread::SpreadElement(spread) => { + has_spread = true; + if !props.is_empty() { + chunks.push(object(std::mem::take(&mut props))); + } + let source = self.expression(*spread.expr.clone()); + chunks.push(arrow(source)); + } + ast::JSXAttrOrSpread::JSXAttr(attribute) => { + let key = match &attribute.name { + ast::JSXAttrName::Ident(name) => name.sym.to_string(), + ast::JSXAttrName::JSXNamespacedName(name) => { + format!("{}:{}", name.ns.sym, name.name.sym) + } + }; + let mut value = attribute + .value + .as_ref() + .map(|value| self.attribute_value(value)) + .unwrap_or_else(|| { + ast::Expr::Lit(ast::Lit::Bool(ast::Bool { + span: DUMMY_SP, + value: true, + })) + }); + if key == "ref" { + value = self.ref_value(value); + } + let getter = !is_static_value(&value); + props.push(property(&key, value, getter)); + } + } + } + let mut children = element + .children + .iter() + .filter_map(|child| self.child(child, native)) + .collect::>(); + if !children.is_empty() { + let children = if children.len() == 1 { + children.remove(0) + } else { + array(children) + }; + let getter = !native && !is_static_value(&children); + props.push(property("children", children, getter)); + } + if !props.is_empty() || chunks.is_empty() { + chunks.push(object(props)); + } + let props = if chunks.len() == 1 && !has_spread { + chunks.remove(0) + } else { + self.helper("mergeProps", chunks) + }; + if native { + let node = self.temporary(); + let create = self.helper("createElement", vec![name]); + let spread = self.helper("spread", vec![ast::Expr::Ident(node.clone()), props]); + block_expr( + vec![binding(node.clone(), create), statement(spread)], + ast::Expr::Ident(node), + ) + } else { + self.helper("createComponent", vec![name, props]) + } + } + + fn fragment(&mut self, fragment: &ast::JSXFragment) -> ast::Expr { + array( + fragment + .children + .iter() + .filter_map(|child| self.child(child, true)) + .collect(), + ) + } +} + +impl VisitMut for SolidJsx { + fn visit_mut_expr(&mut self, expression: &mut ast::Expr) { + match expression { + ast::Expr::JSXElement(element) => *expression = self.element(element), + ast::Expr::JSXFragment(fragment) => *expression = self.fragment(fragment), + _ => expression.visit_mut_children_with(self), + } + } +} diff --git a/crates/perry-ui-macos/Cargo.toml b/crates/perry-ui-macos/Cargo.toml index 8afe3c9b72..98771917cc 100644 --- a/crates/perry-ui-macos/Cargo.toml +++ b/crates/perry-ui-macos/Cargo.toml @@ -69,3 +69,11 @@ objc2-app-kit = { version = "0.3", features = [ "NSStatusItem", "NSStatusBarButton", ] } + +[target.'cfg(target_os = "macos")'.dev-dependencies] +perry-runtime.workspace = true + +[[test]] +name = "native_widget_order" +path = "tests/native_widget_order.rs" +harness = false diff --git a/crates/perry-ui-macos/src/widgets/mod.rs b/crates/perry-ui-macos/src/widgets/mod.rs index 0409a9e937..7e52675e66 100644 --- a/crates/perry-ui-macos/src/widgets/mod.rs +++ b/crates/perry-ui-macos/src/widgets/mod.rs @@ -47,7 +47,7 @@ pub mod zstack; use objc2::rc::Retained; use objc2::runtime::{AnyClass, AnyObject}; use objc2::{msg_send, AnyThread, DefinedClass}; -use objc2_app_kit::{NSStackView, NSView}; +use objc2_app_kit::{NSStackView, NSStackViewGravity, NSView}; use objc2_foundation::NSObjectProtocol; use std::cell::RefCell; @@ -284,13 +284,13 @@ pub fn set_hidden(handle: i64, hidden: bool) { if is_stack { let stack: &NSStackView = unsafe { &*(Retained::as_ptr(&parent) as *const NSStackView) }; - let count = stack.arrangedSubviews().len(); - let insert_idx = index.min(count); - unsafe { - let _: () = objc2::msg_send![ - stack, insertArrangedSubview: &*view, atIndex: insert_idx - ]; - } + let count = stack.viewsInGravity(NSStackViewGravity::Top).len(); + stack.insertView_atIndex_inGravity( + &view, + index.min(count), + NSStackViewGravity::Top, + ); + refresh_stack_parent_map(parent_handle, stack); } } } @@ -486,27 +486,50 @@ pub fn clear_children(handle: i64) { } } -/// Add a child view to a parent view at a specific index. +/// Refresh positions used when AppKit detaches and later reattaches hidden views. +fn refresh_stack_parent_map(parent_handle: i64, stack: &NSStackView) { + let views = stack.viewsInGravity(NSStackViewGravity::Top); + WIDGETS.with(|widgets| { + let widgets = widgets.borrow(); + PARENT_MAP.with(|parents| { + let mut parents = parents.borrow_mut(); + for (index, view) in views.iter().enumerate() { + if let Some(handle_index) = widgets + .iter() + .position(|registered| Retained::as_ptr(registered) == Retained::as_ptr(&view)) + { + parents.insert(handle_index as i64 + 1, (parent_handle, index)); + } + } + }); + }); +} + +/// Insert or move a child at an index, retaining its own layout metadata. +/// Perry stacks use the top/leading gravity area for both orientations. pub fn add_child_at(parent_handle: i64, child_handle: i64, index: i64) { if let (Some(parent), Some(child)) = (get_widget(parent_handle), get_widget(child_handle)) { - let is_stack = if let Some(cls) = AnyClass::get(c"NSStackView") { - parent.isKindOfClass(cls) - } else { - false - }; - + let is_stack = AnyClass::get(c"NSStackView") + .map(|class| parent.isKindOfClass(class)) + .unwrap_or(false); if is_stack { - let stack: &NSStackView = - unsafe { &*(Retained::as_ptr(&parent) as *const NSStackView) }; - // Use addView:inGravity: with top/leading gravity for consistent packing - unsafe { - let _: () = objc2::msg_send![stack, addView: &*child, inGravity: 1i64]; + // A move must detach from the previous arranged-view list without + // remove_child's disposal cleanup (which deactivates width/height). + let previous = PARENT_MAP.with(|parents| parents.borrow().get(&child_handle).copied()); + if let Some((old_handle, _)) = previous { + if let Some(old_view) = get_widget(old_handle) { + let old_stack = + unsafe { &*(Retained::as_ptr(&old_view) as *const NSStackView) }; + old_stack.removeView(&child); + refresh_stack_parent_map(old_handle, old_stack); + } } - // Track parent-child for re-attachment after hide/show - PARENT_MAP.with(|m| { - m.borrow_mut() - .insert(child_handle, (parent_handle, index as usize)); - }); + child.removeFromSuperview(); + let stack = unsafe { &*(Retained::as_ptr(&parent) as *const NSStackView) }; + let count = stack.viewsInGravity(NSStackViewGravity::Top).len(); + let index = index.max(0) as usize; + stack.insertView_atIndex_inGravity(&child, index.min(count), NSStackViewGravity::Top); + refresh_stack_parent_map(parent_handle, stack); } else if zstack::is_zstack(parent_handle) { zstack::add_child(parent_handle, child_handle); } else { @@ -530,17 +553,8 @@ pub fn add_child(parent_handle: i64, child_handle: i64) { // Safety: we verified the type with isKindOfClass let stack: &NSStackView = unsafe { &*(Retained::as_ptr(&parent) as *const NSStackView) }; - let index = stack.arrangedSubviews().len(); - // Use addView:inGravity: with Top/Leading gravity (1) so children - // pack tightly from the top (VStack) or leading edge (HStack) - // instead of defaulting to center gravity area. - unsafe { - let _: () = objc2::msg_send![stack, addView: &*child, inGravity: 1i64]; - } - // Track parent-child for re-attachment after hide/show - PARENT_MAP.with(|m| { - m.borrow_mut().insert(child_handle, (parent_handle, index)); - }); + let count = stack.viewsInGravity(NSStackViewGravity::Top).len(); + add_child_at(parent_handle, child_handle, count as i64); } else if zstack::is_zstack(parent_handle) { zstack::add_child(parent_handle, child_handle); } else { @@ -573,6 +587,10 @@ pub fn remove_child(parent_handle: i64, child_handle: i64) { // Clean up metadata maps cleanup_widget_maps(&handles_to_clean); + if is_stack { + let stack = unsafe { &*(Retained::as_ptr(&parent) as *const NSStackView) }; + refresh_stack_parent_map(parent_handle, stack); + } } } @@ -601,31 +619,22 @@ pub fn set_overlay_frame(handle: i64, x: f64, y: f64, w: f64, h: f64) { } } -/// Reorder a child within an NSStackView by moving from one index to another. +/// Reorder a child within a stack, preserving gravity and hidden-view positions. pub fn reorder_child(parent_handle: i64, from_index: i64, to_index: i64) { if let Some(parent) = get_widget(parent_handle) { - let is_stack = if let Some(cls) = AnyClass::get(c"NSStackView") { - parent.isKindOfClass(cls) - } else { - false - }; - + let is_stack = AnyClass::get(c"NSStackView") + .map(|class| parent.isKindOfClass(class)) + .unwrap_or(false); if is_stack { - let stack: &NSStackView = - unsafe { &*(Retained::as_ptr(&parent) as *const NSStackView) }; - let subviews = stack.arrangedSubviews(); - let count = subviews.len(); - let fi = from_index as usize; - let ti = to_index as usize; - if fi < count && ti < count { - let child: *const NSView = - unsafe { objc2::msg_send![&subviews, objectAtIndex: fi] }; - let child_ref: &NSView = unsafe { &*child }; - stack.removeArrangedSubview(child_ref); - unsafe { - let _: () = - objc2::msg_send![stack, insertArrangedSubview: child_ref, atIndex: ti]; - } + let stack = unsafe { &*(Retained::as_ptr(&parent) as *const NSStackView) }; + let views = stack.viewsInGravity(NSStackViewGravity::Top); + let from = from_index as usize; + let to = to_index as usize; + if from < views.len() && to < views.len() && from != to { + let child = views.objectAtIndex(from); + stack.removeView(&child); + stack.insertView_atIndex_inGravity(&child, to, NSStackViewGravity::Top); + refresh_stack_parent_map(parent_handle, stack); } } } diff --git a/crates/perry-ui-macos/tests/native_widget_order.rs b/crates/perry-ui-macos/tests/native_widget_order.rs new file mode 100644 index 0000000000..8544636ff2 --- /dev/null +++ b/crates/perry-ui-macos/tests/native_widget_order.rs @@ -0,0 +1,89 @@ +#[cfg(target_os = "macos")] +fn main() { + use objc2::rc::Retained; + use objc2_app_kit::{NSApplication, NSStackView, NSView}; + use objc2_foundation::MainThreadMarker; + use perry_runtime as _; + use perry_ui_macos::widgets; + + fn children(handle: i64) -> Vec { + let view = widgets::get_widget(handle).unwrap(); + let stack = unsafe { &*(Retained::as_ptr(&view) as *const NSStackView) }; + stack + .arrangedSubviews() + .iter() + .map(|v| Retained::as_ptr(&v) as usize) + .collect() + } + fn ptr(handle: i64) -> usize { + Retained::as_ptr(&widgets::get_widget(handle).unwrap()) as usize + } + + if std::env::args().any(|arg| arg == "--list") { + println!("native_widget_order: test"); + return; + } + let mtm = MainThreadMarker::new().expect("native widget test runs on the main thread"); + let _app = NSApplication::sharedApplication(mtm); + let parent = widgets::vstack::create(0.0); + let other = widgets::hstack::create(0.0); + let a = widgets::spacer::create(); + let b = widgets::spacer::create(); + let c = widgets::spacer::create(); + widgets::add_child(parent, a); + widgets::add_child(parent, b); + widgets::add_child_at(parent, c, 1); + assert_eq!( + children(parent), + vec![ptr(a), ptr(c), ptr(b)], + "indexed insertion must affect native order" + ); + + widgets::add_child_at(parent, a, 2); + assert_eq!(children(parent), vec![ptr(c), ptr(b), ptr(a)]); + widgets::set_width(b, 80.0); + widgets::add_child_at(other, b, 0); + assert_eq!(children(parent), vec![ptr(c), ptr(a)]); + assert_eq!(children(other), vec![ptr(b)]); + let b_view = widgets::get_widget(b).unwrap(); + assert!( + b_view + .constraints() + .iter() + .any(|constraint| constraint.constant() == 80.0 && constraint.isActive()), + "moving a widget preserves its width constraint" + ); + + widgets::add_child_at(parent, b, -1); + assert_eq!(children(parent), vec![ptr(b), ptr(c), ptr(a)]); + assert!(children(other).is_empty()); + widgets::reorder_child(parent, 0, 2); + assert_eq!(children(parent), vec![ptr(c), ptr(a), ptr(b)]); + + // Simulate a stack-detached hidden child, then exercise the cached position + // used by set_hidden. Reordering must update that position for every child. + let parent_view = widgets::get_widget(parent).unwrap(); + let stack = unsafe { &*(Retained::as_ptr(&parent_view) as *const NSStackView) }; + let a_view: Retained = widgets::get_widget(a).unwrap(); + stack.removeArrangedSubview(&a_view); + a_view.removeFromSuperview(); + widgets::set_hidden(a, false); + assert_eq!(children(parent), vec![ptr(c), ptr(a), ptr(b)]); + widgets::remove_child(parent, c); + stack.removeArrangedSubview(&a_view); + a_view.removeFromSuperview(); + widgets::set_hidden(a, false); + assert_eq!( + children(parent), + vec![ptr(a), ptr(b)], + "removal refreshes surviving cached positions" + ); + widgets::add_child_at(parent, c, i64::MAX); + assert_eq!(children(parent), vec![ptr(a), ptr(b), ptr(c)]); + println!( + "PASS native widget ordering, reparenting, retained constraints, and hidden reattachment" + ); +} + +#[cfg(not(target_os = "macos"))] +fn main() {} diff --git a/crates/perry-ui-windows/src/ffi/widget_layout_extras.rs b/crates/perry-ui-windows/src/ffi/widget_layout_extras.rs index 17dd7e11e5..b0247200db 100644 --- a/crates/perry-ui-windows/src/ffi/widget_layout_extras.rs +++ b/crates/perry-ui-windows/src/ffi/widget_layout_extras.rs @@ -143,7 +143,9 @@ pub extern "C" fn perry_ui_stack_set_distribution(handle: i64, distribution: f64 } #[no_mangle] -pub extern "C" fn perry_ui_widget_reorder_child(_parent: i64, _child: i64, _index: i64) {} +pub extern "C" fn perry_ui_widget_reorder_child(parent: i64, from: f64, to: f64) { + widgets::reorder_child(parent, from as i64, to as i64); +} // perry_debug_trace_init and perry_debug_trace_init_done are provided by perry_runtime diff --git a/crates/perry-ui-windows/src/widgets/mod.rs b/crates/perry-ui-windows/src/widgets/mod.rs index 04ef2182a6..11f025f390 100644 --- a/crates/perry-ui-windows/src/widgets/mod.rs +++ b/crates/perry-ui-windows/src/widgets/mod.rs @@ -679,6 +679,30 @@ pub fn add_child_at(parent_handle: i64, child_handle: i64, index: i64) { crate::app::request_layout(); } +/// Move an existing child without changing its native window or layout metadata. +pub fn reorder_child(parent_handle: i64, from_index: i64, to_index: i64) { + if parent_handle <= 0 { + return; + } + let changed = WIDGETS.with(|widgets| { + let mut widgets = widgets.borrow_mut(); + let Some(parent) = widgets.get_mut((parent_handle - 1) as usize) else { + return false; + }; + let from = from_index as usize; + let to = to_index as usize; + if from >= parent.children.len() || to >= parent.children.len() || from == to { + return false; + } + let child = parent.children.remove(from); + parent.children.insert(to, child); + true + }); + if changed { + crate::app::request_layout(); + } +} + /// Remove a specific child from a parent container. pub fn remove_child(parent_handle: i64, child_handle: i64) { // Remove from children list diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index ddaaece7a3..4b29ebdca6 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -661,8 +661,14 @@ fn collect_module_one( collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), ..Default::default() }); + // Expand only in the selected mode. Ordinary accessor/closure lowering then + // owns captures, source-order semantics and the generated renderer imports. + let solid_module = ctx + .solid_jsx + .then(|| perry_hir::solid_jsx::lower_solid_jsx(ast_module, "perry-solid")) + .flatten(); let lower_result = perry_hir::lower_module_full_with_platform_globals( - ast_module, + solid_module.as_ref().unwrap_or(ast_module), &module_name, &source_file_path, *next_class_id, diff --git a/crates/perry/src/commands/compile/host_config.rs b/crates/perry/src/commands/compile/host_config.rs index 01da27af50..6cd0cb613b 100644 --- a/crates/perry/src/commands/compile/host_config.rs +++ b/crates/perry/src/commands/compile/host_config.rs @@ -89,6 +89,14 @@ fn parse_boolean_switch(value: &str) -> Option { } } +fn solid_jsx_mode(value: Option<&str>) -> Result { + match value { + Some("solid") => Ok(true), + Some("default") => Ok(false), + _ => anyhow::bail!("perry.jsx must be \"solid\" or \"default\""), + } +} + fn should_auto_grant_compile_allow( has_universal_route: bool, allow_was_explicit: bool, @@ -166,6 +174,9 @@ pub(super) fn apply_pkg_and_toml_config( if let Some(pkg_json_path) = pkg_json_path.clone() { if let Ok(content) = fs::read_to_string(&pkg_json_path) { if let Ok(pkg) = serde_json::from_str::(&content) { + if let Some(mode) = pkg.get("perry").and_then(|perry| perry.get("jsx")) { + ctx.solid_jsx = solid_jsx_mode(mode.as_str())?; + } if let Some(aliases) = pkg .get("perry") .and_then(|p| p.get("packageAliases")) @@ -778,6 +789,9 @@ pub(super) fn apply_pkg_and_toml_config( .and_then(|s| s.parse::().ok()) { if let Some(perry_tbl) = table.get("perry").and_then(|v| v.as_table()) { + if let Some(mode) = perry_tbl.get("jsx") { + ctx.solid_jsx = solid_jsx_mode(mode.as_str())?; + } if let Some(strict) = perry_tbl.get("strict").and_then(|v| v.as_bool()) { ctx.strict_eval = strict; // #5230: broad `perry.strict` covers dynamic imports too. diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index daaffb002a..5d7f27ea1a 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -690,6 +690,8 @@ pub struct CompilationContext { pub native_addon_paths: BTreeMap, /// Package aliases: maps npm package name → replacement package name (from perry.packageAliases) pub package_aliases: HashMap, + /// Opt-in Solid universal JSX expansion; ordinary JSX remains the default. + pub solid_jsx: bool, /// Packages to compile natively instead of routing to V8 (from perry.compilePackages) pub compile_packages: HashSet, /// Node native-addon packages omitted from wildcard/automatic whole-package @@ -1219,6 +1221,7 @@ impl CompilationContext { native_addons: BTreeMap::new(), native_addon_paths: BTreeMap::new(), package_aliases: HashMap::new(), + solid_jsx: false, compile_packages: HashSet::new(), auto_skipped_node_addon_packages: HashSet::new(), aot_discovered_modules: HashSet::new(), diff --git a/crates/perry/src/main.rs b/crates/perry/src/main.rs index f900d5ee4b..db8da35bee 100644 --- a/crates/perry/src/main.rs +++ b/crates/perry/src/main.rs @@ -212,7 +212,11 @@ fn is_legacy_invocation(args: &[String]) -> bool { continue; } // Check if it looks like a TypeScript file (and not a subcommand) - if arg.ends_with(".ts") || arg.ends_with(".mts") || arg.ends_with(".cts") { + if arg.ends_with(".ts") + || arg.ends_with(".tsx") + || arg.ends_with(".mts") + || arg.ends_with(".cts") + { return true; } // If it's a known subcommand, not legacy diff --git a/crates/perry/tests/solid_jsx_config.rs b/crates/perry/tests/solid_jsx_config.rs new file mode 100644 index 0000000000..1f8a37cb24 --- /dev/null +++ b/crates/perry/tests/solid_jsx_config.rs @@ -0,0 +1,179 @@ +//! Mode selection and cache isolation complement the executable Solid fixture. + +use std::path::Path; +use std::process::{Command, Output}; + +fn fixture() -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("temporary project"); + std::fs::write( + directory.path().join("main.tsx"), + "console.log(Hello);", + ) + .expect("JSX entry"); + std::fs::write( + directory.path().join("host.ts"), + "export function createElement(name: string) { return { name }; }\n\ + export function spread(node: any, props: any) { node.props = props; }\n", + ) + .expect("universal host"); + directory +} + +fn package(directory: &Path, mode: serde_json::Value) { + std::fs::write( + directory.join("package.json"), + serde_json::json!({ + "type": "module", + "perry": { "jsx": mode, "packageAliases": { "perry-solid": "./host.ts" } } + }) + .to_string(), + ) + .expect("project configuration"); +} + +fn compile(directory: &Path, name: &str) -> Output { + Command::new(env!("CARGO_BIN_EXE_perry")) + .current_dir(directory) + .args([ + "compile", + "main.tsx", + "--no-link", + "-o", + &format!("{name}/output.o"), + ]) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env_remove("PERRY_NO_CACHE") + .env_remove("PERRY_DISABLE_BUILD_CACHE") + .output() + .expect("run Perry") +} + +fn objects(path: &Path, output: &mut Vec>) { + if path.is_file() { + output.push(std::fs::read(path).expect("object bytes")); + } else { + for entry in std::fs::read_dir(path).expect("object directory") { + let path = entry.expect("object entry").path(); + if path.extension().is_some_and(|extension| extension == "o") { + output.push(std::fs::read(path).expect("object bytes")); + } + } + } +} + +fn assert_mode(directory: &Path, name: &str, solid: bool) { + let result = compile(directory, name); + assert!( + result.status.success(), + "compile failed: {}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + let mut bytes = Vec::new(); + objects(&directory.join(name), &mut bytes); + assert!(!bytes.is_empty(), "the compiler must produce object files"); + let ordinary_jsx = bytes.iter().any(|object| { + object + .windows(b"js_jsx".len()) + .any(|window| window == b"js_jsx") + }); + assert_eq!( + ordinary_jsx, !solid, + "the selected mode must reach the correct runtime" + ); + let stdout = String::from_utf8_lossy(&result.stdout); + assert!( + stdout.contains(if solid { + "2 native, 0 JavaScript" + } else { + "1 native, 0 JavaScript" + }), + "only Solid mode imports the universal host: {stdout}" + ); +} + +#[test] +fn mode_switches_preserve_default_jsx_and_do_not_reuse_the_other_object() { + let directory = fixture(); + package(directory.path(), "default".into()); + assert_mode(directory.path(), "default-first.o", false); + package(directory.path(), "solid".into()); + assert_mode(directory.path(), "solid-objects", true); + package(directory.path(), "default".into()); + assert_mode(directory.path(), "default-again.o", false); + assert_eq!( + std::fs::read(directory.path().join("default-first.o/output.o")).unwrap(), + std::fs::read(directory.path().join("default-again.o/output.o")).unwrap(), + "changing back to default must recover its original object" + ); +} + +#[test] +fn toml_mode_overrides_package_mode() { + let directory = fixture(); + package(directory.path(), "solid".into()); + std::fs::write( + directory.path().join("perry.toml"), + "[perry]\njsx = 'default'\n", + ) + .unwrap(); + assert_mode(directory.path(), "toml-default.o", false); + package(directory.path(), "default".into()); + std::fs::write( + directory.path().join("perry.toml"), + "[perry]\njsx = 'solid'\n", + ) + .unwrap(); + assert_mode(directory.path(), "toml-solid", true); +} + +#[test] +fn invalid_mode_is_diagnosed_before_codegen() { + let directory = fixture(); + for mode in [ + serde_json::json!("soldi"), + serde_json::json!(true), + serde_json::Value::Null, + ] { + package(directory.path(), mode); + let result = compile(directory.path(), "invalid.o"); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("perry.jsx must be")); + } +} + +#[test] +fn tsx_shorthand_compiles_without_an_explicit_subcommand() { + let directory = fixture(); + package(directory.path(), "solid".into()); + let output = Command::new(env!("CARGO_BIN_EXE_perry")) + .current_dir(directory.path()) + .args(["main.tsx", "--no-link", "-o", "shorthand"]) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .output() + .expect("Perry JSX shorthand"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("2 native, 0 JavaScript")); +} + +#[test] +fn a_fragment_of_literals_does_not_import_an_unused_renderer() { + let directory = fixture(); + package(directory.path(), "solid".into()); + std::fs::write( + directory.path().join("main.tsx"), + "console.log(<>{42}{true});", + ) + .unwrap(); + let result = compile(directory.path(), "fragment"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert!(String::from_utf8_lossy(&result.stdout).contains("1 native, 0 JavaScript")); +} diff --git a/packages/perry-solid/.gitignore b/packages/perry-solid/.gitignore new file mode 100644 index 0000000000..c654d16c90 --- /dev/null +++ b/packages/perry-solid/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +out diff --git a/packages/perry-solid/README.md b/packages/perry-solid/README.md new file mode 100644 index 0000000000..7221198481 --- /dev/null +++ b/packages/perry-solid/README.md @@ -0,0 +1,200 @@ +# Solid for Perry native UI + +`perry-solid` connects Solid's universal renderer to Perry's native widget +handles. Signals update existing widgets directly. The renderer keeps parent +and sibling information in TypeScript so keyed lists can move native widgets +without recreating them. + +This is the runtime bridge from [#4644](https://github.com/PerryTS/perry/issues/4644). +It provides native hyperscript and an opt-in Solid JSX compiler mode. +Solid's bundled `solid-js/h` and `solid-js/html` use its web renderer and are +not substitutes for this package's `h`. + +## Use from this checkout + +In an application project, install the local package and Solid: + +```sh +npm install /path/to/perry/packages/perry-solid solid-js@1.9.15 +``` + +Select Solid's reactive client runtime in the application's `package.json`: + +```json +{ + "perry": { + "compilePackages": ["solid-js"], + "allow": { "compilePackages": ["solid-js"] }, + "packageAliases": { + "solid-js": "solid-js/dist/solid.js", + "solid-js/store": "solid-js/store/dist/store.js" + } + } +} +``` + +These aliases also apply inside Solid's universal renderer and stores, so they +share the same reactive owner. Solid's default Node entry is an intentionally +nonreactive server build. `perry-solid` declares `nativeModule: true`; its +TypeScript is compiled natively along with Solid. + +```ts +import { App, VStack } from "perry/ui"; +import { createSignal } from "solid-js"; +import { h, render } from "perry-solid"; + +const body = VStack([]); +const dispose = render(() => { + const [count, setCount] = createSignal(0); + return h("VStack", { padding: 16 }, + h("Text", { fontSize: 24 }, () => `Count: ${count()}`), + h("Button", { onPress: () => setCount(n => n + 1) }, "Increment"), + ); +}, body); + +App({ title: "Solid + Perry", width: 400, height: 240, body }); +// Call dispose() when unmounting this root: it stops effects and detaches nodes. +``` + +[examples/counter.ts](examples/counter.ts) adds a keyed list and a rotate button. +Compile it from the package directory with: + +```sh +perry examples/counter.ts -o counter +./counter +``` + +## Components and properties + +### JSX + +Set `"jsx": "solid"` inside your application's `perry` configuration alongside +the Solid client aliases above. The equivalent TOML setting is `[perry]` with +`jsx = "solid"`. An omitted setting, or `"default"`, keeps Perry's existing JSX +behavior. Perry performs the transform in the compiler; Babel is only used as +an independent test oracle for this package. + +```tsx +import { createSignal } from "solid-js"; +import { For } from "perry-solid"; + +function Counter() { + const [count, setCount] = createSignal(0); + return + Count: {count()} + + ; +} +``` + +Native intrinsic tags are `vstack`, `hstack`, `text`, `button`, `spacer`, and +`divider`. Capitalized and member names refer to your components. Use +`{item => {item.name}}` for keyed lists. +Signal reads in properties and children stay reactive; a signal write updates +the affected widgets without rerunning the component. References support a +callback or an assignable variable/member, and reference callbacks run untracked. +Spreads keep property precedence, and fragments group native children. + +For TypeScript checking, use `"jsx": "preserve"` and +`"jsxImportSource": "perry-solid"` in `tsconfig.json`. Perry consumes the `.tsx` +source directly. [examples/counter.tsx](examples/counter.tsx) is a complete app; +copy it into the application project where you installed `perry-solid`, then run +`perry counter.tsx -o counter` there. Generated JSX imports resolve the installed +`perry-solid` package just like handwritten imports. + +### Hyperscript + +Use `h(Component, props)` for functions returning native children. Reactive +children are accessors (`() => count()`); reactive properties are getters: + +```ts +h("Text", { get opacity() { return dimmed() ? 0.5 : 1; } }, "Status") +``` + +Supported elements are `VStack`, `HStack`, `Text`, `Button`, `Spacer`, and +`Divider`. Stacks use an initial spacing of eight points. `Text` and `Button` +accept text children (including arrays and reactive text); stacks accept +widgets and text. A primitive text child gets its own native Text widget only +when inserted into a stack. + +| Property | Native behavior | +| --- | --- | +| `text` | Set a Text value or Button title; use this or text children. | +| `onPress` | Button callback; a reactive getter can replace it. | +| `width`, `height` | Fixed native dimensions. | +| `opacity`, `hidden`, `disabled` | Native widget state. | +| `padding`, `cornerRadius` | Uniform padding and corner radius. | +| `backgroundColor` | Four numeric RGBA channels: `[r, g, b, a]`. | +| `tooltip` | Native tooltip text. | +| `fontSize` | Text font size. | + +Properties map to native setters, not CSS. Unsupported element/property names +throw. `ref` follows Solid's spread contract and receives a `NativeNode`; its +`handle` is an opaque Perry widget handle, not an ordinary serializable number. + +Import `For` from `perry-solid` for Solid's keyed list behavior with native +child types: + +```ts +h("VStack", null, For({ + get each() { return items(); }, + children: item => h("Text", null, item.name), +})) +``` + +Mount with `render(component, emptyStackHandle)`. Use a dedicated empty native +VStack or HStack; the renderer owns its mounted child order. The returned disposer is +idempotent, runs Solid cleanup, releases stored user callbacks, and detaches +the mounted nodes. Native +widget allocation and reclamation otherwise follow Perry's widget registry. + +The low-level universal helpers (`createElement`, `createTextNode`, `insertNode`, `insert`, +`spread`, `setProp`, `createComponent`, `effect`, `memo`, `mergeProps`, and `use`) +are also exported. `perry-solid/renderer` exposes `createNativeRenderer` and its +`NativeDriver` interface for testing host behavior without a display server. + +## Validation + +```sh +npm ci --ignore-scripts +npm test +npm run test:jsx:oracle +npm run typecheck +PERRY_BIN=/absolute/path/to/perry ../../tests/release/packages/_harness.sh --filter perry-solid +``` + +The release fixture copies the actual package sources and pinned dependencies, +then checks the same assertions in Node's browser condition and Perry. It +covers reactive properties/text, callback replacement, keyed identity/order, +reparenting, invalid tree operations, and disposal; it also requires zero +JavaScript modules in the native build. The JSX fixture runs the same assertions +through Solid's pinned official Babel universal transform in Node and through +Perry's own JSX compiler. It also checks conditional identity, refs, fragments, +component execution counts, and generated-helper name collisions. + +`test/native-smoke.ts` is a real widget app for Geisterhand checks. The macOS +backend's `native_widget_order` Cargo target runs on the main thread and checks +actual AppKit ordering, moves between stacks, retained dimensions, and hidden +reattachment. Other backends use their existing native insertion/removal APIs; +this change does not establish executed platform coverage outside macOS. + +From this package directory, with a Geisterhand-enabled Perry installation: + +```sh +perry compile test/native-smoke.ts --geisterhand-port 19764 -o /tmp/perry-solid-smoke +python3 test/native-smoke.py /tmp/perry-solid-smoke --output-dir /tmp/perry-solid-smoke-results +``` + +The runner checks updates to the same native Text handles, button callbacks, +keyed row order with retained widget identities, and stopped effects after +disposal. It saves screenshots and widget snapshots, then exits the app cleanly. +GC scheduling and verifier environment variables are inherited by the app. +To exercise JSX against the same assertions, copy `test/native-smoke.tsx` into +your application project with `perry-solid` installed and `perry.jsx` set to +`"solid"`. Compile that copy with the same Geisterhand flag, then run the Python +runner against the resulting binary. + +The client runtime's separate GC verifier correction is in +[#9822](https://github.com/PerryTS/perry/pull/9822). Use that correction for +`PERRY_GC_VERIFY_EVACUATION=1` when testing workloads with retained array-growth +aliases. diff --git a/packages/perry-solid/examples/counter.ts b/packages/perry-solid/examples/counter.ts new file mode 100644 index 0000000000..f1ed4e4526 --- /dev/null +++ b/packages/perry-solid/examples/counter.ts @@ -0,0 +1,24 @@ +import { App, VStack } from "perry/ui"; +import { createSignal } from "solid-js"; +import { h, render, For } from "../src/index.ts"; + +function Counter() { + const [count, setCount] = createSignal(0); + const [items, setItems] = createSignal(["Alpha", "Beta", "Gamma"]); + return h("VStack", { padding: 16 }, + h("Text", { fontSize: 24 }, () => `Count: ${count()}`), + h("HStack", null, + h("Button", { onPress: () => setCount(n => n + 1) }, "Increment"), + h("Button", { onPress: () => setCount(0) }, "Reset"), + h("Button", { onPress: () => setItems(rows => [rows[2], rows[0], rows[1]]) }, "Rotate"), + ), + h("VStack", null, For({ + get each() { return items(); }, + children: item => h("Text", null, item), + })), + ); +} + +const body = VStack([]); +render(Counter, body); +App({ title: "Solid + Perry", width: 420, height: 300, body }); diff --git a/packages/perry-solid/examples/counter.tsx b/packages/perry-solid/examples/counter.tsx new file mode 100644 index 0000000000..69ead49f62 --- /dev/null +++ b/packages/perry-solid/examples/counter.tsx @@ -0,0 +1,21 @@ +import { App, VStack } from "perry/ui"; +import { createSignal } from "solid-js"; +import { For, render } from "perry-solid"; + +function Counter() { + const [count, setCount] = createSignal(0); + const [items, setItems] = createSignal(["Alpha", "Beta", "Gamma"]); + return + Count: {count()} + + + + + + {item => {item}} + ; +} + +const body = VStack([]); +render(() => , body); +App({ title: "Solid JSX + Perry", width: 420, height: 300, body }); diff --git a/packages/perry-solid/package-lock.json b/packages/perry-solid/package-lock.json new file mode 100644 index 0000000000..ee53ab72fb --- /dev/null +++ b/packages/perry-solid/package-lock.json @@ -0,0 +1,729 @@ +{ + "name": "perry-solid", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perry-solid", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@babel/core": "7.29.7", + "@types/node": "26.4.1", + "babel-preset-solid": "1.9.15", + "solid-js": "1.9.15", + "typescript": "5.9.3" + }, + "peerDependencies": { + "solid-js": "^1.9.15" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/node": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions": { + "version": "0.40.10", + "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.10.tgz", + "integrity": "sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "7.18.6", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/types": "^7.20.7", + "html-entities": "2.3.3", + "parse5": "^7.1.2" + }, + "peerDependencies": { + "@babel/core": "^7.20.12" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions/node_modules/@babel/helper-module-imports": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/babel-preset-solid": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.15.tgz", + "integrity": "sha512-GBmg1OiPb+OwcH51XbDAKPtvrPfQW7rCJTJxcp8+yhtWwN+kqnbEJk2SgVybd+uhTxTKAvjaFyiQSr/eUZBwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jsx-dom-expressions": "^0.40.10" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "solid-js": "^1.9.15" + }, + "peerDependenciesMeta": { + "solid-js": { + "optional": true + } + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/solid-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.15.tgz", + "integrity": "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "~1.5.4", + "seroval-plugins": "~1.5.4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/packages/perry-solid/package.json b/packages/perry-solid/package.json new file mode 100644 index 0000000000..ccf5bd1064 --- /dev/null +++ b/packages/perry-solid/package.json @@ -0,0 +1,48 @@ +{ + "name": "perry-solid", + "version": "0.1.0", + "description": "Solid's universal renderer for Perry native widgets", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./renderer": "./src/renderer.ts", + "./jsx-runtime": "./src/jsx-runtime.ts" + }, + "files": [ + "src", + "README.md" + ], + "license": "MIT", + "peerDependencies": { + "solid-js": "^1.9.15" + }, + "devDependencies": { + "@babel/core": "7.29.7", + "@types/node": "26.4.1", + "babel-preset-solid": "1.9.15", + "solid-js": "1.9.15", + "typescript": "5.9.3" + }, + "scripts": { + "test": "node --conditions=browser test/renderer.test.ts", + "typecheck": "tsc --noEmit", + "test:jsx:oracle": "node test/jsx/oracle.cjs && node --conditions=browser test/jsx/generated.ts" + }, + "perry": { + "nativeModule": true, + "compilePackages": [ + "solid-js" + ], + "allow": { + "compilePackages": [ + "solid-js" + ] + }, + "packageAliases": { + "solid-js": "solid-js/dist/solid.js" + }, + "jsx": "solid" + } +} diff --git a/packages/perry-solid/src/index.ts b/packages/perry-solid/src/index.ts new file mode 100644 index 0000000000..78246c85ec --- /dev/null +++ b/packages/perry-solid/src/index.ts @@ -0,0 +1,92 @@ +import { + VStack, HStack, Text, Button, Spacer, Divider, + textSetString, buttonSetTitle, textSetFontSize, + widgetAddChildAt, widgetRemoveChild, widgetReorderChild, + widgetSetWidth, widgetSetHeight, widgetSetOpacity, + widgetSetHidden, widgetSetEnabled, widgetSetTooltip, + widgetSetBackgroundColor, setCornerRadius, setPadding, + type Widget, +} from "perry/ui"; +import { createNativeRenderer, type NativeDriver, type ElementName } from "./renderer.ts"; +export type { NativeNode, Child, Component, Props, ElementName } from "./renderer.ts"; +export { For } from "./renderer.ts"; + +// Perry injects this target constant (0 = macOS, 1 = iOS, 2 = Android, 3 = Windows, 4 = Linux). +declare const __platform__: number; + +function numeric(value: unknown, fallback: number): number { + if (value == null) return fallback; + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("Expected a finite native widget value"); + return value; +} + +const driver: NativeDriver = { + create(kind: ElementName, onPress: () => void): number { + switch (kind) { + case "VStack": return VStack(8, []); + case "HStack": return HStack(8, []); + case "Text": return Text(""); + case "Button": return Button("", onPress); + case "Spacer": return Spacer(); + case "Divider": return Divider(); + } + }, + setProperty(handle, kind, name, value) { + const widget = handle as Widget; + switch (name) { + case "text": + if (kind === "Text") textSetString(widget, value == null ? "" : String(value)); + else if (kind === "Button") buttonSetTitle(widget, value == null ? "" : String(value)); + else throw new Error(`text is unsupported on ${kind}`); + return; + case "width": widgetSetWidth(widget, numeric(value, 0)); return; + case "height": widgetSetHeight(widget, numeric(value, 0)); return; + case "opacity": widgetSetOpacity(widget, numeric(value, 1)); return; + case "hidden": widgetSetHidden(widget, value ? 1 : 0); return; + case "disabled": widgetSetEnabled(widget, value ? 0 : 1); return; + case "tooltip": widgetSetTooltip(widget, value == null ? "" : String(value)); return; + case "cornerRadius": setCornerRadius(widget, numeric(value, 0)); return; + case "padding": { + const amount = numeric(value, 0); + setPadding(widget, amount, amount, amount, amount); + return; + } + case "fontSize": + if (kind !== "Text") throw new Error("fontSize is supported on Text"); + textSetFontSize(widget, numeric(value, 13)); + return; + case "backgroundColor": { + const color = value == null ? [0, 0, 0, 0] : value; + if (!Array.isArray(color) || color.length !== 4) throw new Error("backgroundColor expects [r, g, b, a]"); + widgetSetBackgroundColor(widget, numeric(color[0], 0), numeric(color[1], 0), numeric(color[2], 0), numeric(color[3], 0)); + return; + } + default: throw new Error(`Unsupported Perry Solid property: ${name}`); + } + }, + insert(parent, child, index, previousParent) { + // AppKit's indexed insertion detaches without destroying retained layout + // metadata. Other backends need the old parent explicitly cleared first. + if (previousParent !== null && __platform__ !== 0) { + widgetRemoveChild(previousParent as Widget, child as Widget); + } + widgetAddChildAt(parent as Widget, child as Widget, index); + }, + move(parent, from, to) { widgetReorderChild(parent as Widget, from, to); }, + remove(parent, child) { widgetRemoveChild(parent as Widget, child as Widget); }, +}; + +const native = createNativeRenderer(driver); +export const h = native.h; +export const render = native.render; +export const createElement = native.createElement; +export const createTextNode = native.createTextNode; +export const insert = native.insert; +export const insertNode = native.insertNode; +export const spread = native.spread; +export const setProp = native.setProp; +export const createComponent = native.createComponent; +export const effect = native.effect; +export const memo = native.memo; +export const mergeProps = native.mergeProps; +export const use = native.use; diff --git a/packages/perry-solid/src/jsx-runtime.ts b/packages/perry-solid/src/jsx-runtime.ts new file mode 100644 index 0000000000..eacad348d5 --- /dev/null +++ b/packages/perry-solid/src/jsx-runtime.ts @@ -0,0 +1,32 @@ +import type { Child, NativeNode, Props } from "./renderer.ts"; + +/** Types for JSX preserved for Perry's Solid compiler mode. */ +export namespace JSX { + export type Element = Child; + export type ElementType = keyof IntrinsicElements | ((props: any) => Child); + export interface ElementChildrenAttribute { children: {}; } + export interface NativeProps extends Props { + children?: Child; + ref?: NativeNode | ((node: NativeNode) => void); + text?: string; + onPress?: () => void; + width?: number; + height?: number; + opacity?: number; + hidden?: boolean; + disabled?: boolean; + tooltip?: string; + padding?: number; + cornerRadius?: number; + fontSize?: number; + backgroundColor?: [number, number, number, number]; + } + export interface IntrinsicElements { + vstack: NativeProps; + hstack: NativeProps; + text: NativeProps; + button: NativeProps; + spacer: NativeProps; + divider: NativeProps; + } +} diff --git a/packages/perry-solid/src/renderer.ts b/packages/perry-solid/src/renderer.ts new file mode 100644 index 0000000000..1499f890d6 --- /dev/null +++ b/packages/perry-solid/src/renderer.ts @@ -0,0 +1,205 @@ +import { createRenderer } from "solid-js/universal"; +import { createRoot, getOwner, onCleanup, mergeProps, For as SolidFor, type Accessor } from "solid-js"; + +export type ElementName = "VStack" | "HStack" | "Text" | "Button" | "Spacer" | "Divider"; +export type Props = Record; +export type Child = NativeNode | string | number | boolean | null | undefined | Child[] | (() => Child); +export type Component

= (props: P) => Child; + +/** Solid For with native children instead of DOM-specific JSX declarations. */ +export const For = SolidFor as (props: { + each: readonly T[] | false | null | undefined; + fallback?: Child; + children: (item: T, index: Accessor) => Child; +}) => Child; + +/** Backend operations. Handles belong to the native widget registry. */ +export interface NativeDriver { + create(kind: ElementName, onPress: () => void): number; + setProperty(handle: number, kind: ElementName, name: string, value: unknown, previous: unknown): void; + insert(parent: number, child: number, index: number, previousParent: number | null): void; + move(parent: number, from: number, to: number): void; + remove(parent: number, child: number): void; +} + +/** Retained ordering metadata; native handles themselves have no sibling API. */ +export interface NativeNode { + kind: ElementName | "#text" | "#root"; + handle: number; + materialized: boolean; + parent: NativeNode | null; + children: NativeNode[]; + props: Props; + text: string; +} + +function isLabel(node: NativeNode): boolean { + return node.kind === "Text" || node.kind === "Button"; +} + +function isContainer(node: NativeNode): boolean { + return node.kind === "VStack" || node.kind === "HStack" || node.kind === "#root"; +} + +export function createNativeRenderer(driver: NativeDriver) { + function makeNode(kind: NativeNode["kind"], text = ""): NativeNode { + return { kind, handle: 0, materialized: false, parent: null, children: [], props: {}, text }; + } + + function createElement(name: string): NativeNode { + // JSX intrinsic names are lowercase; the native driver uses Perry names. + switch (name) { + case "vstack": name = "VStack"; break; + case "hstack": name = "HStack"; break; + case "text": name = "Text"; break; + case "button": name = "Button"; break; + case "spacer": name = "Spacer"; break; + case "divider": name = "Divider"; break; + } + if (!["VStack", "HStack", "Text", "Button", "Spacer", "Divider"].includes(name)) { + throw new Error(`Unsupported Perry Solid element: ${name}`); + } + const node = makeNode(name as ElementName); + // A native button's dispatcher outlives a removed Solid owner. Release + // user callbacks when that owner is disposed, even before native detach. + if (getOwner()) onCleanup(() => { node.props = {}; }); + node.handle = driver.create(name as ElementName, () => { + const callback = node.props.onPress; + if (typeof callback === "function") callback(); + }); + node.materialized = true; + return node; + } + + function materialize(node: NativeNode): number { + // Text under a Text/Button contributes to its label. Allocate an independent + // native Text only when the text node is actually inserted into a container. + if (node.kind === "#text" && !node.materialized) { + node.handle = driver.create("Text", () => {}); + node.materialized = true; + driver.setProperty(node.handle, "Text", "text", node.text, undefined); + } + return node.handle; + } + + function refreshLabel(node: NativeNode): void { + let text = ""; + for (const child of node.children) text += child.text; + driver.setProperty(node.handle, node.kind as ElementName, "text", text, undefined); + } + + function removeNode(parent: NativeNode, node: NativeNode): void { + if (node.parent !== parent) return; + const index = parent.children.indexOf(node); + parent.children.splice(index, 1); + node.parent = null; + if (isLabel(parent)) refreshLabel(parent); + else driver.remove(parent.handle, materialize(node)); + } + + function insertNode(parent: NativeNode, node: NativeNode, anchor?: NativeNode): void { + if (anchor === node) return; + if (anchor && anchor.parent !== parent) throw new Error("Insertion anchor belongs to another parent"); + if (isLabel(parent)) { + if (node.kind !== "#text") throw new Error("Text and Button children must be text"); + } else if (!isContainer(parent)) { + throw new Error(`${parent.kind} cannot contain children`); + } + for (let ancestor: NativeNode | null = parent; ancestor; ancestor = ancestor.parent) { + if (ancestor === node) throw new Error("Cannot insert a node into its own subtree"); + } + const previousParent = node.parent; + const previousIndex = previousParent ? previousParent.children.indexOf(node) : -1; + if (previousParent) previousParent.children.splice(previousIndex, 1); + const index = anchor ? parent.children.indexOf(anchor) : parent.children.length; + parent.children.splice(index, 0, node); + node.parent = parent; + + if (previousParent && previousParent !== parent && isLabel(previousParent)) refreshLabel(previousParent); + if (isLabel(parent)) { + if (previousParent && !isLabel(previousParent)) driver.remove(previousParent.handle, materialize(node)); + refreshLabel(parent); + } else if (previousParent === parent) { + if (previousIndex !== index) driver.move(parent.handle, previousIndex, index); + } else { + const oldHandle = previousParent && !isLabel(previousParent) ? previousParent.handle : null; + driver.insert(parent.handle, materialize(node), index, oldHandle); + } + } + + const renderer = createRenderer({ + createElement, + createTextNode(value) { return makeNode("#text", String(value)); }, + isTextNode(node) { return node.kind === "#text"; }, + replaceText(node, value) { + node.text = String(value); + if (node.materialized) driver.setProperty(node.handle, "Text", "text", node.text, undefined); + if (node.parent && isLabel(node.parent)) refreshLabel(node.parent); + }, + setProperty(node, name, value, previous) { + if (name === "onPress") { + if (node.kind !== "Button") throw new Error("onPress is supported on Button"); + if (value != null && typeof value !== "function") throw new Error("onPress must be a function"); + } else { + driver.setProperty(node.handle, node.kind as ElementName, name, value, previous); + } + node.props[name] = value; + }, + insertNode, + removeNode, + getParentNode(node) { return node.parent || undefined; }, + getFirstChild(node) { return node.children[0]; }, + getNextSibling(node) { + const parent = node.parent; + return parent ? parent.children[parent.children.indexOf(node) + 1] : undefined; + }, + }); + + // Solid's implementation accepts arrays, primitives and accessors too; + // its universal declaration narrows component results to NodeType. + const createComponent = renderer.createComponent as

(component: (props: P) => Child, props: P) => Child; + + /** Native hyperscript. Reactive properties use getters; children may be accessors. */ + function h(type: ElementName, props?: Props | null, ...children: Child[]): NativeNode; + function h

(type: Component

, props: P, ...children: Child[]): Child; + function h(type: ElementName | Component, props: Props | null = null, ...children: Child[]): Child { + const properties = children.length + ? mergeProps(props || {}, { children: children.length === 1 ? children[0] : children }) + : (props || {}); + if (typeof type === "function") return createComponent(type, properties); + const node = createElement(type); + renderer.spread(node, properties); + return node; + } + + function releaseSubtree(node: NativeNode): void { + for (const child of node.children) releaseSubtree(child); + node.children = []; + node.parent = null; + node.props = {}; + } + + /** Mount into an existing native stack; dispose effects and detach its nodes. */ + function render(code: () => Child, handle: number): () => void { + const root = makeNode("#root"); + root.handle = handle; + root.materialized = true; + const dispose = createRoot(dispose => { + renderer.insert(root, code()); + return dispose; + }); + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + dispose(); + while (root.children.length) { + const child = root.children[root.children.length - 1]; + removeNode(root, child); + releaseSubtree(child); + } + }; + } + + return { ...renderer, createComponent, render, h, removeNode }; +} diff --git a/packages/perry-solid/test/jsx/.gitignore b/packages/perry-solid/test/jsx/.gitignore new file mode 100644 index 0000000000..6c1fa8aa50 --- /dev/null +++ b/packages/perry-solid/test/jsx/.gitignore @@ -0,0 +1,3 @@ +generated.ts +out +*.log diff --git a/packages/perry-solid/test/jsx/host.ts b/packages/perry-solid/test/jsx/host.ts new file mode 100644 index 0000000000..4d8313c895 --- /dev/null +++ b/packages/perry-solid/test/jsx/host.ts @@ -0,0 +1,45 @@ +import { createNativeRenderer, type NativeDriver, type NativeNode, type ElementName } from "../../src/renderer.ts"; + +export const widgets: { + kind: ElementName; + children: number[]; + props: Record; + press: () => void; +}[] = []; + +const driver: NativeDriver = { + create(kind, press) { + widgets.push({ kind, children: [], props: {}, press }); + return widgets.length; + }, + setProperty(handle, _kind, name, value) { widgets[handle - 1].props[name] = value; }, + insert(parent, child, index, previousParent) { + if (previousParent !== null) { + const old = widgets[previousParent - 1].children; + old.splice(old.indexOf(child), 1); + } + widgets[parent - 1].children.splice(index, 0, child); + }, + move(parent, from, to) { + const children = widgets[parent - 1].children; + const child = children.splice(from, 1)[0]; + children.splice(to, 0, child); + }, + remove(parent, child) { + const children = widgets[parent - 1].children; + children.splice(children.indexOf(child), 1); + }, +}; + +export const root = driver.create("VStack", () => {}); +export const { + render, h, createElement, createTextNode, createComponent, insert, insertNode, + spread, setProp, effect, memo, mergeProps, use, +} = createNativeRenderer(driver); + +export function props(node: NativeNode): Record { + return widgets[node.handle - 1].props; +} +export function children(node: NativeNode): number[] { + return widgets[node.handle - 1].children; +} diff --git a/packages/perry-solid/test/jsx/main.tsx b/packages/perry-solid/test/jsx/main.tsx new file mode 100644 index 0000000000..899006d3d6 --- /dev/null +++ b/packages/perry-solid/test/jsx/main.tsx @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { createSignal, onCleanup } from "solid-js"; +import { For, type NativeNode, type Child } from "../../src/renderer.ts"; +import { render, root, widgets, props, children } from "./host.ts"; + +// Deliberately collide with the transform's first candidate helper prefix. +const __perry_solid_0_createElement = "user binding"; +const [count, setCount] = createSignal(0); +const [rows, setRows] = createSignal(["Alpha", "Beta", "Gamma"]); +const [shown, setShown] = createSignal(1); +const [spreadProps, setSpreadProps] = createSignal({ text: "Spread 0", width: 150 }); +const [handler, setHandler] = createSignal<() => void>(() => setCount(value => value + 1)); +let label!: NativeNode; +let button!: NativeNode; +let list!: NativeNode; +let raw!: NativeNode; +let conditional!: NativeNode; +let spreadOnly!: NativeNode; +let precedence!: NativeNode; +const memberRef: { current?: NativeNode } = {}; +let callbackRef!: NativeNode; +let refCalls = 0; +const capture = (node: NativeNode) => { count(); refCalls++; callbackRef = node; }; +let componentRuns = 0; +let cleanups = 0; + +function Panel(properties: { children?: Child; title: string }) { + componentRuns++; + return {properties.children}; +} +const UI = { Panel }; +const dispose = render(() => { + onCleanup(() => cleanups++); + return + Count: {count()} + + {"Raw: " + count()} + + {item => {item}} + + {shown() > 0 && Kept} + + + Member + Callback + <>Fragment + ; +}, root); + +assert.equal(__perry_solid_0_createElement, "user binding"); +assert.equal(props(label!).text, "Count: 0"); +assert.equal(props(label!).width, 100); +assert.equal(props(button!).text, "Increment"); +assert.equal(props(spreadOnly!).text, "Spread 0"); +assert.equal(props(precedence!).width, 200); +assert.equal(props(memberRef.current!).text, "Member"); +assert.equal(props(callbackRef!).text, "Callback"); +const firstRaw = children(raw!)[0]; +const firstConditional = conditional!; +const firstRows = [...children(list!)]; +widgets[button!.handle - 1].press(); +assert.equal(props(label!).text, "Count: 1"); +assert.equal(props(label!).width, 101); +assert.equal(children(raw!)[0], firstRaw); +assert.equal(widgets[firstRaw - 1].props.text, "Raw: 1"); +assert.equal(componentRuns, 1, "signal writes do not rerun the component"); +setHandler(() => () => setCount(value => value + 10)); +widgets[button!.handle - 1].press(); +assert.equal(props(label!).text, "Count: 11"); +assert.equal(refCalls, 1, "ref callbacks do not subscribe to signals they read"); +setRows(items => [items[2], items[0], items[1]]); +assert.deepEqual(children(list!), [firstRows[2], firstRows[0], firstRows[1]]); +setShown(2); +assert.equal(conditional!, firstConditional, "truthy condition updates preserve the native branch"); +setShown(0); +assert.equal(firstConditional.parent, null); +setShown(1); +assert.notEqual(conditional!, firstConditional); +setSpreadProps({ text: "Spread 1", width: 160 }); +assert.equal(props(spreadOnly!).text, "Spread 1"); +assert.equal(props(spreadOnly!).width, 160); +assert.equal(props(precedence!).width, 211, "later attributes retain precedence over a changing spread"); +const beforeDispose = props(label!).text; +dispose(); +setCount(99); +widgets[button!.handle - 1].press(); +assert.equal(count(), 99); +assert.equal(props(label!).text, beforeDispose); +assert.equal(cleanups, 1); +assert.deepEqual(widgets[root - 1].children, []); +console.log("PASS Solid JSX: native updates, components, keyed identity, conditionals, spreads, refs, fragments, disposal"); diff --git a/packages/perry-solid/test/jsx/oracle.cjs b/packages/perry-solid/test/jsx/oracle.cjs new file mode 100644 index 0000000000..a95053681f --- /dev/null +++ b/packages/perry-solid/test/jsx/oracle.cjs @@ -0,0 +1,14 @@ +const { readFileSync, writeFileSync } = require("node:fs"); +const { join } = require("node:path"); +const { transformSync } = require("@babel/core"); +const preset = require("babel-preset-solid"); + +const input = join(__dirname, "main.tsx"); +const output = transformSync(readFileSync(input, "utf8"), { + filename: input, + configFile: false, + babelrc: false, + parserOpts: { plugins: ["typescript", "jsx"] }, + presets: [[preset, { generate: "universal", moduleName: "./host.ts", builtIns: [] }]], +}); +writeFileSync(join(__dirname, "generated.ts"), output.code + "\n"); diff --git a/packages/perry-solid/test/jsx/package.json b/packages/perry-solid/test/jsx/package.json new file mode 100644 index 0000000000..a592634bf1 --- /dev/null +++ b/packages/perry-solid/test/jsx/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "type": "module", + "perry": { + "jsx": "solid", + "compilePackages": ["solid-js"], + "allow": { "compilePackages": ["solid-js"] }, + "packageAliases": { + "solid-js": "solid-js/dist/solid.js", + "perry-solid": "./host.ts" + } + } +} diff --git a/packages/perry-solid/test/native-smoke.py b/packages/perry-solid/test/native-smoke.py new file mode 100644 index 0000000000..5f4a543cc6 --- /dev/null +++ b/packages/perry-solid/test/native-smoke.py @@ -0,0 +1,105 @@ +"""Exercise native-smoke.ts on macOS through its Geisterhand server.""" + +import argparse +import json +import socket +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("binary", type=Path) +parser.add_argument("--port", type=int, default=19764) +parser.add_argument("--output-dir", type=Path, required=True) +args = parser.parse_args() +if sys.platform != "darwin": + parser.error("this test checks AppKit frame coordinates and requires macOS") +with socket.socket() as probe: + probe.bind(("127.0.0.1", args.port)) +args.output_dir.mkdir(parents=True, exist_ok=True) +base = f"http://127.0.0.1:{args.port}" + + +def get(path): + with urllib.request.urlopen(base + path, timeout=4) as response: + return response.read() + + +def wait_value(handle, expected): + deadline = time.monotonic() + 8 + actual = None + while time.monotonic() < deadline: + actual = json.loads(get(f"/value/{handle}"))["value"] + if actual == expected: + return + time.sleep(0.1) + raise AssertionError((handle, expected, actual)) + + +def click(handle): + request = urllib.request.Request(base + f"/click/{handle}", method="POST", data=b"") + with urllib.request.urlopen(request, timeout=4) as response: + assert json.load(response)["ok"] + + +def capture(name): + tree = get("/widgets?tree=true") + (args.output_dir / f"{name}.json").write_bytes(tree) + (args.output_dir / f"{name}.png").write_bytes(get("/screenshot")) + return json.loads(tree) + + +with (args.output_dir / "stdout.log").open("wb") as stdout, (args.output_dir / "stderr.log").open("wb") as stderr: + process = subprocess.Popen([str(args.binary.resolve())], stdout=stdout, stderr=stderr) + try: + deadline = time.monotonic() + 25 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError(f"app exited {process.returncode}; see stderr.log") + try: + if json.loads(get("/health"))["status"] == "ok": + buttons = sorted({item["handle"] for item in json.loads(get("/widgets?type=button")) + if item["callback_kind"] == 0}) + if len(buttons) == 4: + break + except OSError: + pass + time.sleep(0.1) + else: + raise RuntimeError("Geisterhand and four smoke-test buttons did not start") + + before = capture("before") + values = {item["handle"]: json.loads(get(f'/value/{item["handle"]}'))["value"] for item in before} + by_text = {value: handle for handle, value in values.items() if value is not None} + counter, raw = by_text["Count: 0"], by_text["Raw: 0"] + rows = [by_text[name] for name in ("Alpha", "Beta", "Gamma")] + increment, rotate, dispose, exit_button = buttons + click(increment) + wait_value(counter, "Count: 1") + wait_value(raw, "Raw: 1") + click(rotate) + time.sleep(0.2) + after = capture("after") + frames = {item["handle"]: item["frame"] for item in after} + # Same widget handles, now Gamma / Alpha / Beta, in AppKit's bottom-up coordinates. + assert frames[rows[2]]["y"] > frames[rows[0]]["y"] > frames[rows[1]]["y"], frames + click(dispose) + time.sleep(0.2) + wait_value(counter, "Count: 1") # disposal also sets the signal to 99 + capture("disposed") + try: + click(exit_button) + except OSError: + pass # process.exit can close the HTTP response first + assert process.wait(timeout=8) == 0 + print("PASS native text identity, button events, keyed widget order, and disposal") + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=8) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/packages/perry-solid/test/native-smoke.ts b/packages/perry-solid/test/native-smoke.ts new file mode 100644 index 0000000000..f7e8302af7 --- /dev/null +++ b/packages/perry-solid/test/native-smoke.ts @@ -0,0 +1,27 @@ +import { App, VStack, widgetAddChild, type Widget } from "perry/ui"; +import { createSignal } from "solid-js"; +import { h, render, For, type NativeNode } from "../src/index.ts"; + +const [count, setCount] = createSignal(0); +const [items, setItems] = createSignal(["Alpha", "Beta", "Gamma"]); +const body = VStack([]); +let counter: NativeNode; +let increment: NativeNode; +let rotate: NativeNode; +let list: NativeNode; +const dispose = render(() => { + counter = h("Text", { fontSize: 24 }, () => `Count: ${count()}`); + increment = h("Button", { onPress: () => setCount(n => n + 1) }, "Increment"); + rotate = h("Button", { onPress: () => setItems(rows => [rows[2], rows[0], rows[1]]) }, "Rotate"); + list = h("VStack", null, For({ + get each() { return items(); }, + children: item => h("Text", null, item), + })); + return h("VStack", { padding: 16 }, counter, increment, rotate, h("VStack", null, () => `Raw: ${count()}`), list); +}, body); +const stop = h("Button", { onPress: () => { dispose(); setCount(99); } }, "Dispose"); +// Keep the disposal control outside the mounted Solid root for the smoke test. +widgetAddChild(body, stop.handle as Widget); +const exit = h("Button", { onPress: () => process.exit(0) }, "Exit"); +widgetAddChild(body, exit.handle as Widget); +App({ title: "Solid native smoke", width: 420, height: 360, body }); diff --git a/packages/perry-solid/test/native-smoke.tsx b/packages/perry-solid/test/native-smoke.tsx new file mode 100644 index 0000000000..064076fe6c --- /dev/null +++ b/packages/perry-solid/test/native-smoke.tsx @@ -0,0 +1,17 @@ +import { App, VStack, Button, widgetAddChild } from "perry/ui"; +import { createSignal } from "solid-js"; +import { render, For } from "perry-solid"; + +const [count, setCount] = createSignal(0); +const [items, setItems] = createSignal(["Alpha", "Beta", "Gamma"]); +const body = VStack([]); +const dispose = render(() => + Count: {count()} + + + {"Raw: " + count()} + {item => {item}} +, body); +widgetAddChild(body, Button("Dispose", () => { dispose(); setCount(99); })); +widgetAddChild(body, Button("Exit", () => process.exit(0))); +App({ title: "Solid native smoke", width: 420, height: 360, body }); diff --git a/packages/perry-solid/test/renderer.test.ts b/packages/perry-solid/test/renderer.test.ts new file mode 100644 index 0000000000..952b6adcd6 --- /dev/null +++ b/packages/perry-solid/test/renderer.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { createSignal, onCleanup } from "solid-js"; +import { createNativeRenderer, For, type NativeDriver, type ElementName, type NativeNode } from "../src/renderer.ts"; + +const widgets: { kind: ElementName; children: number[]; props: Record; press: () => void }[] = []; +const operations: string[] = []; +const driver: NativeDriver = { + create(kind, press) { + widgets.push({ kind, children: [], props: {}, press }); + return widgets.length; + }, + setProperty(handle, kind, name, value) { widgets[handle - 1].props[name] = value; }, + insert(parent, child, index, previousParent) { + if (previousParent) { + const old = widgets[previousParent - 1].children; + old.splice(old.indexOf(child), 1); + } + widgets[parent - 1].children.splice(index, 0, child); + operations.push(`insert ${child} ${index}`); + }, + move(parent, from, to) { + const children = widgets[parent - 1].children; + const child = children.splice(from, 1)[0]; + children.splice(to, 0, child); + operations.push(`move ${child} ${to}`); + }, + remove(parent, child) { + const children = widgets[parent - 1].children; + children.splice(children.indexOf(child), 1); + operations.push(`remove ${child}`); + }, +}; +const renderer = createNativeRenderer(driver); +const { h } = renderer; +const root = driver.create("VStack", () => {}); +const [count, setCount] = createSignal(0); +const [items, setItems] = createSignal(["a", "b", "c"]); +const [handler, setHandler] = createSignal<() => void>(() => setCount(n => n + 1)); +let label: NativeNode; +let button: NativeNode; +let list: NativeNode; +let effects = 0; +let cleanups = 0; +const dispose = renderer.render(() => { + onCleanup(() => cleanups++); + label = h("Text", { get width() { return 100 + count(); } }, () => { + effects++; + return `Count ${count()}`; + }) as NativeNode; + button = h("Button", { get onPress() { return handler(); } }, "Increment") as NativeNode; + list = h("VStack", null, For({ + get each() { return items(); }, + children: (item: string) => h("Text", null, item), + })) as NativeNode; + return h("VStack", null, label, button, list); +}, root); + +assert.equal(widgets[label!.handle - 1].props.text, "Count 0"); +assert.equal(widgets[button!.handle - 1].props.text, "Increment"); +assert.equal(widgets.filter(w => w.kind === "Text").length, 4, "label text nodes allocate no extra widgets"); +const labelHandle = label!.handle; +widgets[button!.handle - 1].press(); +assert.equal(widgets[labelHandle - 1].props.text, "Count 1"); +assert.equal(widgets[labelHandle - 1].props.width, 101); +setHandler(() => () => setCount(n => n + 10)); +widgets[button!.handle - 1].press(); +assert.equal(widgets[labelHandle - 1].props.text, "Count 11"); +assert.equal(label!.handle, labelHandle); + +const original = [...widgets[list!.handle - 1].children]; +setItems(rows => [rows[2], rows[0], rows[1]]); +assert.deepEqual(widgets[list!.handle - 1].children, [original[2], original[0], original[1]]); +setItems(["b", "d", "c"]); +const final = widgets[list!.handle - 1].children; +assert.equal(final[0], original[1]); +assert.equal(final[2], original[2]); +assert.equal(widgets[final[1] - 1].props.text, "d"); +assert.equal(list!.children[0].parent, list!); +assert.ok(operations.some(op => op.startsWith("move "))); + +const other = renderer.createElement("VStack"); +const moved = list!.children[0]; +renderer.insertNode(other, moved); +assert.equal(moved.parent, other); +assert.deepEqual(widgets[other.handle - 1].children, [moved.handle]); +assert.ok(!widgets[list!.handle - 1].children.includes(moved.handle)); +assert.throws(() => renderer.insertNode(other, list!, list!.children[0])); +assert.throws(() => renderer.insertNode(moved, other)); +assert.throws(() => renderer.insertNode(list!, list!)); +renderer.insertNode(list!, moved, list!.children[0]); +assert.equal(widgets[list!.handle - 1].children[0], moved.handle); +assert.deepEqual(widgets[other.handle - 1].children, []); + +const beforeDispose = effects; +dispose(); +dispose(); +setCount(99); +widgets[button!.handle - 1].press(); +assert.equal(count(), 99, "disposed owners release native user callbacks"); +assert.equal(cleanups, 1); +assert.equal(effects, beforeDispose); +assert.deepEqual(widgets[root - 1].children, []); +// Perry widget handles can use NaN-boxed words. They are opaque tokens; +// numeric truthiness must never decide whether a native widget exists. +const opaqueWrites: string[] = []; +const opaque = createNativeRenderer({ + create() { return Number.NaN; }, + setProperty(_handle, _kind, name, value) { + if (name === "text") opaqueWrites.push(String(value)); + }, + insert() {}, move() {}, remove() {}, +}); +const [raw, setRaw] = createSignal("raw 0"); +let rawContainer: NativeNode; +const disposeOpaque = opaque.render(() => { + rawContainer = opaque.h("VStack", null, raw); + return rawContainer; +}, Number.NaN); +const rawNode = rawContainer!.children[0]; +assert.equal(opaqueWrites[opaqueWrites.length - 1], "raw 0"); +setRaw("raw 1"); +assert.equal(opaqueWrites[opaqueWrites.length - 1], "raw 1"); +assert.equal(rawContainer!.children[0], rawNode, "single reactive text preserves its native node"); +disposeOpaque(); +console.log("PASS Solid native renderer: signals, properties, events, keyed order, reparenting, disposal"); diff --git a/packages/perry-solid/tsconfig.json b/packages/perry-solid/tsconfig.json new file mode 100644 index 0000000000..3be8c425b0 --- /dev/null +++ b/packages/perry-solid/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "paths": { + "perry/ui": [ + "../../types/perry/ui/index.d.ts" + ], + "perry-solid/jsx-runtime": [ + "./src/jsx-runtime.ts" + ] + }, + "jsx": "preserve", + "jsxImportSource": "perry-solid" + }, + "include": [ + "src/**/*", + "test/**/*", + "examples/**/*" + ], + "exclude": [ + "test/jsx/generated.ts" + ] +} diff --git a/tests/release/packages/perry-solid/.gitignore b/tests/release/packages/perry-solid/.gitignore new file mode 100644 index 0000000000..88ff7f851c --- /dev/null +++ b/tests/release/packages/perry-solid/.gitignore @@ -0,0 +1,3 @@ +work/ +*.log +.last-skip diff --git a/tests/release/packages/perry-solid/expected-jsx.txt b/tests/release/packages/perry-solid/expected-jsx.txt new file mode 100644 index 0000000000..1f02a19fa2 --- /dev/null +++ b/tests/release/packages/perry-solid/expected-jsx.txt @@ -0,0 +1 @@ +PASS Solid JSX: native updates, components, keyed identity, conditionals, spreads, refs, fragments, disposal diff --git a/tests/release/packages/perry-solid/expected.txt b/tests/release/packages/perry-solid/expected.txt new file mode 100644 index 0000000000..0530703fca --- /dev/null +++ b/tests/release/packages/perry-solid/expected.txt @@ -0,0 +1 @@ +PASS Solid native renderer: signals, properties, events, keyed order, reparenting, disposal diff --git a/tests/release/packages/perry-solid/fixture.sh b/tests/release/packages/perry-solid/fixture.sh new file mode 100755 index 0000000000..a2ee1d826e --- /dev/null +++ b/tests/release/packages/perry-solid/fixture.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "${1:-}" == "--__did-skip-marker" ]] && exit 1 +cd "$(dirname "$0")" +source ../_fixture_lib.sh +fixture_dir="$PWD" +package_dir="$(cd ../../../../packages/perry-solid && pwd)" +mkdir -p work +cp "$package_dir/package.json" "$package_dir/package-lock.json" work/ +cp -R "$package_dir/src" "$package_dir/test" work/ +cd work +npm ci --ignore-scripts --no-audit --no-fund > install.log 2>&1 +fixture_setup perry-solid +node --conditions=browser test/renderer.test.ts > node-out.txt +diff -u "$fixture_dir/expected.txt" node-out.txt +PERRY_DISABLE_BUILD_CACHE=1 fixture_compile_run_diff perry-solid test/renderer.test.ts "$fixture_dir/expected.txt" +if ! grep -Eq 'Found [0-9]+ module\(s\): [1-9][0-9]* native, 0 JavaScript' perry-compile.log; then + echo 'FAIL perry-solid — expected every module to compile natively' + exit 1 +fi +cp perry-compile.log perry-renderer-compile.log +node test/jsx/oracle.cjs +node --conditions=browser test/jsx/generated.ts > jsx-node-out.txt +diff -u "$fixture_dir/expected-jsx.txt" jsx-node-out.txt +PERRY_DISABLE_BUILD_CACHE=1 fixture_compile_run_diff perry-solid-jsx test/jsx/main.tsx "$fixture_dir/expected-jsx.txt" +if ! grep -Eq 'Found [0-9]+ module\(s\): [1-9][0-9]* native, 0 JavaScript' perry-compile.log; then + echo 'FAIL perry-solid-jsx — expected every module to compile natively' + exit 1 +fi