From 184fe24e37d4fbf681fbff54c9e250b1d7eb7b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 5 Sep 2026 21:25:27 +0200 Subject: [PATCH 1/2] feat(ui): bridge Solid universal rendering to native widgets --- Cargo.lock | 1 + changelog.d/4644-solid-native-renderer.md | 1 + crates/perry-dispatch/src/ui_table/part_a.rs | 2 +- crates/perry-ui-macos/Cargo.toml | 8 + crates/perry-ui-macos/src/widgets/mod.rs | 125 +++++------ .../tests/native_widget_order.rs | 89 ++++++++ .../src/ffi/widget_layout_extras.rs | 4 +- crates/perry-ui-windows/src/widgets/mod.rs | 24 +++ packages/perry-solid/.gitignore | 3 + packages/perry-solid/README.md | 154 ++++++++++++++ packages/perry-solid/examples/counter.ts | 24 +++ packages/perry-solid/package-lock.json | 94 +++++++++ packages/perry-solid/package.json | 43 ++++ packages/perry-solid/src/index.ts | 91 ++++++++ packages/perry-solid/src/renderer.ts | 196 ++++++++++++++++++ packages/perry-solid/test/native-smoke.py | 105 ++++++++++ packages/perry-solid/test/native-smoke.ts | 27 +++ packages/perry-solid/test/renderer.test.ts | 125 +++++++++++ packages/perry-solid/tsconfig.json | 21 ++ tests/release/packages/perry-solid/.gitignore | 3 + .../release/packages/perry-solid/expected.txt | 1 + tests/release/packages/perry-solid/fixture.sh | 20 ++ 22 files changed, 1101 insertions(+), 60 deletions(-) create mode 100644 changelog.d/4644-solid-native-renderer.md create mode 100644 crates/perry-ui-macos/tests/native_widget_order.rs create mode 100644 packages/perry-solid/.gitignore create mode 100644 packages/perry-solid/README.md create mode 100644 packages/perry-solid/examples/counter.ts create mode 100644 packages/perry-solid/package-lock.json create mode 100644 packages/perry-solid/package.json create mode 100644 packages/perry-solid/src/index.ts create mode 100644 packages/perry-solid/src/renderer.ts create mode 100644 packages/perry-solid/test/native-smoke.py create mode 100644 packages/perry-solid/test/native-smoke.ts create mode 100644 packages/perry-solid/test/renderer.test.ts create mode 100644 packages/perry-solid/tsconfig.json create mode 100644 tests/release/packages/perry-solid/.gitignore create mode 100644 tests/release/packages/perry-solid/expected.txt create mode 100755 tests/release/packages/perry-solid/fixture.sh 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/4644-solid-native-renderer.md b/changelog.d/4644-solid-native-renderer.md new file mode 100644 index 0000000000..18328f3940 --- /dev/null +++ b/changelog.d/4644-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/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-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/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..fae27497f6 --- /dev/null +++ b/packages/perry-solid/README.md @@ -0,0 +1,154 @@ +# 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; Solid JSX compilation remains a separate stage. +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 + +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`, `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 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. + +`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. + +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/package-lock.json b/packages/perry-solid/package-lock.json new file mode 100644 index 0000000000..7b6a3f49d8 --- /dev/null +++ b/packages/perry-solid/package-lock.json @@ -0,0 +1,94 @@ +{ + "name": "perry-solid", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perry-solid", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "26.4.1", + "solid-js": "1.9.15", + "typescript": "5.9.3" + }, + "peerDependencies": { + "solid-js": "^1.9.15" + } + }, + "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/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/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" + } + } +} diff --git a/packages/perry-solid/package.json b/packages/perry-solid/package.json new file mode 100644 index 0000000000..76f86c56b6 --- /dev/null +++ b/packages/perry-solid/package.json @@ -0,0 +1,43 @@ +{ + "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" + }, + "files": [ + "src", + "README.md" + ], + "license": "MIT", + "peerDependencies": { + "solid-js": "^1.9.15" + }, + "devDependencies": { + "@types/node": "26.4.1", + "solid-js": "1.9.15", + "typescript": "5.9.3" + }, + "scripts": { + "test": "node --conditions=browser test/renderer.test.ts", + "typecheck": "tsc --noEmit" + }, + "perry": { + "nativeModule": true, + "compilePackages": [ + "solid-js" + ], + "allow": { + "compilePackages": [ + "solid-js" + ] + }, + "packageAliases": { + "solid-js": "solid-js/dist/solid.js" + } + } +} diff --git a/packages/perry-solid/src/index.ts b/packages/perry-solid/src/index.ts new file mode 100644 index 0000000000..9757460a30 --- /dev/null +++ b/packages/perry-solid/src/index.ts @@ -0,0 +1,91 @@ +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 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/renderer.ts b/packages/perry-solid/src/renderer.ts new file mode 100644 index 0000000000..a1e8cdac0f --- /dev/null +++ b/packages/perry-solid/src/renderer.ts @@ -0,0 +1,196 @@ +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 { + 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/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/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..9c101c5918 --- /dev/null +++ b/packages/perry-solid/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "paths": { + "perry/ui": [ + "../../types/perry/ui/index.d.ts" + ] + } + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "examples/**/*.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.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..84da2669d4 --- /dev/null +++ b/tests/release/packages/perry-solid/fixture.sh @@ -0,0 +1,20 @@ +#!/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 From cf0c828077777080632b49fd7937f4b24beebdde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 5 Sep 2026 21:26:14 +0200 Subject: [PATCH 2/2] docs: number Solid native renderer changeset for PR 9825 --- ...644-solid-native-renderer.md => 9825-solid-native-renderer.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{4644-solid-native-renderer.md => 9825-solid-native-renderer.md} (100%) diff --git a/changelog.d/4644-solid-native-renderer.md b/changelog.d/9825-solid-native-renderer.md similarity index 100% rename from changelog.d/4644-solid-native-renderer.md rename to changelog.d/9825-solid-native-renderer.md