This file documents every non-obvious decision, bug, and fix made during the implementation of perry-react. It exists so future sessions can pick up where this left off without re-discovering the same things.
perry-react/
src/index.ts — the entire renderer: createElement, hooks, reconciler,
element→widget mapping, createRoot
demo/
src/main.tsx — entry point: createRoot + root.render(<App />)
src/App.tsx — counter + text-input demo component
package.json — packageAliases wiring
main — compiled binary (gitignored)
package.json — perry-react package manifest
The renderer (src/index.ts) is a single file by design. Perry's native module system requires a single entry point and the whole thing compiles to one .o.
demo/package.json:
{
"perry": {
"packageAliases": {
"react": "perry-react",
"react/jsx-runtime": "perry-react",
"react-dom": "perry-react",
"react-dom/client": "perry-react"
}
}
}Perry reads this in compile.rs around line 934–937. Before codegen, the compiler substitutes the alias target wherever it sees the aliased package name. This is the same mechanism used for @prisma/client → perry-prisma etc. No source code changes needed in user components.
perry-react is declared as a native module ("nativeModule": true in its package.json). Perry adds it to the same NATIVE_MODULES list as perry/ui, which means it's compiled and linked as native Perry TypeScript, not run through V8.
Perry's parser handles .tsx natively (SWC-based). The JSX lowering is in perry/crates/perry-hir/src/lower.rs, function lower_jsx_element (~line 10209).
<div style={s}> → Expr::Call {
<h1>text</h1> callee: ExternFuncRef("jsxs"),
<p>{count}</p> args: [
</div> "div",
Expr::Object([
("style", s),
("children", Expr::Array([
Expr::Call(ExternFuncRef("jsx"), ["h1", ...]),
Expr::Call(ExternFuncRef("jsx"), ["p", ...]),
]))
])
]
}
- Single child → stored directly as
childrenvalue (not wrapped in array) - Multiple children →
Expr::Array([...])for thechildrenprop jsxfor 0–1 children,jsxsfor 2+ children (React convention)
ExternFuncRef("jsx") resolves to __wrapper_jsx at link time — the wrapper Perry generates for every exported function in a native module. The wrapper ABI is (i64 closure_ptr, f64 arg0, f64 arg1, ...) -> f64.
Every JS value is stored as a 64-bit NaN-boxed float (f64). The encoding:
Normal float: standard IEEE 754 f64 (exponent not all-1s)
Pointer: 0x7FFD_0000_0000_0000 | (ptr & 0x0000_FFFF_FFFF_FFFF)
String: 0x7FFC_0000_0000_0000 | str_ptr
Integer: 0x7FFE_0000_0000_0000 | (i32 as u32)
Undefined: 0x7FFF_8000_0000_0001
Null: 0x7FFF_8000_0000_0002
Bool true: 0x7FFF_0000_0000_0001
Bool false: 0x7FFF_0000_0000_0000
Key helpers in codegen.rs:
inline_nanbox_pointer(builder, I64_ptr) -> F64— masks lower 48 bits, ORs POINTER_TAG, bitcastsensure_i64(builder, F64_val) -> I64— strips top 16 bits via& 0x0000_FFFF_FFFF_FFFFensure_f64(builder, val) -> F64— raw bitcast I64→F64 or identity; does NOT NaN-box
ensure_i64 is safe for both properly NaN-boxed pointers and raw-bitcast subnormals because the mask strips the tag either way. This is why the array fix (Fix 5 below) doesn't break existing callers.
All changes are in perry/crates/perry-codegen/src/codegen.rs.
Problem: When compiling obj.someMethod(args), the object value was passed raw to js_native_call_method. If the object was an I64 (raw pointer), the runtime's value.is_pointer() check failed because the POINTER_TAG wasn't set.
Fix:
let obj_val = if obj_val_type == types::I64 {
inline_nanbox_pointer(builder, obj_val_raw)
} else {
obj_val_raw
};Problem: Arguments pushed into the stack slot for js_native_call_method were also not NaN-boxed. A ReactElement (I64) passed as an argument would arrive in the runtime as typeof "number" instead of typeof "object".
Fix: Same pattern per argument:
let arg_val = if arg_val_type == types::I64 {
inline_nanbox_pointer(builder, arg_val_raw)
} else {
arg_val_raw
};Problem: When assigning an I64 value to an any-typed variable (the is_union branch of Stmt::Let), the codegen did a raw bitcast I64→F64. This produces a subnormal float — a valid float but with no NaN-box tag. Any later typeof or is_pointer() check would classify it as a number.
This was the root cause of: const elemAny: any = element being needed in createRoot.render. Without the explicit : any annotation Perry would pass the I64 ReactElement struct directly through the dynamic closure call path as a subnormal.
Fix:
} else if is_union {
let val_type = builder.func.dfg.value_type(val);
if val_type == types::I64 {
inline_nanbox_pointer(builder, val) // was: raw bitcast
} else if val_type == types::I32 {
builder.ins().fcvt_from_sint(types::F64, val)
} else {
val
}Problem: All three code paths that return an array pointer from Expr::Array codegen (empty array, js_array_from_jsvalue, js_array_from_f64) used a raw bitcast:
Ok(builder.ins().bitcast(types::F64, MemFlags::new(), arr_ptr))This produces a subnormal float — no POINTER_TAG. When an array was stored as an object field (e.g. props.children = [h1, h2, p, ...]), reading it back via js_dynamic_object_get_property returned a value with typeof "number". Array.isArray would fail. _appendChildren treated the entire children array as a primitive value and tried to render it directly as text — producing the 0.0000000… display in the window.
Fix (applied at all three sites):
Ok(inline_nanbox_pointer(builder, arr_ptr)) // was: raw bitcastensure_i64 at call sites that need the raw pointer already masks away the top 16 bits (& 0x0000_FFFF_FFFF_FFFF), so this fix doesn't break any existing native FFI calls that pass arrays to functions expecting i64.
Applying inline_nanbox_pointer to ALL I64 arguments in the dynamic closure call path (~line 28292) was tried and reverted. It crashes because some I64 arguments through that path are plain integers (e.g. sig.set(sig.value + 1)) — NaN-boxing an integer as a pointer sends garbage to the native State setter. The dynamic closure call path has lost type information by the time codegen runs there, so we cannot distinguish pointers from integers. The TypeScript-level workaround (const elemAny: any = element) is used instead to trigger Fix 3 at the right locations.
This shows up in createRoot.render and is not accidental:
render: (element: ReactElement) => {
const elemAny: any = element // <-- intentional
_renderFn = () => elemAny
_idx = 0
const w = _buildWidget(elemAny)element: ReactElement is an I64 in Perry's ABI (it's a named struct type). _buildWidget expects any (F64). The dynamic closure call path for within-module calls does a raw bitcast I64→F64 for I64 arguments — producing a subnormal float. Without the explicit : any annotation, the first call to _buildWidget receives a subnormal, typeof element returns "number", and the entire component tree is replaced with the float rendered as text.
const elemAny: any = element triggers Fix 3 (the is_union branch of Stmt::Let), which NaN-boxes the I64 pointer with POINTER_TAG before storing it in the any-typed local. _renderFn = () => elemAny then captures the properly tagged F64, so re-renders in _scheduleRerender also work correctly.
The perry binary at /usr/local/bin/perry is a symlink to target/release/perry. Always rebuild release:
cd /Users/amlug/projects/perry
cargo build -p perry --release # ~50sThen recompile the demo:
cd /Users/amlug/projects/perry-react/demo
perry compile src/main.tsx -o main
./mainA debug build (cargo build -p perry) updates target/debug/perry but NOT the installed binary — a common source of "my fix didn't work" confusion in this session.
The current hook system is a global pair of arrays:
let _vals: any[] = [] // current values
let _sigs: any[] = [] // Perry State handles (one per useState slot)
let _initCount = 0
let _idx = 0 // reset to 0 before each render pass_idx is incremented once per hook call and reset to 0 before rendering. This works correctly for a single component tree where each component appears exactly once. It breaks when:
- A component function is called more than once (two
<Counter />instances share the same_valsslots) - A component is conditionally rendered (slot indices shift between renders)
The fix is a proper per-node hook store, keyed by position in the component tree (a "fiber key"). That requires a fiber tree data structure, which is Phase 2.
function _scheduleRerender(): void {
if (_rootWidget === null || _renderFn === null) { return }
widgetClearChildren(_rootWidget)
_idx = 0
const rootEl = _renderFn()
const w = _buildWidget(rootEl)
if (w !== null) { widgetAddChild(_rootWidget, w) }
}On every state change, the entire native widget tree is destroyed and rebuilt. Perry's widgetClearChildren releases the old widget handles. _buildWidget walks the new element tree and creates fresh handles. This is O(n) in the component tree size and O(n) in the number of Perry FFI calls.
For typical settings/form UIs this is imperceptible. For large lists it may be visible. Phase 2 introduces keyed reconciliation: only diff and update the changed subtrees.
Running ./demo/main:
- Window opens with correct title, width, height
h1,h2,prender with correct text and font sizesdivwithstyle={{ flexDirection: "row" }}renders asHStackbuttonrenders withonClickwiredinput[type=text]renders asTextFieldwithonChange→ synthetic eventuseStatetriggers_scheduleRerenderon setter call- Counter increments/decrements/resets correctly
- Text input updates greeting reactively
-
number.toString()in_childrenText— Perry's dynamic method dispatch for primitive number values may not correctly dispatch.toString(). UseString(n)or"" + nin component code for now. The subnormal-float display bug was a symptom of this when the children array was misclassified as a number, but even with that fixed,(someNumber).toString()may behave unexpectedly. -
Per-instance hook state — as described above.
-
useEffectdeps — not tracked; effect runs once only. -
keyprop — parsed by JSX lowering but stripped before props are passed (correct React behavior), but the reconciler doesn't use keys for ordered list diffing because it does full rebuilds.