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/4] 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/4] 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 From 5bba53f1acbfc2aee5f93ec04742af023deb4dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 10:34:25 +0200 Subject: [PATCH 3/4] feat(ui): compile Solid JSX for native rendering --- changelog.d/4644-solid-jsx.md | 1 + crates/perry-hir/src/lib.rs | 1 + crates/perry-hir/src/solid_jsx.rs | 518 ++++++++++++++ .../src/commands/compile/collect_modules.rs | 8 +- .../perry/src/commands/compile/host_config.rs | 14 + crates/perry/src/commands/compile/types.rs | 3 + crates/perry/src/main.rs | 6 +- crates/perry/tests/solid_jsx_config.rs | 179 +++++ packages/perry-solid/README.md | 52 +- packages/perry-solid/examples/counter.tsx | 21 + packages/perry-solid/package-lock.json | 635 ++++++++++++++++++ packages/perry-solid/package.json | 11 +- packages/perry-solid/src/index.ts | 1 + packages/perry-solid/src/jsx-runtime.ts | 32 + packages/perry-solid/src/renderer.ts | 9 + packages/perry-solid/test/jsx/.gitignore | 3 + packages/perry-solid/test/jsx/host.ts | 45 ++ packages/perry-solid/test/jsx/main.tsx | 91 +++ packages/perry-solid/test/jsx/oracle.cjs | 14 + packages/perry-solid/test/jsx/package.json | 13 + packages/perry-solid/test/native-smoke.tsx | 17 + packages/perry-solid/tsconfig.json | 16 +- .../packages/perry-solid/expected-jsx.txt | 1 + tests/release/packages/perry-solid/fixture.sh | 9 + 24 files changed, 1688 insertions(+), 12 deletions(-) create mode 100644 changelog.d/4644-solid-jsx.md create mode 100644 crates/perry-hir/src/solid_jsx.rs create mode 100644 crates/perry/tests/solid_jsx_config.rs create mode 100644 packages/perry-solid/examples/counter.tsx create mode 100644 packages/perry-solid/src/jsx-runtime.ts create mode 100644 packages/perry-solid/test/jsx/.gitignore create mode 100644 packages/perry-solid/test/jsx/host.ts create mode 100644 packages/perry-solid/test/jsx/main.tsx create mode 100644 packages/perry-solid/test/jsx/oracle.cjs create mode 100644 packages/perry-solid/test/jsx/package.json create mode 100644 packages/perry-solid/test/native-smoke.tsx create mode 100644 tests/release/packages/perry-solid/expected-jsx.txt diff --git a/changelog.d/4644-solid-jsx.md b/changelog.d/4644-solid-jsx.md new file mode 100644 index 0000000000..9db293fc1b --- /dev/null +++ b/changelog.d/4644-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-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/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/README.md b/packages/perry-solid/README.md index fae27497f6..7221198481 100644 --- a/packages/perry-solid/README.md +++ b/packages/perry-solid/README.md @@ -6,7 +6,7 @@ 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. +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`. @@ -66,6 +66,44 @@ perry examples/counter.ts -o 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: @@ -110,7 +148,7 @@ 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`, +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. @@ -120,6 +158,7 @@ are also exported. `perry-solid/renderer` exposes `createNativeRenderer` and its ```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 ``` @@ -128,7 +167,10 @@ 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. +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 @@ -147,6 +189,10 @@ 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 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 index 7b6a3f49d8..ee53ab72fb 100644 --- a/packages/perry-solid/package-lock.json +++ b/packages/perry-solid/package-lock.json @@ -9,7 +9,9 @@ "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" }, @@ -17,6 +19,322 @@ "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", @@ -27,6 +345,130 @@ "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", @@ -34,6 +476,161 @@ "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", @@ -89,6 +686,44 @@ "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 index 76f86c56b6..ccf5bd1064 100644 --- a/packages/perry-solid/package.json +++ b/packages/perry-solid/package.json @@ -7,7 +7,8 @@ "types": "./src/index.ts", "exports": { ".": "./src/index.ts", - "./renderer": "./src/renderer.ts" + "./renderer": "./src/renderer.ts", + "./jsx-runtime": "./src/jsx-runtime.ts" }, "files": [ "src", @@ -18,13 +19,16 @@ "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" + "typecheck": "tsc --noEmit", + "test:jsx:oracle": "node test/jsx/oracle.cjs && node --conditions=browser test/jsx/generated.ts" }, "perry": { "nativeModule": true, @@ -38,6 +42,7 @@ }, "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 index 9757460a30..78246c85ec 100644 --- a/packages/perry-solid/src/index.ts +++ b/packages/perry-solid/src/index.ts @@ -82,6 +82,7 @@ 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; 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 index a1e8cdac0f..1499f890d6 100644 --- a/packages/perry-solid/src/renderer.ts +++ b/packages/perry-solid/src/renderer.ts @@ -47,6 +47,15 @@ export function createNativeRenderer(driver: NativeDriver) { } 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}`); } 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.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/tsconfig.json b/packages/perry-solid/tsconfig.json index 9c101c5918..3be8c425b0 100644 --- a/packages/perry-solid/tsconfig.json +++ b/packages/perry-solid/tsconfig.json @@ -10,12 +10,20 @@ "paths": { "perry/ui": [ "../../types/perry/ui/index.d.ts" + ], + "perry-solid/jsx-runtime": [ + "./src/jsx-runtime.ts" ] - } + }, + "jsx": "preserve", + "jsxImportSource": "perry-solid" }, "include": [ - "src/**/*.ts", - "test/**/*.ts", - "examples/**/*.ts" + "src/**/*", + "test/**/*", + "examples/**/*" + ], + "exclude": [ + "test/jsx/generated.ts" ] } 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/fixture.sh b/tests/release/packages/perry-solid/fixture.sh index 84da2669d4..a2ee1d826e 100755 --- a/tests/release/packages/perry-solid/fixture.sh +++ b/tests/release/packages/perry-solid/fixture.sh @@ -18,3 +18,12 @@ if ! grep -Eq 'Found [0-9]+ module\(s\): [1-9][0-9]* native, 0 JavaScript' perry 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 From 87d6e78d0ce77fab44b792314969b43d5c434a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 11:05:32 +0200 Subject: [PATCH 4/4] docs: number Solid JSX changeset for PR 9865 --- changelog.d/{4644-solid-jsx.md => 9865-solid-jsx.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{4644-solid-jsx.md => 9865-solid-jsx.md} (100%) diff --git a/changelog.d/4644-solid-jsx.md b/changelog.d/9865-solid-jsx.md similarity index 100% rename from changelog.d/4644-solid-jsx.md rename to changelog.d/9865-solid-jsx.md