diff --git a/CLAUDE.md b/CLAUDE.md index 6713862ca..dad16adab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.370 +**Current Version:** 0.5.371 ## TypeScript Parity Status @@ -149,7 +149,103 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re Keep entries to 1-2 lines max. Full details in CHANGELOG.md. +- **v0.5.371** — Closes #256: spec-compliant microtask ordering for plain async functions. Pre-fix Perry's `async function` declarations compiled to direct-call functions whose body ran synchronously to completion, with each `await` lowered to a busy-wait poll loop. Diverges from spec: an `await` should always yield to the microtask queue, even on already-resolved Promises, so synchronous code following an unawaited async call runs before the awaited body's continuation. The reproducer from the issue (`async function main() { await inner(); }; main(); console.log("top")`) printed `main → inner → main-2 → top` instead of Node's `main → inner → top → main-2`. **Fix is a CPS / state-machine transform that mirrors the existing `function*` generator transform**: (1) new `crates/perry-transform/src/async_to_generator.rs` pre-pass rewrites `Expr::Await(x)` → `Expr::Yield { value: Some(x) }` in plain async function bodies, hoists non-top-level awaits (e.g. `console.log("x: " + await y)`) into fresh Lets so every Await ends up in a position the generator transform's `linearize_body` can split states at, and flips the function flags `is_async=false, is_generator=true, was_plain_async=true`. (2) Existing `transform_generator_function` in `crates/perry-transform/src/generator.rs` reads the new `was_plain_async` flag and wraps the resulting `{next, return, throw}` iterator in an inline async-step driver: `let __iter = iter_obj; let __step; __step = (v, isErr) => { try { r = isErr ? __iter.throw(v) : __iter.next(v); } catch (e) { return Promise.reject(e); } if (r.done) return Promise.resolve(r.value); return Promise.resolve(r.value).then(v => __step(v, false), e => __step(e, true)); }; return __step(undefined, false);`. The two-step `let __step; __step = ...;` pattern is required because Perry's closure-capture analysis silently produces NaN for the `const f = (...)=>f(...)` form. (3) Generator transform's `linearize_body` Try-arm guard widened from `body_contains_yield(body)` to also fire when finally has yields — `await using` desugars to `try { body } finally { await dispose() }` and pre-fix the yield-in-finally hit codegen's `Expr::Yield => double_literal(0.0)` arm and got fire-and-forgotten. (4) Generator transform's hoisted-Let rewrite extended to recursively descend into nested control-flow (For init/body, While body, If branches, Try, Switch, Labeled) — without this a for-of loop inside a state body keeps its inner `let v = arr[i]` as a Let that creates a shadow slot hiding the outer captured box; manifested as `for (const v of arr) sum += v` returning `sum=0` inside transformed async functions (test_issue_233 case 5). (5) Both `compute_max_local_id` (in `async_to_generator.rs` and `generator.rs`) extended to scan class member bodies — the v0.5.323 issue #212 capture rewrite allocates method-local fresh ids per class method per captured outer local, and without scanning them, the generator transform's freshly-allocated state/done/sent/wrapper ids could collide and corrupt unrelated class-method codegen. (6) `crates/perry-runtime/src/promise.rs::js_promise_run_microtasks` now unwraps thenables when the .then callback returns a Promise — pre-fix the runtime stored the Promise pointer directly as the chained promise's value, so the async-step driver's recursive `step()` returns produced `Promise>` chains that never unwrapped to the real value (`await fn()` returned `Promise { }` for any async function with multiple awaits). **Conservative scope**: my pre-pass skips the rewrite ENTIRELY if the module has classes with `__perry_cap_*` instance fields (the v0.5.323 capture rewrite marker), because the async-step driver's fresh LocalId allocations can collide with the v0.5.323 method-local rebind ids. Per-function it also skips if the body contains a nested closure with non-empty captures (forEach/map/filter pattern) — both are tracked separately as follow-up work to fully fix the closure-capture-inside-async-state-machine class of bugs. New regression `test-files/test_issue_256_microtask_ordering.ts` covers the issue's exact reproducer + matches `node --experimental-strip-types` byte-for-byte. **Test results**: gap tests 25/28 (= main baseline; same 3 documented failures `array_methods` / `console_methods` / `typed_arrays`); 13 prototype regression tests all pass — `test_async`, `test_async2-5`, `test_async_chain`, `test_issue_154_using_dispose`, `test_issue_212_class_method_capture`, `test_issue_233_async_array_param_push`, `test_issue_235_method_default_param_padding`, `test_issue_236_fetch_then_console_log`, `test_issue_240_interface_dispatch`, `test_issue_167_loop_alloca_stack_eat`. The 3 prototype blockers (LLVM constant-folding mystery, linearize_body Try-arm finally-side yields, class-method capture interaction) are sidestepped by the all-HIR design (no new runtime helper exposes the LLVM bug) + the linearize_body Try fix + the conservative-scope skip respectively. - **v0.5.370** — Closes #255: perry-jsruntime no longer panics with `RefCell already borrowed` (then `active scope can't be dropped`) when a Perry closure passed to a JS-imported function (via `JsCreateCallback`, landed in #248 Phase 2B / v0.5.369) reads a property from a JS object passed in as a callback argument. The user's exact `gp.use((ctx) => log(ctx.deltaTime))` shape from issue #248 — which had been working around `gp.use(() => counter++)` only — now runs end-to-end. **Two cascading fixes**: (1) `with_runtime` is now re-entrancy-safe via a thread-local `REENTRY_PTR: Cell<*mut JsRuntimeState>` that the outer `with_runtime` body stashes on entry. Inner re-entrant calls (V8 trampoline → Perry callback → `js_get_property` → `with_runtime`) detect the stash and reuse the outer's `&mut JsRuntimeState` instead of trying to acquire a second `RefCell::borrow_mut`. A Drop guard clears the pointer on normal return AND on panic-unwind. `ensure_runtime_initialized` short-circuits when the stash is non-null (re-entrant code can't initialize a runtime that's already initialized). (2) But that alone surfaced a SECOND panic from V8's scope tracker: `state.runtime.handle_scope()` inside the re-entrant FFI conflicts with the trampoline's own active scope (V8 internally has a scope stack and rejects out-of-order Drops). **Fix**: the trampoline now stashes its `&mut HandleScope` in a second thread-local `REENTRY_SCOPE_PTR`, and `js_handle_object_get_property` checks for it and reuses the trampoline's existing scope instead of calling `state.runtime.handle_scope()`. Public helpers `stash_trampoline_scope()` (returns a `TrampolineScopeGuard` for LIFO Drop) and `try_trampoline_scope()` (unsafe — returns `Option<&'a mut HandleScope<'a>>`, lifetime is the caller's responsibility, valid only while the trampoline frame is on the stack). The body of `js_handle_object_get_property` factored into `get_property_with_scope(scope, ...)` so both the normal path (creates a new scope) and the trampoline-reuse path share the same logic. **Scope of the fix**: `js_handle_object_get_property` is the single FFI most commonly hit from inside callbacks (any `obj.field` read on a JS-supplied arg). Other re-entrant FFIs (`js_set_property`, `js_call_method` nested, `js_handle_array_get`, `js_handle_array_length`) use the same `state.runtime.handle_scope()` pattern and remain panic-prone if called from inside callbacks — refactoring them to use `try_trampoline_scope()` is mechanical (extract body into `_with_scope` helper, add the trampoline-stash branch) and tracked as a follow-up. **Safety analysis**: the raw-pointer escape hatch is the standard callback-driven re-entrancy pattern. The outer `&mut state` reference is paused on the call stack while V8 → trampoline → Perry callback → inner FFI runs; the inner reference is only live during that window; they never alias in time. Strictly UB by Rust's aliasing rules, but it's what every callback-driven library that holds state ends up doing in some form (deno_core uses an OpState handoff at await points instead, but our synchronous re-entry case can't suspend). Documented as `unsafe fn try_trampoline_scope`. New regression test `test-files/test_issue_255_jsruntime_reentrancy.ts` + fixture `test-files/fixtures/issue_255_jsmod.js` exercises: single property read inside callback (the user's #248 repro), multi-property + nested object property read (`ev.target.id` exercises 2-level re-entrancy), same callback fired twice (verifies REENTRY_SCOPE_PTR stash/restore guard), captured-variable mutation + property read combined. All print correctly; pre-fix the first case panicked. **Verified**: cargo build --release -p perry-runtime -p perry-stdlib -p perry-jsruntime -p perry clean; cargo test --release -p perry-jsruntime --lib 4/0; gap tests 25/28 = baseline; #248 Phase 2B regression test (test_issue_248_phase2b_js_callback.ts) still passes (`call0 counter: 1`, `call1 received: 42`, `call2 sum: 30`, `call3 sum: 6`, `callTwice count: 2`). - **v0.5.369** — Closes #248 (Phase 2B): wire `JsCreateCallback` end-to-end so Perry closures passed to JS-imported functions (`arr.forEach(cb)`, `gameLoop.use(handler)`, etc.) actually fire. Phase 2A (v0.5.368) bailed at codegen for this variant because the runtime FFI `js_create_callback(func_ptr, closure_env, param_count)` expects `func_ptr` to have signature `(closure_env: i64, args_ptr: *const f64, args_len: i64) -> f64` (the `native_callback_trampoline` contract at `crates/perry-jsruntime/src/interop.rs:993`), but Perry closure bodies have `(closure_ptr, arg0, arg1, ...)` per arity — no direct call-compatible mapping. **Phase 2B bridge**: new runtime helper `js_closure_call_array(closure_env, args_ptr, args_len) -> f64` in `crates/perry-runtime/src/closure.rs` mirrors the existing `js_native_call_value` dispatch but takes `closure_env: i64` (raw `*const ClosureHeader`) as its first arg instead of an f64 NaN-boxed value, so the SysV-x64 / Win64 first-arg register lands in the integer slot (rdi/rcx) rather than the float slot (xmm0) — matching the trampoline's `extern "C" fn(i64, *const f64, i64) -> f64` expectation. Body switches on `args_len` and calls the right `js_closure_callN` (0..16). **JsCreateCallback codegen** in `crates/perry-codegen/src/expr.rs` now lowers to: lower the closure expression, unbox to i64 closure pointer, `ptrtoint @js_closure_call_array to i64` for the trampoline target, call `js_create_callback(func_addr, closure_ptr, param_count)`. Result is a NaN-boxed JS handle (V8-handle tag 0x7FFB) that JS code can call like any other JS function. **Closure-collector update** in `crates/perry-codegen/src/collectors.rs`: 8 new arms for the `Js*` HIR family (JsLoadModule, JsGetExport, JsCallFunction, JsCallMethod, JsGetProperty, JsSetProperty, JsNew, JsNewFromHandle, JsCreateCallback) so closures nested inside JS-interop expressions don't fall through the `_ => {}` catch-all and end up referenced via `js_closure_alloc(@perry_closure_*)` with their bodies never defined (clang error: "use of undefined value" — same class of bug as Tier 1.1). **Int32 unbox at the dispatch boundary**: V8 NaN-boxes JS integers with `INT32_TAG=0x7FFE` (perry-jsruntime's `bridge.rs:215`); Perry's closure-body arithmetic uses raw `fadd` / `fsub` / `fmul` on f64 inputs and assumes plain doubles, not tagged values. Pre-fix `cb(10, 20)` from JS into a Perry closure `(a, b) => a + b` returned 10 instead of 30 because `fadd(10.0, NaN-boxed-int32-20)` produces a NaN whose payload `console.log`'s tag-aware unbox decoded as 10 (one of the operands' int32). The dispatch helper now unboxes any INT32_TAG-tagged f64 to a plain double before passing to `js_closure_callN`. New regression test `test-files/test_issue_248_phase2b_js_callback.ts` + fixture `test-files/fixtures/issue_248_phase2b_jsmod.js` exercises 0/1/2/3-arg callbacks with mutable captures plus same-callback-fired-twice (using a method-call dispatcher object so `js_call_method` forces perry-jsruntime to win Mach-O link resolution and V8 actually runs end-to-end). All 5 cases now print correctly: `call0 counter: 1`, `call1 received: 42`, `call2 sum: 30`, `call3 sum: 6`, `callTwice count: 2`. The user's exact `gp.use((ctx) => {...})` shape from `/tmp/issue248/test_callback_simple.ts` also now compiles + runs end-to-end (V8 fires the closure, capture mutations propagate back to outer Perry scope, `done, final counter = 1`). **Known follow-up**: callbacks that read fields from JS-supplied object args (`ctx.deltaTime` inside `gp.use((ctx) => log(ctx.deltaTime))`) trigger a pre-existing perry-jsruntime re-entrancy panic (`RefCell already borrowed` at `crates/perry-jsruntime/src/lib.rs:123`) — V8's trampoline holds the `JsRuntimeState` borrow while invoking the Perry callback, and the callback's `js_get_property` re-enters `with_runtime` on the same borrow. Orthogonal to #248's codegen scope. **Verified**: cargo build --release -p perry-runtime -p perry-stdlib -p perry-jsruntime -p perry clean (note: rebuilding perry-jsruntime is necessary after a perry-runtime change because Cargo bundles the perry-runtime CGU inside `libperry_jsruntime.a` for link-time symbol resolution; without the rebuild the bundled CGU has the OLD source); cargo test --release -p perry-codegen --lib 22/0; gap tests 25/28 = baseline (the 3 failing — `array_methods` / `console_methods` / `typed_arrays` — pre-existing categorical/CI gaps unrelated to this change). - **v0.5.368** — Closes #248: codegen for `arr.push(...src)` and the V8 / perry-jsruntime interop expression family. **Phase 1 (ArrayPushSpread)**: `arr.push(...src)` was rejected by the LLVM backend with `expression ArrayPushSpread not yet supported`. HIR has lowered the variant since v0.5.x (`crates/perry-hir/src/lower/expr_call.rs:2077`); only the codegen arm in `crates/perry-codegen/src/expr.rs` was missing — WASM (`crates/perry-codegen-wasm/src/emit.rs:5441`) and analysis helpers (`collectors.rs:1218`, `walker.rs:786`, `analysis.rs:34`) all knew about it. Fix is a single new arm mirroring `Expr::ArrayPush` at line 3016 (same three receiver storage cases — LocalGet / boxed-captured / plain — and the same realloc-aware writeback). No new runtime helper needed: `js_array_concat(dst, src)` already existed at `crates/perry-runtime/src/array.rs:1011`, comment-as-spec'd "reserved for the internal push-spread desugaring path"; Set sources work transparently via the SET_REGISTRY check inside `js_array_concat`. **Phase 2 (V8 interop, 8 new arms)**: the LLVM backend bailed for **JsLoadModule, JsGetExport, JsCallFunction, JsCallMethod, JsGetProperty, JsSetProperty, JsNew, JsNewFromHandle** — the HIR family `perry-hir/src/js_transform.rs::transform_js_imports` produces whenever a `.ts` entry imports from a `.js` module the resolver classifies as JS-runtime-loaded (`crates/perry/src/commands/compile/collect_modules.rs:73`, extension-driven). Pre-fix the user's `bun i @codehz/pipeline` repro bombed at codegen with `JsCallFunction not yet supported`. New arms call into the existing perry-jsruntime FFI surface: `js_load_module(path_ptr, path_len) -> u64`, `js_get_export/get_property/set_property/call_function/call_method/new_instance/new_from_handle` etc. — all eight already declared in `runtime_decls.rs` except `js_call_method` (added here at line 1631 with signature `DOUBLE, &[DOUBLE, I64, I64, I64, I64]`). New shared helper `lower_js_args_array(ctx, lowered_args) -> (ptr, len)` marshals already-lowered NaN-boxed args into a stack alloca'd `[N x double]` via the issue-#167 `alloca_entry_array` pattern (hoisted to function entry block); empty input returns `("null", "0")` for the FFI's null-pointer fallback. **Module handle representation**: V8 module ids are u64; codegen returns them as f64 via `bitcast_i64_to_double` to fit `lower_expr`'s return-type contract, then consumers bitcast back to i64 before passing to the runtime. **JS value handles** are NaN-boxed f64 with V8-handle tag 0x7FFB — handled internally by perry-jsruntime's `v8_to_native` / `native_to_v8` helpers; codegen treats them as opaque doubles. **Runtime bootstrap**: new `needs_js_runtime: bool` field on `CrossModuleCtx` (threaded from `CompileOptions::needs_js_runtime`, originally set in `collect_modules.rs:105` when any `.js` module enters `ctx.js_modules`), wired into `compile_module_entry` so the entry main's prelude calls `js_runtime_init()` between `js_gc_init` and user code. Without this, every `js_load_module` site bailed at the runtime with `[js_load_module] no JS runtime state!`. **`JsCreateCallback` deliberately deferred** to Phase 2B: the runtime FFI `js_create_callback(func_ptr, closure_env, param_count)` expects `func_ptr` to have signature `(closure_env: i64, args_ptr: *const f64, args_len: i64) -> f64` (see the `native_callback_trampoline` in `crates/perry-jsruntime/src/interop.rs:993`), but Perry closure bodies have `(closure_ptr, arg0, arg1, ...)` per arity — there's no direct call-compatible mapping. Wiring this needs either codegen-emitted per-arity adapter thunks or a runtime-side closure-array dispatcher; for now the arm bails with a clear message so users see exactly what's blocked. The user's exact `pipeline()` repro at `/tmp/issue248/test.ts` now compiles + links + runs (exit 0). **Regression tests**: `test-files/test_issue_248_array_push_spread.ts` (10 cases — number/string/object arrays, empty src/dst, array-literal spread, chained push-spread, post-spread `.indexOf` + `.length`, push-spread inside loop forcing realloc past the 16-cap, mixed `push` + `push-spread` — all byte-for-byte against `node --experimental-strip-types`), plus `test-files/test_issue_248_phase2_js_interop.ts` + fixture `test-files/fixtures/issue_248_jsmod.js` exercising JsLoadModule + JsCallFunction (compile + link + clean exit). **Verified**: cargo build --release -p perry-runtime -p perry-stdlib -p perry-jsruntime -p perry clean; cargo test --release -p perry-codegen --lib 22/0; gap tests 25/28 = baseline. Bumped 0.5.366 → 0.5.368 above origin's parallel-track 0.5.366 (HarmonyOS SDK fix #250) + 0.5.367 (HarmonyOS HAP bundler #252) per the merge-collision precedent. PR #251. - **v0.5.369** — HarmonyOS PR B.4 + B.5 + B.6 squash-equivalent: cherry-picks `3042563a` + `b01653f6` + `41d597c0` (originally v0.5.127 / v0.5.128 / v0.5.129) from the `harmony-os` branch — the audit-driven fixes the original branch made AFTER its own first emulator run. End-to-end `hdc install` is now achievable (modulo cert + bundle-name match). **B.5 (v0.5.128) — DevEco 6.x SDK + ets-loader replacement**: most of B.5's compile.rs work already on main via the v0.5.366 fast-follow (DevEco app-bundle SDK probe + macOS framework leak fix); cherry-pick fold-in here is just the NEW hunks: (a) extends the `is_harmonyos` linker arm in `compile/link.rs` with OHOS runtime libs `-Wl,--allow-multiple-definition -lm -lpthread -ldl -lace_napi.z`. `libace_napi.z.so` is what ArkTS exposes for `napi_module_register` / `napi_create_*` (consumed by `perry-runtime/src/ohos_napi.rs`); OHOS naming convention is `.z.so` and `-l` strips `lib`+`.so` but NOT the middle `.z`, so `-lace_napi.z` is the deliberate spelling. (b) Skip BSD strip on harmonyos targets — macOS strip emits a noisy `non-object and non-archive file` warning on ELF binaries. (c) `crates/perry/src/commands/harmonyos_hap.rs` rewritten to skip the ets-loader Node/rollup pipeline entirely and shell out to `es2abc --extension ts --module --merge-abc` directly — the harmony-os branch found ets-loader needs ~15 env vars (aceModuleRoot, aceModuleBuild, aceModuleJsonPath, aceProfilePath, compileMode=moduleJson, plus a full DevEco build-profile.json5); synthesizing all of that is effectively re-implementing hvigor. The Phase-1 ArkTS shim is plain TypeScript (no `@Entry`/`@Component`/`struct` decorators yet) so es2abc accepts it via the `--extension ts` flag. HAPs now ship a single merged `ets/modules.abc` instead of per-file .abc. PR C reintroduces ets-loader once the TS→ArkUI emitter produces real ArkUI decorators. (d) `EntryAbility.ets` no longer imports `@ohos.window` or has `onWindowStageCreate` with `windowStage.loadContent('pages/Index')`; window stays blank but `console.log` reaches hilog — enough to validate Phase 1's goal of "cross-compile → NAPI bind → TS main() executes". `module.json5` drops `pages: "$profile:main_pages"`, `main_pages.json` no longer emitted, `resources/base/profile/` no longer created. **B.6 (v0.5.129) — native-object pickup**: `compile/link.rs` walks `target///release/build/*/out/` for loose `.o` files emitted by `cc-rs` build scripts (notably `libmimalloc-sys`, which produces a 362-KB `-static.o` containing 154 mi_* symbols). Rust's staticlib normally bundles these into `libperry_runtime.a`, but on macOS→OHOS cross-builds the `libmimalloc.a` wrapper comes out as a zero-member BSD-format archive (BSD ar's `__.SYMDEF SORTED` layout — macOS-host `ar` creates it, llvm-ar can't read it back) and rustc's "bundle native libs into staticlib" silently skips it. Without forwarding the loose `.o` files to the final link, `libentry.so` ends up with `mi_malloc_aligned` marked UND, and the OHOS dynamic linker rejects dlopen at `EntryAbility.onCreate` with "symbol not found." Walked-pickup is coarser than Rust's per-crate link-lib directive walking (picks up `.o` from any transitive C dep, not just mimalloc), but mimalloc is the only C dep in perry-runtime's closure today and unreferenced ones are dead-stripped via the existing `--gc-sections`. **B.4 (v0.5.127) — earlier audit fixes** mostly bundle into B.5/B.6 above (`-appCertFile` vs `-profileFile` distinction in the hap-sign CLI invocation, `developtools_hapsigner` README pointers in code comments). **Cherry-pick fold-in**: 3 cherry-picks across 3 commits required Cargo.toml + CLAUDE.md conflict resolution per commit (mechanical). compile.rs conflicts taken-as-ours each time and the meaningful new hunks (linker libs, native-object pickup) hand-applied to their current homes in `compile/link.rs` since main has refactored that code out of compile.rs. The harmonyos_hap.rs es2abc rewrite + EntryAbility.ets simplification auto-merged cleanly. Bumped 0.5.129 → **0.5.369** (above main's current 0.5.368 from PR #251). +- **v0.5.362** — Closes #240: cross-module interface dispatch silently dropped to `undefined` when the consumer module type-imported the interface. Type-only imports are stripped at HIR lowering (`crates/perry-hir/src/lower.rs:2777`), so the interface's source module never enters `ctx.native_modules` and the consumer's `imported_classes` list ended up without the implementer. The dispatch tower at `crates/perry-codegen/src/lower_call.rs::needs_dynamic_dispatch` then found zero implementors of the called method, skipped its tower, and fell through to a generic property-get-then-closure-call that resolved `obj.method` to `undefined` (methods aren't stored as fields on instances; they're per-class symbols `_perry_method_____`). The user's bisection table pinned it to "≥4 files where the consumer is in its own module and only type-imports the interface". Fix in `crates/perry/src/commands/compile.rs`: new polymorphic-receiver augmentation pass after the existing import walk that scans every function/method/field type in the consumer's HIR for `Named(X)` references whose X doesn't resolve to any class/interface/enum/type-alias in the program HIR (`all_program_type_names` built once across `ctx.native_modules`) and isn't in a curated TS/runtime builtin set (Promise, Array, Map, Set, Date, Buffer, the typed arrays, the utility-type family, etc.). When such an unresolved name is found, the consumer pulls every program-wide exported class into `imported_classes` (existing dedup keeps it idempotent); the dispatch tower at the call site then filters per-method-name so IR size stays bounded. Without `implements`-clause tracking we can't be more surgical, but the conservative "pull everything" matches the existing precedent for namespace imports. New regression test `test-files/test_issue_240_interface_dispatch.ts` covers in-file dispatch shape; the cross-module repro is verified end-to-end in `/tmp/issue240` (4-file mongodb-style + sync variants both flip from `consume: m typeof=undefined` → `[Impl.greet] CALLED with world / consume: m typeof=string`). Pre-fix workload's `.o` had `_js_object_get_field_by_name_f64` + `_js_closure_call1` (closure-call fallback), post-fix it references `_perry_method_impl_ts__Impl__greet` directly via the dispatch tower. Gap tests 25/28 = baseline; 12-test regression set runs identically pre/post-fix. +- **v0.5.361** — Issue #185 Phase D step 3: live screenshot preview in the inspector UI. The screenshot infrastructure already existed end-to-end across every platform (`perry_ui_screenshot_capture` is registered on macOS / iOS / tvOS / GTK4 / Windows / Android, the `GET /screenshot` server route already returns a PNG, the runtime has the condvar-backed cross-thread marshaling at `geisterhand_registry.rs::CaptureScreenshot`); what was missing was the inspector consuming it. **Layout change**: `crates/perry-ui-geisterhand/src/inspector_ui/index.html` switches from 2-column (`tree | detail`) to 3-column (`tree 280px | preview 1fr | detail 340px`). New `#preview` section has a top toolbar (`capture` button + `auto` checkbox + status line showing dimensions + capture timestamp) and a `#preview-frame` div with checkered transparency-grid background that holds the live ``. The `capture` handler `fetch("/screenshot", { cache: "no-store" })`, blobs the response, swaps `URL.createObjectURL`'d image element into the frame, revokes the previous URL to avoid memory leaks, and updates the status line. **Selection overlay**: clicking a widget in the tree pane now also draws a translucent-bordered box on top of the preview at the widget's `frame.{x,y,w,h}` coordinates. The overlay positions itself in flex-container coordinates (handles letterboxing when the screenshot's aspect ratio doesn't match the preview frame), uses `getBoundingClientRect()` to compute the rendered img's offset, and resizes via a `window.resize` listener. **Auto-refresh checkbox** polls every 1.5s (matches the tree-refresh cadence) — off by default since screenshot capture isn't free. Status line shows e.g. `800×664 · 15:42:08`. **Verified**: `cargo build --release -p perry-ui-geisterhand` clean (1 pre-existing dead-code warning unchanged); end-to-end smoke `perry compile X.ts --enable-geisterhand && curl -o /tmp/x.png /screenshot` produces an 800×664 PNG; the inspector at `localhost:7676/` shows the screenshot inline + lets the user click any widget in the left tree to highlight its bounds on the preview. Auto-built `target/geisterhand/release/libperry_ui_geisterhand.a` (looked up first by `find_geisterhand_lib`'s search order, before `target/release/`) was overwritten with the freshly-built variant so the new HTML lands. **Phase D fully delivered**: step 1 inspector page (v0.5.349) + step 2 live style edit (v0.5.350 macOS, v0.5.360 iOS/tvOS/GTK4/Windows) + step 3 live screenshot preview here. Issue #185's three explicit asks (rich styling primitives, cross-platform parity, devtools) are now all fully shipped end-to-end. Remaining open is #230 (Win32 DirectComposition shadow — the last `Stub` in the styling matrix), Android `apply_style` dispatcher, visionOS full geisterhand registration block, and the unrelated pre-existing `ui/gallery.ts` retina baseline mismatch. +- **v0.5.360** — Issue #185 Phase D step 2 platform sweep: extends the live `apply_style` dispatcher from macOS-only (v0.5.350) to **iOS + tvOS + GTK4 + Windows**. Each platform gets a new `crates/perry-ui-/src/geisterhand_style.rs` (gated on `feature = "geisterhand"`) that switches on `prop_id` and routes to the per-platform setter (UIKit calls the `crate::perry_ui_widget_set_*` FFI exports directly so iOS/tvOS share one identical file; GTK4 uses `widgets::set_*` helpers; Windows uses `widgets::set_insets` for the padding row since the helper name diverged from the macOS / GTK4 `set_edge_insets` pattern). Each platform's `app.rs` `#[cfg(feature = "geisterhand")]` registration block now imports `perry_geisterhand_register_apply_style` and calls it alongside the existing `state_set` / `screenshot_capture` / `textfield_set_string` registrations. Inspector users on Linux + Windows + iOS + tvOS sim now see live style edits apply when their app is built with the matching platform's `geisterhand` Cargo feature on. visionOS deferred — its `app.rs` doesn't currently have the geisterhand fn-pointer registration block at all (only the pump call), so adding `apply_style` registration alone wouldn't be useful; it's a separate sweep to wire the full registration surface. Android also deferred — JNI marshaling for the dispatcher signature has its own caveats (function-pointer-via-JNI vs. via Rust trampoline) that warrant a focused follow-up. **Verified**: `cargo build --release -p perry-ui-{macos,ios,tvos,gtk4} --features geisterhand` (with `--target aarch64-apple-{ios-sim,tvos-sim}` for the sim crates) all clean; macOS smoke-test confirms the existing `POST /style/:h` flow still produces `{"ok":true,"applied":["backgroundColor","opacity"]}`. Windows host build doesn't change (cross-compile from macOS still hits the pre-existing 4 host-build errors in `create_font_with_family`'s missing `#[cfg(target_os="windows")]` — separate latent bug, unrelated). Workspace tests still 173/0 perry-runtime, 4/0 perry-ui. +- **v0.5.359** — Merges PR #238 (closes #234 — TheHypnoo): real `Blob` with arrayBuffer/text/bytes/slice instance methods. Pre-fix `await response.blob()` returned a metadata-only stub `{size, type}` and silently dropped `resp.body`. Three-part fix mirroring #232 / #227: (1) `crates/perry-stdlib/src/fetch.rs` adds `BlobData` + `BLOB_REGISTRY` + `alloc_blob` (mirrors `HEADERS_REGISTRY` / `alloc_headers`); `js_response_blob` rewritten to clone body bytes + content-type into a fresh `BlobData` and resolve with the numeric handle. 6 new FFIs: `js_blob_size`, `js_blob_type` (returns `*StringHeader`), `js_blob_array_buffer` (real `BufferHeader` via `buffer_alloc` — same shape as `js_response_array_buffer`), `js_blob_bytes` (alias), `js_blob_text` (UTF-8 lossy decode = WHATWG replacement-character semantics), `js_blob_slice` with `f64::NAN` sentinels for missing numeric args, null `type_ptr` to inherit, negative-index normalisation per spec. (2) `crates/perry-hir/src/destructuring.rs` two new `lower_var_decl` arms next to the Response.clone() propagation: `const blob = await .blob()` (Response→Blob) and `const b2 = .slice(...)` (Blob→Blob for chained slicing). (3) `crates/perry-codegen/src/lower_call.rs` + `runtime_decls.rs` new `if module == "blob"` arm with size/type/arrayBuffer/bytes/text/slice dispatch. New regression `test-files/test_issue_234_blob_methods.ts` (11 cases — size, text, arrayBuffer + Uint8Array roundtrip, bytes alias, multi-byte UTF-8 + Buffer.from roundtrip, slice with various arg shapes, empty body, negative indices) — all byte-for-byte against `node --experimental-strip-types`. Added to `SKIP_TESTS` in `.github/workflows/test.yml` (compile-smoke step) and `test-parity/known_failures.json` as `ci-env` — same documented macOS-14 SDK/linker pattern as `test_gap_buffer_ops` / `test_buffer_small_alloc` / `test_issue_227_array_buffer_bytes` / `test_gap_fetch_response`. **Maintainer fold-in (v0.5.359 vs PR's 0.5.356 base)**: bumped version above origin's parallel-track v0.5.357 (#233) + v0.5.358 (#235); plus two correctness/cleanup fixes on top of TheHypnoo's PR commits — (a) `js_blob_slice`'s `type_ptr.is_null()` branch now defaults to `String::new()` instead of inheriting the original blob's content-type. Per WHATWG Blob spec / MDN: when `contentType` is absent, the new blob's type is the empty string. Caught by edge-case audit (`new Blob([...], {type: "image/png"}).slice(0, 1).type` is `""` in Node, was `"image/png"` in the original PR); same fix to the inner `string_from_header(...).unwrap_or(...)` decode-failure fallback. (b) Collapsed the duplicate comment block in `lower_call.rs::"slice"` arm (lines 3188-3194 of the original PR restated the same NaN-sentinel rationale twice). `blob.stream()` deferred to #237 (Web Streams API needed project-wide before scoping a Blob-only stub). Verified: `cargo build --release -p perry-runtime -p perry-stdlib -p perry-codegen -p perry` clean; PR's regression matches Node byte-for-byte after the slice-type fix; gap tests 25/28 = baseline; #227 regression still passes. +- **v0.5.358** — Closes #235: `await Foo.method({...})` could silently hang the async chain when the method had a default param the caller skipped. Two contributing parts both compiled fine but interacted to read garbage at the callee. (1) The cross-module method DECLARE at `crates/perry-codegen/src/codegen.rs` was hardcoded to 6 doubles ("safe upper bound" — see the original comment block), but the call site only passed `args.len() + 1`. The Win64 / SysV ABI placed `recv + user-args` in d0..dN and left dN+1..d5 holding whatever the prior call's return-state had stamped there — typically a real heap pointer from the just-finished `MongoClient.connect()` chain. The callee's `findOne(filter, options)` then read `options` from d2 and dereferenced `options.session` against that stale pointer, which silently hung in `js_native_call_method` rather than throwing. The hang was code-shape sensitive because removing/adding any sync stmt before the await reshuffled which prior-call's leftover ended up in d2. (2) `crates/perry-hir/src/lower_decl.rs::lower_class_method` had no `build_default_param_stmts` call — the `if (param === undefined) param = default;` desugaring fired only for free functions and constructors, never for instance/static class methods. So even when a TS author wrote `findOne(filter, options = {})`, the body just did `options.session` directly with no default substitution. **Fix is in three places**: (a) `ImportedClass` gets a parallel `method_param_counts: Vec` populated from `class.methods.iter().map(|m| m.params.len())` at the 4 import-resolve sites in `crates/perry/src/commands/compile.rs` + 2 test sites in `compile/object_cache.rs`; the cross-module method declare at codegen.rs:980 now reads this Vec and emits `arity + 1` doubles instead of 6. (b) New `method_param_counts: HashMap<(class, method), usize>` field on `CrossModuleCtx` + `FnCtx`, built once in `compile_module` from BOTH `hir.classes` AND `opts.imported_classes` — covers local + cross-module dispatch uniformly. (c) Both dispatch tower sites in `lower_call.rs` (the dynamic-dispatch tower at line 1173 + the static-dispatch + virtual-override site at line 1374) now look up the implementor's max arity (across all overrides for the static case) and pad `lowered_args` with TAG_UNDEFINED to `max_arity + 1` total. The ncm fallback path inside the dynamic-dispatch tower changes `iter().skip(1).enumerate()` → `iter().skip(1).take(n).enumerate()` so the padding entries don't overflow the n-sized buffer. (d) `lower_class_method` prepends the same `build_default_param_stmts` block already used by free functions and constructors — without this, even the now-correct TAG_UNDEFINED arrival in the callee left the body reading `param == undefined` instead of the declared default. Side effect of the cache-key derivation: `compute_object_cache_key` includes the new `method_param_counts` in the hash so changes to a method's arity invalidate consumers. Verified end-to-end against the issue's exact mongodb repro from `/Users/amlug/projects/mango`: pre-fix output stopped at `1 findOne start` and hung indefinitely (sometimes SIGSEGV, sometimes silent exit 0); post-fix output is `A connecting / 1 findOne start / 2 findOne done: doc / 3 closed`. New regression test `test-files/test_issue_235_method_default_param_padding.ts` exercises the dispatch-tower bug shape with pure local code (no socket, no mongo) — 6 cases covering local class with default, object-default, two-defaults, async + multiple awaits + default, dynamic-dispatch (Any-typed receiver), and "after-prior-call" register-leftover shape; matches `node --experimental-strip-types` byte-for-byte. Gap tests baseline preserved. +- **v0.5.357** — Closes #233: `Array.push` from inside an async function silently capped at 16 elements when the array was passed in as a parameter. The default initial capacity is 16 — once exceeded, `js_array_push_f64` reallocates and returns a new pointer, but in the async case the caller's parameter slot was never updated with that new pointer (sync wrappers don't have this issue because they're inlined; async functions can't be — they're state machines). The caller's `samples` stayed pointing at the OLD 16-cap ArrayHeader while every subsequent push operated on a separate orphaned new array. **Fix**: install a forwarding pointer at the OLD location on every grow, reusing the GC's existing `GC_FLAG_FORWARDED` mechanism (originally for the copying evacuation pass). `crates/perry-runtime/src/array.rs::js_array_grow` now calls `set_forwarding_address(old_header, new_ptr)` after the copy — the OLD payload's first 8 bytes (length+capacity) become the forwarding pointer, and the gc_flags byte gets the FORWARDED bit (0x80). Every array-pointer reader follows the chain: (1) `clean_arr_ptr` walks the FORWARDED chain (cap depth 64 against cycles) before the LAZY_ARRAY check and the length/capacity sanity check — without this the corrupt header bytes would either get sanity-rejected or read as ArrayHeader fields. (2) `js_value_length_f64` (in value.rs) detects FORWARDED at the GC header and routes through the array-cleaner to follow the chain. (3) Three inline IndexGet codegen fast paths (bounded-loop + general + Object-with-numeric-keys) now read GcHeader byte 1 (gc_flags), AND with 0x80, and route to the `js_array_get_f64` slow path when FORWARDED — same pattern as the existing LAZY_ARRAY guard. (4) `lower_index_set_fast` adds a FORWARDED branch routing through `js_array_set_f64_extend` so writes via stale pointers update the live new array. (5) `trace_array` in gc.rs detects FORWARDED arrays and pushes the forwarding target on the worklist so the live new array stays marked. (6) `rewrite_array_fields` skips FORWARDED arrays since their first 8 bytes hold a pointer not length+capacity. New regression test `test-files/test_issue_233_async_array_param_push.ts` covers number/string/object array element types, nested async wrappers, post-push slice/indexOf/iteration, and IndexSet via stale pointers — all byte-for-byte against `node --experimental-strip-types`. Gap tests 25/28 = baseline (the 3 failing — `array_methods` / `console_methods` / `typed_arrays` — pre-existing TypedArray-related categorical gaps unrelated to this change). Runtime test suite 21/21 + GC suite 40/40 still passing. +- **v0.5.356** — Fix `textfieldGetString` / `textareaGetString` returning gibberish numbers. `crates/perry-dispatch/src/lib.rs` had both rows declared as `ret: ReturnKind::F64`, but the runtime FFIs `perry_ui_textfield_get_string` / `perry_ui_textarea_get_string` actually return `*mut StringHeader` cast to i64 (GC-pinned via GC_FLAG_PINNED so the alloc survives until codegen NaN-boxes it). Treating the pointer bits as a raw double meant every read produced spec values like `"27017"` or `"65933097631650390000000000000000"` — string ops downstream (.length, .toUpperCase, .charAt, comparison, JSON.parse) then crashed or silently propagated garbage. Flip to `ReturnKind::Str` so codegen NaN-boxes with `STRING_TAG` (0x7FFF) and downstream property dispatch sees a proper string. Two-row metadata fix; no runtime, codegen, or HIR changes needed. Drift integration test stays clean. +- **v0.5.355** — Closes #236 (three independent bugs surfaced from the OP's `await fetch("https://api.github.com").json()` + `.then(console.log)` repro). Standing united with @TheHypnoo's diagnosis on the first two; my added bug (third bullet) explains the user's note that `response.text()` "doesn't work either". (1) `crates/perry-stdlib/src/fetch.rs::HTTP_CLIENT` now sets `.user_agent(concat!("perry/", env!("CARGO_PKG_VERSION")))` on the shared reqwest client. Pre-fix, anonymous Perry requests had no User-Agent and api.github.com responded `403 Forbidden` with the plaintext "Request forbidden by administrative rules. Please make sure your request has a User-Agent header" — `response.json()` then rejected because that's not JSON. Per-request UA still wins via reqwest's per-builder header replace. (2) Promise propagation when callback is null (`crates/perry-runtime/src/promise.rs`): `js_promise_resolve`, `js_promise_reject`, and the already-settled paths in `js_promise_then` previously gated their TASK_QUEUE push on `on_fulfilled.is_null()` / `on_rejected.is_null()`. The microtask runner has propagation logic for null-callback tasks (lines 343-355 — forward value/reason to `next` without invoking anything), but tasks with null callbacks were never PUSHED to the queue, so propagation never fired. Net effect: `Promise.resolve(x).then()` chained `next` promise stayed pending forever, and `await next` busy-waited. Fix: push to the queue whenever there's either a callback OR a chained `next` promise — same OR-condition in all three sites. (3) `console.log` used as a value (e.g. `let f = console.log` / `.then(console.log)` / passing it to `setTimeout`) lowered to the sentinel `0.0` (`crates/perry-codegen/src/expr.rs::Expr::PropertyGet`'s GlobalGet-receiver-as-value arm). The HIR uses `Expr::GlobalGet(0)` as a sentinel for ALL builtin globals (lower.rs:5037), so codegen routes by the property string. New `js_console_log_as_closure` runtime fn in `crates/perry-runtime/src/builtins.rs` returns a singleton `*mut ClosureHeader` (CAS-protected lazy alloc) whose `func_ptr` is `console_log_callable_thunk` (an `extern "C" fn(*const ClosureHeader, f64) -> f64` that dispatches `js_console_log_dynamic` and returns `TAG_UNDEFINED`). The codegen value-path arm now matches `property == "log"` on a `GlobalGet(_)` receiver and emits a call to `js_console_log_as_closure` instead of `0.0`; everything else still falls through. New GC root scanner `scan_console_log_singleton_roots` registered in `gc_init` pins the singleton against sweeps. Caveat: `Math.log`-as-value (extremely rare) now also routes through the console.log thunk — pre-fix it was the same broken `0.0` either way, so not a real regression; full disambiguation needs a side-channel for the original global name from HIR lowering. (4) Bonus on the diagnostics issue @TheHypnoo flagged: every fetch promise rejection used to allocate a bare `*StringHeader` and NaN-box it with POINTER_TAG (0x7FFD). The `print_uncaught` printer in `crates/perry-runtime/src/exception.rs` then read the StringHeader's `byte_len` u32 as `object_type`, which was neither `OBJECT_TYPE_ERROR` (2) nor `OBJECT_TYPE_REGULAR` (1), so it fell into the generic stringifier which emitted `Uncaught exception: [object Object]`. New helper `fetch_error_bits>(msg: S) -> u64` in fetch.rs allocates a real `ErrorHeader` via `perry_runtime::error::js_error_new_with_message` so the printer takes the dedicated Error arm and emits `Uncaught exception: Error: ` plus a stack frame. Bulk-replaced 16 call sites across `js_fetch_get` / `_get_with_auth` / `_post_with_auth` / `_post` / `_with_options` / `_response_text` / `_response_json` / `_text` / streaming via a regex sweep. Same fix flips the catch-side too: pre-fix `try { await r.json() } catch (e: any) { ... e.name }` saw a non-Error value; post-fix `e.name === "Error"` and `e.message === "JSON parse error: ..."`. New regression `test-files/test_issue_236_fetch_then_console_log.ts` exercises (2) and (3) without needing network — pre-fix it hung on the first `await ...then(console.log)`; post-fix it prints `start / hello world / between / 42 / after` and exits 0. The OP's exact GitHub-API repro now compiles + runs end-to-end against the live api.github.com, returning the canonical `{ authorizations_url, code_search_url, ... current_user_url, ... }` JSON tree. Maintainer fold-in: bumped 0.5.354 → 0.5.355. +- **v0.5.354** — Merges PR #232 (closes #227 — TheHypnoo): `await response.arrayBuffer()` was resolving with a metadata-only `{byteLength: N}` stub object — `resp.body` was consumed only for `.len()`, the bytes were dropped. Two-part fix: (1) `crates/perry-stdlib/src/fetch.rs::js_response_array_buffer` now allocates a real `BufferHeader` via `perry_runtime::buffer::buffer_alloc(len)`, memcpys `resp.body` into it, and resolves the promise with the buffer pointer NaN-boxed as POINTER_TAG (`.byteLength` continues to route correctly through the existing BufferHeader property dispatch in `value.rs`); (2) `crates/perry-runtime/src/buffer.rs::js_uint8array_new` POINTER_TAG arm gains an `is_registered_buffer(raw)` branch that does direct buffer→buffer memcpy, mirroring `js_buffer_from_value`'s existing handling — without it the catch-all routed through `js_uint8array_from_array` and tried to iterate the BufferHeader as `f64` NaN-boxed elements (garbage bytes). New regression `test-files/test_issue_227_array_buffer_bytes.ts` covers text/empty/multi-byte UTF-8 / JSON roundtrip via both `new Uint8Array(ab)` and `Buffer.from(ab)` shapes — all byte-for-byte against `node --experimental-strip-types`. `test_gap_fetch_response.ts` extended with the byte-level assertions that the previous metadata-only stub silently passed. `response.blob()` keeps the stub shape (tracked as #234 — needs Blob's `.arrayBuffer()` / `.text()` / `.bytes()` / `.slice()` instance methods plus codegen dispatch routing). Maintainer fold-in: bumped 0.5.353 → 0.5.354 (origin's parallel-track v0.5.353 was Ralph's Windows clang `-target` fix); added both new regression tests to `SKIP_TESTS` in `.github/workflows/test.yml` (compile-smoke step) AND `test-parity/known_failures.json` as `ci-env` — same documented macOS-14 SDK/linker pattern as `test_gap_buffer_ops` / `test_buffer_small_alloc` / `test_inline_uint8array_param`. Note for #235 follow-up: `test_gap_fetch_response` was previously passing the smoke list and the addition of the Buffer-using assertions tipped it into the macOS-14 ci-env category — the underlying SDK/linker gap is the real long-tail problem and a CI runner image bump (macOS-15) would clear all 9+ ci-env Buffer skips at once. +- **v0.5.353** — Windows clarity pass for users hitting the (now-fixed) `LNK2019: unresolved external symbol __main` link error. Three coordinated fixes that turn an inscrutable downstream linker error into either (a) a successful build or (b) a clear, actionable Perry-side message: (1) `crates/perry-codegen/src/linker.rs::compile_ll_to_object` now ALWAYS passes `-target` to clang, deriving the host default via `crate::codegen::default_target_triple` when the caller doesn't specify one. Pre-fix, host builds passed the `.ll` to clang with no `-target` flag; clang's behavior is "use my own built-in default Target:, override the IR's stated `target triple` directive" — visible as `warning: overriding the module target triple` in stderr. On hosts where clang's default is non-msvc (typical on Windows when the user has MinGW/MSYS2 clang, Strawberry Perl's clang, an Anaconda env, or a Rust GNU toolchain LLVM bundle ahead of `C:\Program Files\LLVM`), this silently rewrote Perry's `target triple = "x86_64-pc-windows-msvc"` to `x86_64-w64-windows-gnu`. LLVM's mingw32 COFF emitter then injected a `__main` libgcc/MinGW C++ static-init reference into the generated `main()`. lld-link/link.exe (MSVC C runtime) doesn't define `__main`, so the link bombed with `LNK2019: unresolved external symbol __main referenced in function main` even though the .ll said msvc. Pinning `-target` to the IR's actual triple makes clang trust the IR and skips the override path. `default_target_triple` promoted from private `fn` → `pub(crate) fn` so linker.rs can reach it. (2) Pre-flight clang probe (new `probe_clang_default_triple` in linker.rs) runs `clang --version` once per process and parses the `Target:` line. When the host is Windows AND the requested triple is msvc AND clang's default is GNU/MinGW, prints a single informational `note:` explaining "clang default is ``; Perry is forcing -target so the link stays MSVC-flavored. If anything below fails, install msvc-default LLVM (winget install LLVM.LLVM) or set PERRY_LLVM_CLANG." Silent on a clean msvc setup. Suppress with `PERRY_NO_CLANG_PROBE=1` for CI/scripted builds. (3) When `clang -c` does fail, the error message now shows: the resolved clang path, the first line of `clang --version`, the requested `-target`, and the path to the temp `.ll` left for debugging — plus a pattern-matched hint via the new `build_clang_failure_hint` helper. Hint patterns: MinGW-default-on-Windows-targeting-msvc → `winget install LLVM.LLVM` install instructions; "overriding the module target triple" warning surfaced by clang → "your clang may not support that target — install LLVM.LLVM or set PERRY_LLVM_CLANG"; "library not found" → check platform SDK install; fallback → generic "set PERRY_LLVM_CLANG to a clang whose default Target: matches ". 4 unit tests in `linker::tests` mod pin all four hint branches (the Windows-specific MinGW assertion is gated on `cfg!(target_os = "windows")`). (4) `crates/perry/src/commands/compile/optimized_libs.rs` — the `auto-optimize: Perry workspace source not found, using prebuilt libperry_runtime.a + libperry_stdlib.a` log message (only emitted when source isn't found AND verbose ≥ 1) was hardcoded to the Unix `.a` filenames regardless of host OS. Confused users who saw it on Windows into thinking Perry was searching for a `.a` (it isn't — `find_runtime_library` correctly resolves to `perry_runtime.lib` + `perry_stdlib.lib` on Windows hosts; the actual link succeeds with the right files). Now picks the platform-correct names per `target` / host. **Verified end-to-end**: smoke compile of `console.log("hello perry")` from the released `perry-windows-x86_64.zip` (i.e. workspace-source-not-found path) now logs `using prebuilt perry_runtime.lib + perry_stdlib.lib` and produces a 0.5 MB working .exe. UI compile (`App({ title, body: VStack([Text("Hello"), Button("Click", () => {})]) })`) produces 0.8 MB working .exe with the expected `compiler_builtins` cross-archive duplicate-symbol warnings (pre-existing/expected on Windows, not a regression). 4 new unit tests in `perry-codegen::linker::tests` pass. The actual `LNK2019 __main` failure shape from the user's repro screenshot is reproduced locally by passing `-target x86_64-w64-mingw32` to clang against perry's own .ll output (verifiable via `clang -c -target x86_64-w64-mingw32 main_ts.ll -o m.o; llvm-objdump --syms m.o | grep __main` → shows `__main` as section 0 / undefined external) and is now prevented for any user whose clang has any default target. +- **v0.5.352** — Wire better-sqlite3 `Database` ctor + chained Statement methods (Ralph). Three connected fixes: (1) `crates/perry-codegen/src/lower_call/builtin.rs` — new "Database" arm calls `js_sqlite_open` so `new Database(path)` returns a real `SqliteDbHandle` instead of falling into `lower_new`'s empty-object placeholder (pre-fix `db.prepare(...)` then unboxed the bogus pointer and prepare returned -1; every chained `.run/.get/.all` silently produced undefined). (2) `crates/perry-hir/src/lower/expr_call.rs` — extends chained-method dispatch so `db.prepare(sql).run/get/all` resolves the outer call against the Statement returned by prepare; the same wiring transparently covers mongodb `db→Database`, `collection→Collection`, pg `connect→PoolClient`, mysql2 `getConnection→PoolConnection`, ioredis `duplicate→Redis`. (3) `crates/perry-stdlib/src/sqlite.rs::params_from_array` guards against NaN-tagged non-array inputs — codegen pads omitted-arg slots with `TAG_UNDEFINED` (looks like a non-null pointer); pre-fix dereferencing as `*const ArrayHeader` was UB and the garbage `length` crashed `stmt.run()` / `stmt.all()` on no-arg calls. Restores end-to-end better-sqlite3 support that the v0.5.329-v0.5.342 lower_call extraction series silently broke. +- **v0.5.351** — Closes #221: `arr[i] = v` writes silently dropped on module-level `const A: T[] = []` declared empty + mutated from inside a function. Codegen at `crates/perry-codegen/src/expr.rs::Expr::IndexSet` was funneling module-global array receivers (those resolved via `ctx.module_globals` rather than `ctx.locals`) through `js_array_set_f64` — the bounds-checked variant that returns silently when `index >= length`. For an empty array (length=0), every `A[i] = v` at any index was a no-op, so both the value write and the implicit `.length` update vanished. Bug surfaced while debugging Bloom-Engine/jump's `discoverLevels()` populating `LEVEL_FILES` / `LEVEL_NAMES` (declared empty at module level) via index-assignment from a function — the level-select screen rendered empty because every write dropped. Pre-existing workarounds (`.push()`, pre-initialized array `const P = [0,0,...]`, top-level index-assign outside any function) all worked because they hit different codegen paths. Fix: detect the module-globals branch in IndexSet, use `js_array_set_f64_extend` (the realloc-capable variant the local-stack-slot fast path already uses), and write the new pointer back to the global slot — symmetric with `lower_index_set_fast`'s writeback to a stack-local slot. The genuine non-LocalGet and closure-capture cases (no writeback target available) keep the bounded path, with a comment explaining why. New regression test `test-files/test_issue_221_module_array_index_set.ts` covers empty string-array filled by index from inside a function, empty number array, pre-sized number array index-set past existing length (extends + gap-fills), and object array filled by index from inside a function — matches `node --experimental-strip-types` byte-for-byte (modulo Perry's documented `js_array_set_f64_extend` behavior of filling gaps with `0` rather than `undefined` — a runtime helper choice, not something this fix should change). Verified: cargo build --release clean; gap tests 25/28 = baseline (the 3 failures `array_methods` / `console_methods` / `typed_arrays` are documented categorical/CI gaps unrelated to this change); LLVM IR dump confirms `js_array_set_f64_extend` is now emitted with the result stored back to `@perry_global___`. +- **v0.5.350** — Issue #185 Phase D step 2: live style editing via `POST /style/:h` + editable inspector UI fields. Four pieces: (1) `crates/perry-runtime/src/geisterhand_registry.rs` adds `PendingAction::ApplyStyle { handle, prop_id, args: [f64; 4] }` variant, `STYLE_*` prop-id constants (1-9 covering `backgroundColor` / `color` / `borderColor` / `borderWidth` / `borderRadius` / `opacity` / `padding` / `hidden` / `enabled`), `perry_geisterhand_queue_apply_style(handle, prop_id, a0..a3)` FFI for the HTTP-thread → main-thread marshaling, `perry_geisterhand_register_apply_style(fn)` so platform UI libs register their dispatcher, and a new pump arm that fires the registered fn-pointer on each drained ApplyStyle action. (2) `crates/perry-ui-macos/src/geisterhand_style.rs` (new file, gated on `feature = "geisterhand"`) implements `apply_style(handle, prop_id, a0..a3)` that switches on `prop_id` and routes to the existing `widgets::set_background_color` / `widgets::text::set_color` / `set_border_color` / `set_border_width` / `set_corner_radius` / `set_opacity` / `set_edge_insets` / `set_hidden` / `perry_ui_widget_set_enabled` setters; registered alongside the other geisterhand fn-pointers in `app.rs`. (3) `crates/perry-ui-geisterhand/src/server.rs` adds `POST /style/:h` route — JSON body whose keys are camelCase prop names + values (CSS color string OR `{r,g,b,a}` object for color props, number for scalars, bool for hidden/enabled). New `parse_color_string` helper (mirrors the codegen-side `parse_color_string` exactly: hex 3/4/6/8 + 10 named colors + `transparent`) so live edits produce the same pixels as compile-time inline styles. Unknown keys / invalid color strings are silently dropped (don't render a magenta sentinel for a typo); the response body returns `{"ok":true,"applied":["backgroundColor","opacity"]}` listing which props made it through, useful for the UI's "applied … on N" toast. (4) `crates/perry-ui-geisterhand/src/inspector_ui/index.html` extends the right-column detail with a `live edit` section: 9 inputs (text for color props, number for scalars, checkbox for bools), each with a `change`/`blur` handler that POSTs `{[prop]: value}` to `/style/:handle` and updates the status line. **Verified end-to-end via curl**: `POST /style/1 {"backgroundColor":"#3B82F6","opacity":0.75,"hidden":false}` returns `{"ok":true,"applied":["backgroundColor","hidden","opacity"]}` — the HTTP thread parses, queues all three actions, the main-thread pump drains and dispatches via the registered fn-pointer. Bad JSON → 400 invalid JSON; bad handle → 400 invalid handle; unknown prop names are silently filtered (`{"foo":"bar","backgroundColor":"red"}` returns `applied:["backgroundColor"]`). For full end-to-end visual verification on macOS, the user must build with `--features geisterhand` on `perry-ui-macos` (the existing infrastructure path; default builds skip the registration hooks). iOS/tvOS/visionOS/Android/GTK4/Windows dispatcher registration follows as a sweep PR — the architecture is in place; each platform just needs its own `geisterhand_style.rs` and the matching call in `app.rs`. Phase D step 3 (cross-platform screenshot capture parity beyond the existing watchOS path) follows separately. +- **v0.5.349** — Issue #185 Phase D step 1: live inspector UI served at `http://localhost:7676/` by the embedded geisterhand HTTP server. Three pieces: (1) `crates/perry-ui-geisterhand/src/inspector_ui/index.html` — single-page, dependency-free vanilla-JS app (~280 lines) embedded via `include_str!` so it ships inside the binary with zero external assets; renders a left-column tree view of registered widgets (kind, label, meta), right-column per-widget detail (frame, hidden/enabled, live `/value/:h`, raw JSON), auto-refresh every 1.5s with a pause/resume control, and a "fire onClick" button hitting the existing `POST /click/:h`. Color scheme is dark-mode-monospace (matches devtools convention). (2) `crates/perry-ui-geisterhand/src/server.rs` — three new route arms `(Get, "/")`, `(Get, "/inspector")`, `(Get, "/index.html")` all serve `INSPECTOR_HTML` with a new `html_header()` helper. No new server-side endpoints needed for the read-only view — uses the existing `/widgets?tree=true`, `/value/:h`, `/click/:h` JSON surface. (3) `crates/perry-codegen/src/codegen.rs` — entry-module main() prelude now emits a `perry_geisterhand_start(port)` call when `cross_module.needs_geisterhand` is true (auto-flow from `--enable-geisterhand` / `--geisterhand-port`). The call site has a critical secondary purpose beyond just starting the server: it pins the geisterhand server module against macOS's lazy-load `-dead_strip`, so without it the linker silently eliminates `INSPECTOR_HTML` (the `include_str!`-embedded page) as unreferenced rodata and hitting `/` returns "not found". Diagnosed live: pre-fix `nm /tmp/visual_test_macos | grep geisterhand` showed zero symbols and `strings .. | grep "Perry Inspector"` was empty; post-fix the listening message prints, the symbol is linked, and `curl localhost:7676/` returns the full HTML. New fields `needs_geisterhand` / `geisterhand_port` on `CrossModuleCtx` (threaded from `CompileOptions`); the function declaration must be emitted BEFORE `main` claims `&mut llmod` (Rust borrow checker rejects calling `llmod.declare_function` while `main` holds the mutable borrow), so the declare/call are split. Verified end-to-end: `perry compile inspector_test.ts -o /tmp/it --enable-geisterhand` produces a 1.3 MB binary; launching prints `[geisterhand] listening on http://127.0.0.1:7676`; `curl localhost:7676/` returns the inspector page; `curl localhost:7676/widgets?tree=true` returns a JSON array. The widget registry is empty in the default macOS build because `perry-ui-macos` ships without `--features geisterhand` enabled and the `register` calls in widget creation are gated behind that cfg — separate pre-existing limitation, not introduced here. Phase D step 2 (live edit via a new `POST /style/:h` endpoint that pushes a StyleProps object through the same per-key setters Phase C uses) and step 3 (cross-platform screenshot capture parity beyond the existing watchOS path) follow as separate PRs. +- **v0.5.348** — Docs for the v0.5.347-merged `perry/updater` subsystem (PR #224). New `docs/src/updater/overview.md` covers the trust model (Ed25519 over the SHA-256 digest, not the file bytes), manifest schema, install + sentinel-rollback flow, low-level `perry/updater` ambients, and the `@perry/updater` wrapper (`checkForUpdate` / `initUpdater` / `markHealthy`); new `Auto-Update` section in SUMMARY.md slotted between Internationalization and System APIs. New `docs/examples/updater/snippets.ts` (`run: false` — updater fetches a manifest, replaces the running binary, and detached-relaunches, none of which work under the doc-tests sandbox; compile-link still gates API drift on the `perry/updater` ambient and the `@perry/updater` wrapper) with 9 ANCHOR blocks (`imports-low-level` / `imports-high-level` / `compare-versions` / `verify-file` / `install-and-relaunch` / `rollback` / `paths` / `sentinel-manual` / `high-level-check` / `high-level-init` / `manifest-shape`) extracted by the new doc page. Per-OS sentinel path table and platform-triple table (`darwin-aarch64` / `windows-x86_64` / `linux-x86_64` etc.) documented so manifest authors can mirror the canonical Rust-style form the wrapper picks against. Codegen / runtime / `@perry/updater` source unchanged from v0.5.347 — pure docs follow-up. +- **v0.5.347** — Closes #210 (4 of 5): wire Windows styling stubs for `text.decoration` + `widget.opacity` + `widget.border_color` + `widget.border_width`. **Decoration** (`crates/perry-ui-windows/src/widgets/text.rs::set_decoration`) — new `apply_decoration` reads the widget's current HFONT via `GetObjectW`, mutates `lfUnderline` (decoration=1) / `lfStrikeOut` (decoration=2) on the LOGFONTW, recreates via `CreateFontIndirectW`, and re-emits through the existing `apply_font` lifecycle (which handles old-HFONT `DeleteObject`). Falls back to a fresh "Segoe UI 14/400" if no HFONT is set yet so calling `set_decoration` before `set_font_*` still works. **Opacity** (`mod.rs::set_opacity`) — new `apply_opacity` adds `WS_EX_LAYERED` to the child HWND's extended style if not yet set, then applies the alpha (clamped 0-1, ×255) via `SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA)`. Per-child `WS_EX_LAYERED` works on Windows 8+ (Perry's minimum). **Borders** (`mod.rs::{set_border_color,set_border_width}`) — new `ensure_border_subclass` lazily installs a one-time `SetWindowSubclass` per HWND (id=`0x70_72_72_79`, idempotent via thread_local `BORDER_SUBCLASSED` HashSet, handle passed via `dwRefData`). The subclass's WM_PAINT handler calls `DefSubclassProc` first so the wrapped control renders normally, then `GetDC` + `CreatePen(PS_SOLID, w, COLORREF(...))` + `SelectObject` + `Rectangle` to draw a colored outline at the client-rect bounds. Defaults match CSS: missing color → black 1.0 alpha, missing width → 1px (so calling either setter alone produces a visible 1px black border); width clamped to 1 minimum, with explicit 0 producing no-op. Both setters share the joint `BORDER_STATE` so the paint reads color + width together. **Matrix** (`crates/perry-ui/src/styling_matrix.rs`) flips Windows column on the 4 wired rows from `Stub` → `Wired`. Auto-regenerated `docs/src/ui/styling-matrix.md` summary line now shows Windows at **42 Wired / 1 Stub** (down from 5 stubs); only `widget.shadow` remains as `Stub` because real CSS-style shadow rendering on Win32 child controls needs DirectComposition (`IDCompositionVisual` + `DropShadowEffect`) — a multi-day refactor that's its own follow-up. Cross-compile from macOS to Windows isn't possible without the SDK, so the Win32 calls (`SetWindowSubclass`, `DefSubclassProc`, `GetWindowLongW`, `SetWindowLongW`, `SetLayeredWindowAttributes`, `GetObjectW`, `CreateFontIndirectW`, `CreatePen`, `Rectangle`) are validated by inspection against existing usage patterns in the codebase (`canvas.rs::Stroke` for the pen + `Rectangle` shape, `text.rs::set_font_family` for the LOGFONT round-trip pattern); CI's windows-2022 runner verifies the link. Pre-existing 4 host-build errors in `create_font_with_family` (missing `#[cfg(target_os="windows")]`) unchanged. +- **v0.5.346** — Issue #185 follow-up: fix iOS / tvOS / visionOS sim crash on `Button("X", () => {}, { color: "white" })`. Same shape as the v0.5.313 macOS bug, just unwired on the three UIKit platforms. `apply_inline_style` (`crates/perry-codegen/src/lower_call.rs`) routes every `color: ...` inline-style prop through `text_set_color`, comment-as-spec'd "no-op on widgets that ignore it." But `crates/perry-ui-{ios,tvos,visionos}/src/widgets/text.rs::set_color` did an unchecked `setTextColor:` on the receiver — and on UIKit `UIButton` doesn't implement that selector (UIButton uses `setTitleColor:forState:`). On a styled Button the call raised ObjC `unrecognized selector`, objc2 panicked across the FFI boundary, the panic was non-unwinding (`extern "C"`) → process abort. Crash trace from the iOS sim: `panic_cannot_unwind → perry_ui_text_set_color → main`. Fix mirrors the macOS v0.5.313 class-probe pattern: in each platform's `text::set_color`, probe the widget's runtime class via `isKindOfClass:` — `UIButton` routes to `super::button::set_text_color` (which exists on all three platforms and does `setTitleColor:forState:UIControlStateNormal=0`), `UILabel` follows the original `setTextColor:` path, anything else silently no-ops (matching the codegen comment's stated intent). New regression: `docs/examples/ui/styling/visual_test.ts` (the v0.5.324 comprehensive test app) now launches end-to-end on iPhone 16 sim from a fresh boot — pre-fix died at the Buttons section's "Styled" cell with `color: "white"`, post-fix renders all 13 sections cleanly. tvOS / visionOS sim builds need nightly Rust + `-Zbuild-std` (tier-3 targets), deferred for runtime-launch verification but the patch follows the iOS-validated shape verbatim and the macOS twin has been in production since v0.5.313. +- **v0.5.345** — Fix Windows CI doc-test failures (`ui/layout/snippets.ts` + `ui/menus/snippets.ts` ACCESS_VIOLATION) by aligning runtime extern signatures with the `perry-dispatch` table. Three coordinated fixes covering all 8 perry-ui-* platform crates: (1) `perry_ui_navstack_create()` now takes 0 args (matching dispatch row `args: &[]`) — was `(title_ptr: i64, body_handle: i64)` on every platform. The TS surface is `NavStack(): Widget`; dispatch emitted a 0-arg LLVM call but Rust read RCX/RDX. On Win64 ABI those were uninitialized, so `str_from_header(garbage_ptr)` dereferenced wild memory at offset 0x5 and crashed. macOS/Linux SysV ABI happened to land 0 in RDI/RSI in this call shape, masking the bug. (2) `perry_ui_menu_add_item_with_shortcut(menu, title, shortcut: i64, callback: f64)` reorders the last two args to match dispatch `[Widget, Str, Str, Closure]` — runtime previously had `(menu, title, callback: f64, shortcut: i64)`. On Win64 ABI int and float positional args share register slot indices (RDX/XMM1, R8/XMM2 etc.), so the swap put the closure value into the runtime's `shortcut_ptr: i64` slot — `str_from_header(closure_ptr)` then crashed. SysV's separate int/float register pools coincidentally landed each value in the right register for the wrong slot name, hiding it on macOS/Linux. (3) `perry_ui_app_set_timer(app: i64, interval_ms: f64, callback: f64)` adds the missing leading App handle to match dispatch `[Widget, F64, Closure]` (TS API `appSetTimer(app, intervalMs, callback)`) — same XMM-misalignment family bug, manifesting as wrong timer intervals + lost callbacks on Win64 rather than a crash. (4) `perry_ui_stack_set_distribution(handle, distribution: f64)` aligns Windows with the other 7 platforms (was `i64` only on Windows) so the f64 dispatch arg lands in XMM1 instead of being read as garbage from RDX. (5) `perry-ui-windows::toolbar::create()` parents the new toolbar HWND under the parking window — was passing `None` for the `WS_CHILD` parent which Windows refuses with HRESULT 0x8007057E "Cannot create a top-level child window". The fix is the same parking-HWND pattern every other widget already uses; `attach()` reparents to the real app window when the layout resolves. Verified: full doc-tests sweep on Windows now reports **76/81 passed, 0 failed, 5 skipped** (matches CI shape — `--filter-exclude ui/gallery.ts`). The 5 skips are `platforms/wasm_snippets.ts` / `runtime/thread_primitives.ts` / `runtime/thread_snippets.ts` / `ui/threading/snippets.ts` / `ui/widgets/image_symbol.ts` — all platform-banner skips, not regressions. Latent mismatches surfaced during the audit but NOT fixed in this commit (they don't crash today by luck and are tracked separately): `Picker` / `tabbarAddTab` / `frameSplitCreate` / `TextArea` constructor / `sheetCreate` arg shapes — each will need a similar dispatch-vs-runtime alignment, but only when those code paths actually exercise the Win64 ABI path. +- **v0.5.344** — Release-unblocker: triage 2 parity failures + re-bless Linux gallery baseline. (1) `test-parity/known_failures.json` adds `test_gap_array_methods` (status:known_limitation — TypedArray.at() returns undefined; categorical typed-array gap, was passing at v0.5.319, regressed in the v0.5.319→v0.5.343 series) and `test_json_lazy_predicates` (status:bug — `Array.isArray()` returns false on `[...].map(fn)` result; missing predicate-side recognition of the map-returned shape). Both surfaced when the v0.5.343 release-packages `await-tests` gate failed: parity job flagged them as new vs the snapshot. (2) `docs/examples/_baselines/linux/gallery.png` re-blessed from the v0.5.343 CI artifact (was 900x1400, now 900x1024 matching the GTK4 runner's actual render). The previous baseline was stale — likely from a pre-#202/#206 GTK4 styling state where the gallery rendered taller. Macos baseline unchanged (passes CI cleanly at 900x970), Windows baseline unchanged (separate ACCESS_VIOLATION crashes in `ui/layout/snippets.ts` + `ui/menus/snippets.ts` are tracked separately — Windows worker investigating). v0.5.343 tag stays as-is per release-skill rule (do not retag); this is the first publishable release after the long extraction series. +- **v0.5.343** — Post-extraction cleanup: removes 66 unused-import warnings introduced across the v0.5.329–v0.5.342 split work via `cargo fix --bin perry --lib -p perry-hir -p perry-codegen --allow-dirty --release`. 25 files touched (15 in the new sub-modules, 10 in unrelated files where cargo fix happened to find pre-existing dead imports). Each fix is exclusively `use`-statement narrowing — no behavior changes. Examples: `expr_object.rs` had `anyhow!`, `FuncId`, `ArrayElement`, `BinaryOp`, `is_destructuring_pattern`, `infer_type_from_expr` from the bulk-import phase that turned out unused once the body was inlined; `lower_call/native.rs` had `Function`, `is_abort_controller_expr`, `lower_abort_controller_call`, `nanbox_string_inline` ditto; `compile/resolve.rs` had `find_file_dep_in_package_json`, `find_node_modules`, `has_perry_native_module`, `is_ts_file`, `resolve_exports`, `resolve_package_entry`, `resolve_package_source_entry`, `resolve_with_extensions` (these are still callable via the `super::` re-exports — they're just not used inside the resolve module itself, only by its callers). Workspace warning count: **321 → 255** (−66, −21%). The remaining 255 are pre-existing categorical issues (63 `unnecessary unsafe block` in perry-runtime, 25 `unreachable pattern` in perry-hir/perry-codegen lowerers, 23 `unnecessary transmute`, etc. — separate bugs, not session-introduced). **Verified**: cargo build --release clean; cargo test --workspace 434/0/5 = baseline; gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline. Final session tally: 15 commits (v0.5.329→v0.5.343), all baselines green throughout, ~14,000 LOC reorganized into 19 focused sub-modules, the most-cited monolith (`lower::lower_expr`) shrunk by 91%, compile.rs by 60%, lower.rs by 44%, lower_call.rs by 33%. +- **v0.5.342** — Tier 2.1 final extraction: moves the per-platform link command construction out of `crates/perry/src/commands/compile.rs::run_with_parse_cache` into a new `compile/link.rs` sub-module. Pre-extraction this was a single ~1240-LOC inline block fanning out across macOS / iOS / tvOS / visionOS / watchOS / Android / Linux / Windows + 5 cross-compile permutations — every platform-specific link flag change churned the orchestrator file. The new `pub(super) fn build_and_run_link(args_input, ctx, target, obj_paths, compiled_features, runtime_lib, stdlib_lib, jsruntime_lib, exe_path, format) -> Result<()>` takes 10 params (no struct needed once `is_*` flags get re-derived from `target` internally) and owns: (1) per-platform `Command::new(...)` selection — clang on macOS/iOS, swiftc on watchOS/visionOS, Android NDK clang, lld-link / link.exe on Windows, ld64.lld on Linux→Apple cross paths, plus the matching SDK / sysroot / triple discovery via `xcrun --sdk ... --show-sdk-path` / `--find clang` / `--find swiftc`. (2) entry-object `_main` rename via `rust-objcopy --redefine-sym` for watchOS / visionOS Swift-app shells (`_main → _perry_main_init`) and iOS / tvOS / watchOS game-loop variants (`_main → __perry_user_main`). (3) accumulating object files + dead-strip flags (`-Wl,--gc-sections` / `-dead_strip` / `-Xlinker -dead_strip` / `/OPT:REF /OPT:ICF`). (4) library link order — jsruntime → runtime fallback → stdlib precedence rules (the comment explains the Mach-O first-wins vs ELF `--allow-multiple-definition` vs MSVC `/FORCE:MULTIPLE` differences). (5) `-o exe_path` / `/OUT:...`. (6) plugin-host `-Wl,-u,_` force-keep + `-rdynamic` on Linux. (7) per-platform framework / system-lib enumeration (UIKit / SwiftUI / AVFoundation on Apple, GTK4 via `pkg-config --libs gtk4` with hardcoded fallback on Linux, Win32 system libs on Windows, Android stub for `JNI_GetCreatedJavaVMs`). (8) UI lib + strip-dedup invocation + GTK4 `--whole-archive` for `js_stdlib_process_pending`. (9) geisterhand lib link + Windows `/INCLUDE:` symbol force-references. (10) external `perry.nativeLibrary` manifest crates: `cargo build --release [+nightly] [-Zbuild-std] --manifest-path` per crate, find the resulting staticlib, framework / lib / pkg-config additions, swiftc compilation of manifest-declared `swift_sources` for `--features watchos-swift-app` (with deduplication via `seen_swift_sources: HashSet`), and metal-source target validation. (11) `cmd.status()?` invocation + bail on non-zero. **Visibility changes**: 0 — every helper called by the new module is already `pub(super)` from the prior 9 sub-module extractions in this Tier 2.1 stack. The new module accesses `find_geisterhand_*`, `find_ui_library`, `find_lld_link`, `find_msvc_link_exe`, `find_perry_windows_sdk`, `windows_pe_subsystem_flag`, `find_msvc_lib_paths`, `find_stdlib_library`, `find_llvm_tool`, `find_visionos_swift_runtime`, `find_watchos_swift_runtime`, `apple_sdk_version`, `strip_duplicate_objects_from_lib`, `build_geisterhand_libs`, and `rust_target_triple` (compile.rs's own private fn — accessible to children) via `super::*`. **Argument-passing approach**: the call site is in `run_with_parse_cache` AFTER `args.output` has already been consumed by `unwrap_or_else` (line 2429 in old numbering), so the function takes `args_input: &Path` (i.e. `&args.input`) rather than `&CompileArgs`. The other ~10 params are by reference — `&CompilationContext`, `Option<&str>`, `&[PathBuf]`, `&[String]`, `&Path` (×2), `&Option` (×2), and `OutputFormat` by value (Copy). The dylib link path stays inline in `run_with_parse_cache` because it returns early with a `CompileResult`; per-platform `.app` bundling (iOS / visionOS / watchOS / tvOS) and Android `.so` companion-lib copying also stay inline since they happen after the link returns and depend on many post-link variables (`result_bundle_id`, `result_app_dir`, etc.). **`is_cross_*` cleanup**: `is_cross_windows`, `is_cross_ios`, `is_cross_visionos`, `is_cross_macos`, and `is_cross_tvos` were all only used inside the extracted block. Their declarations have been removed from `compile.rs`; the new `link.rs` re-derives them internally from `target`. **compile.rs delta**: 5019 → 3783 LOC (-1236, ~25% reduction). **Cumulative across this session** (v0.5.329-v0.5.342, fourteen commits): **compile.rs 9391 → 3783 LOC (~60% total reduction)**, lower_call.rs 7000+ → 4681 LOC (~33%), lower.rs 13591 → 7554 LOC (~44%), `lower::lower_expr` 6687 → 624 LOC (~91%). compile.rs is now ~40% of its starting size; what remains is the orchestrator entry points (`run`, `run_with_parse_cache`), context construction (`CompilationContext::new`), and the parse / typecheck / lower / codegen / cache pipeline — i.e. exactly what an orchestrator file should hold. The full `compile/` sub-module structure has 10 focused files: parse_cache (294 LOC), strip_dedup (518), library_search (704), targets (824), object_cache (747), resolve (849), optimized_libs (413), collect_modules (412), and now link (1245). **What still works**: cargo build --release clean; cargo test --workspace 434/0/5 = baseline; gap tests 25/28 = baseline (the 3 failing — `array_methods`, `console_methods`, `typed_arrays` — pre-existing categorical/CI gaps, not regression-induced); doc-tests --skip-xcompile 80/82 = baseline (the lone fail is the pre-existing `ui/gallery.ts` retina screenshot drift); UI smoke compile (`App({title, body: VStack([Text("OK"), Button("Click", () => {})]) })`) produces a 856K macOS binary that exits 0 in `PERRY_UI_TEST_MODE=1`, and the strip-dedup output (419 → 35 trimmed objects via 178 by-symbol-subset + 222 by-name-pattern + 16 rlib-extracted + 19 kept) matches v0.5.341 byte-for-byte — proves the live path through library_search + targets + strip_dedup + ObjectCache + resolve + link all works end-to-end. **Cumulative across this session** (v0.5.329-v0.5.342, fourteen commits): every plan item delivered, plus four follow-up rounds extracting roughly 13,200 LOC across 18 new sub-modules into the largest cognitive-load reduction in the project's history. Tier 2.1 is now complete. +- **v0.5.341** — Tier 2.1 final round: extracts the last two big bounded chunks of compile.rs. (1) **`compile/optimized_libs.rs`** (~390 LOC moved): the auto-rebuild driver that picks the smallest matching Cargo feature set for the user's TS code. Contains the `OptimizedLibs` struct + impl + `build_optimized_libs` fn (the cargo + workspace + target-dir hash-keyed driver) + the doc explaining the panic-mode + feature-derivation logic. Both runtime and stdlib halves fall back to the prebuilt libraries gracefully on failure. The hash-keyed `target/perry-auto-{:016x}` dir means consecutive runs with the same profile are no-ops after the first build. (2) **`compile/collect_modules.rs`** (~390 LOC moved): the transitive import-walk that builds `CompilationContext.native_modules` / `js_modules`. Walks the import graph from the entry file, lowers every TypeScript module to HIR, classifies each as native-compiled vs JS-runtime-loaded, and runs per-module HIR passes (`inline_functions`, `transform_generators`) before adding to context. Source hashes feed the V2.2 codegen cache key derivation. **Cumulative deltas this commit**: compile.rs 5795 → 5019 LOC (-776, ~13%). **Cumulative across the entire session** (v0.5.329-v0.5.341): **compile.rs 9391 → 5019 LOC (~47% total reduction)**, lower_call.rs 7000+ → 4681 LOC (~33%), lower.rs 13591 → 7554 LOC (~44%), `lower::lower_expr` (the most-cited monolith) 6687 → 624 LOC (~91%). compile.rs is now nearly half its starting size; what remains is the `run_with_parse_cache` orchestrator + `CompilationContext::new` + `build_link_command` (the bulk — the inline link command construction that fans out per-platform). Further splits would be possible but each would require careful coordination of the 30+ helper variables threaded through `build_link_command`. **What still works**: cargo build --release clean; cargo test --workspace 434/0 = baseline; gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline; multi-module #212 closure-capture smoke matches Node byte-for-byte. **The final compile/ sub-module structure** has 9 focused files: parse_cache (294 LOC), strip_dedup (518), library_search (704), targets (824), object_cache (747), resolve (849), optimized_libs (413), collect_modules (412). **Cumulative across this session** (v0.5.329-v0.5.341, thirteen commits): every plan item delivered, plus four follow-up rounds extracting roughly 12,000 LOC across 17 new sub-modules into the largest cognitive-load reduction in the project's history. +- **v0.5.340** — Tier 2.1 + 2.2 mass follow-up: completes the lower_call.rs split with the 805-LOC `lower_native_method_call` that v0.5.339 deferred, plus the next four big chunks of compile.rs (per-target codegen orchestrators, on-disk object cache, npm/module resolution). Four extractions in one PR. **lower_call.rs `lower_native_method_call`** (1 new sub-module, 805 LOC moved): `lower_call/native.rs` is the giant dispatcher routing `obj.method(args)` calls against native modules (mysql2, pg, redis, mongo, ws, fastify, fetch, perry/ui, perry/system, perry/i18n, perry/plugin, AbortController, …). Required bumping 14 helpers in `lower_call.rs` from private `fn` to `pub(super) fn` so the new sub-module can reach them via `super::` (perry_*_table_lookup family — UI / UI_INSTANCE / SYSTEM / I18N / PLUGIN / PLUGIN_INSTANCE — plus native_module_lookup, lower_perry_ui_table_call, lower_fetch_native_method, lower_abort_controller_call, lower_notification_schedule, find_outer_writes_stmt, is_abort_controller_expr, lower_native_module_dispatch, collect_closure_introduced_ids). The fn itself is `pub(crate)` (re-exported from lower_call.rs for callers in `crate::expr` that import directly). **compile.rs per-target orchestrators** (1 new sub-module, ~800 LOC moved): `compile/targets.rs` contains the 6 `compile_for_*` widget/web/wasm functions (ios_widget, watchos_widget, android_widget, wearos_tile, web, wasm) plus their supporting helpers (`generate_js_bundle`, `find_watchos_swift_runtime`, `find_visionos_swift_runtime`, `apple_sdk_version`, `lookup_bundle_id_from_toml`, `compile_metallib_for_bundle`). 12 fns hoisted to `pub(super)`. **compile.rs object cache** (1 new sub-module, ~440 LOC moved): `compile/object_cache.rs` contains `djb2_hash` + `Djb2Hasher` (the streaming hash accumulator) + `compute_object_cache_key` (the 246-LOC opts→key derivation) + the `ObjectCache` struct + impl + the 282-LOC `object_cache_tests` mod. Cluster moved together because they all relate to the V2.2 `.perry-cache/objects//.o` codegen cache layout. `djb2_hash` re-exported as `pub` (used elsewhere in compile.rs); `compute_object_cache_key` is now `pub fn` so the resolve.rs sub-module's `cached_resolve_import` can keep calling it. **compile.rs resolve_import family** (1 new sub-module, ~810 LOC moved): `compile/resolve.rs` is the entire TypeScript / JavaScript module-resolution machinery: `find_perry_workspace_root` (workspace-marker walk), `find_node_modules` (walk-up search), `find_file_dep_in_package_json` (issue #209 file: dep resolution), `parse_package_specifier` / `resolve_with_extensions` / `resolve_package_entry` / `resolve_package_source_entry` / `resolve_exports` (per-segment resolution), `resolve_import` + `cached_resolve_import` (public entry + cache), `discover_extension_entries`, `compute_module_prefix`, plus the npm-package classification helpers (`has_perry_native_library`, `has_perry_native_module`, `parse_native_library_manifest`, `is_in_perry_native_package`, `extract_compile_package_dir`, `is_in_compile_package`) and the file-extension predicates (`is_js_file`, `is_ts_file`, `is_declaration_file`). 17 fns hoisted to `pub(super)` or `pub`. **Cumulative deltas this commit**: lower_call.rs 5085 → 4681 LOC (-404, ~8%); compile.rs 8057 → 5795 LOC (-2262, ~28%). **Cumulative across the entire session** (v0.5.329-v0.5.340): compile.rs 9391 → 5795 LOC (~38% reduction), lower_call.rs 7000+ → 4681 LOC (~33%), lower.rs 13591 → 7554 LOC (~44%), `lower::lower_expr` (the most-cited monolith) 6687 → 624 LOC (~91%). The single biggest cognitive-load reduction in the project's history. **What's still inline in compile.rs** (5795 LOC): the orchestrator entry points (`run`, `run_with_parse_cache`), context construction (`CompilationContext::new`), library/runtime building (`build_optimized_libs`, ~270 LOC), the link command construction (`build_link_command` and the inline body of `run_with_parse_cache`, the bulk of remaining LOC), and the v0.5.295 `find_clang` + Linux LLVM-prefix fallback. Each is independently extractable but doing more would shift this PR into rewrite-territory. **Verified**: cargo build --release clean; cargo test --workspace 434/0 = baseline; gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline; comprehensive UI smoke (`App({ title, body: VStack([Text("OK"), Button("Click", () => {})]) })`) compiles to 0.9 MB binary, exits 0 in `PERRY_UI_TEST_MODE=1`, and the strip-dedup output (419 → 35 trimmed objects) matches v0.5.331 exactly — proves the live path through library_search + targets + strip_dedup + ObjectCache + resolve all works end-to-end. Multi-module #212 closure-capture smoke matches Node byte-for-byte. **Cumulative across this session** (v0.5.329-v0.5.340, twelve commits): every plan item delivered as full or pilot work, plus three rounds of follow-up extractions completing most of the cognitive-load reductions the plan called out. +- **v0.5.339** — Tier 2.3 + 2.2 mass extraction: completes the lower_expr split (~all extractable arms shipped) and finishes the lower_call.rs follow-up that v0.5.334's `ui_styling` extraction left open. Three rounds in one PR. **Round 1 — Member + Assign + New** (3 new sub-modules, 1110 LOC moved out of lower_expr): `lower/expr_member.rs` (424 LOC) handles `obj.prop` / `obj["k"]` / `obj[i]` / namespace forms (`Math.PI`) / enum member access / private field reads / `Symbol.iterator` fast path. `lower/expr_assign.rs` (330 LOC) handles `=` / compound assigns / property assigns / index assigns / destructuring assigns; depends on `lower_expr_assignment` (now `pub(super) fn`) and the `destructuring::lower_destructuring_assignment` helper. `lower/expr_new.rs` (414 LOC) routes `new C()` calls to user-defined classes, built-in JS classes (Date / Map / Set / RegExp / Buffer / TypedArray*), and dynamic `new (someFn)()` form. **Round 2 — `lower_call.rs` `lower_builtin_new`** (1 new sub-module, 399 LOC moved): `lower_call/builtin.rs` handles `new C()` codegen for built-in classes (Date / Map / Set / Buffer / fetch Headers/Request/Response / mongodb MongoClient / redis Redis / fastify App / ws WebSocketServer / pg Client/Pool / perry/plugin Decimal / AsyncLocalStorage / AbortController / Command). Calling-side promoted to `pub(super) fn`; the parent module imports via the existing `mod builtin` pattern. (`lower_native_method_call` 805 LOC was assessed but skipped — its 20+ helper cross-references make safe extraction much riskier than the leverage warrants for a single PR; deferred to a focused follow-up.) **Round 3 — `lower_expr` Call arm** (1 new sub-module, 3986 LOC moved): `lower/expr_call.rs` is the giant call dispatcher — by far the largest single arm in the codebase, handling Math.* / JSON.* / fetch / native module method dispatch / class static methods / Symbol / Reflect / Proxy / built-in coercions / etc. Required bumping 6 helpers in lower.rs (`extract_typed_parse_source_order`, `resolve_typed_parse_ty`, `try_desugar_reactive_text`, `try_desugar_reactive_animate`, `is_widget_modifier_name`, `is_generator_call_expr`) from private `fn` to `pub(super) fn` so the new sub-module can reach them. **Cumulative `lower_expr` reduction**: 6687 LOC (original) → 624 LOC (~91% reduction). The function is now a thin dispatcher that delegates almost every arm to a focused sub-module. **Cumulative `lower.rs` reduction**: 13591 LOC → 7554 LOC (~44% reduction). **Cumulative `lower_call.rs` reduction across the session**: 7000+ LOC → 5085 LOC (~27% reduction since v0.5.328, combining Tier 1.3 dispatch tables + ui_styling + builtin extractions). **What remains**: `lower_native_method_call` in lower_call.rs (805 LOC, assessed risky in this round), per-target codegen orchestrators in compile.rs (~1200 LOC), `resolve_import` family (~600 LOC), `compute_object_cache_key` + `ObjectCache` (~700 LOC), `build_optimized_libs` + `build_link_command` (~2000 LOC). Each is independently extractable; doing them as focused PRs preserves reviewability. **Verified**: cargo build --release clean; cargo test --workspace 434/0 = baseline; gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline; comprehensive smoke compile exercising Math.*, JSON.parse → array methods chain (`data.map(x => x*2).reduce(...)`), String methods, Object.{keys,values}, console.* with multiple args, class instantiation + chained calls inside .forEach, function call with rest spread (`sum3(...args)`) — all match `node --experimental-strip-types` byte-for-byte. **Cumulative across this session** (v0.5.329-v0.5.339, eleven commits): all 13 plan items shipped as full or partial extractions; lower_expr is now ~91% smaller; the giant arm split is the largest cognitive-load reduction in the entire session. +- **v0.5.338** — Tier 2.3 follow-up: extracts three more `lower_expr` arms from `crates/perry-hir/src/lower.rs` into focused sub-modules. (1) **`expr_function.rs`** (335 LOC) — both `ast::Expr::Arrow` (178 LOC) and `ast::Expr::Fn` (138 LOC) plus a shared `compute_closure_captures` helper that the original arms duplicated verbatim. The Arrow + Fn lowering shares almost all of its logic (parameter destructuring, body lowering with JS function-hoisting, closure capture analysis); the only differences are arrows capture `this` from the enclosing scope while function expressions don't, and arrows allow a single-expression body shorthand. Co-locating them lets the capture analysis become a real shared function instead of being copy-pasted. (2) **`expr_object.rs`** (508 LOC) — the `ast::Expr::Object` arm including its inline `is_closed_shape` predicate. This is the largest single arm extracted so far. The lowered shape depends on whether the literal is a "closed shape" (no spreads, all fixed string keys) — such literals lower to `new __AnonShape_N()` so downstream property access hits the codegen direct-GEP fast path; open-shape literals (spreads, computed keys, getters/setters) fall through to a generic `Object` / `ObjectSpread` HIR node. **Files**: 2 new sub-modules under `lower/`, plus the v0.5.337 `expr_misc.rs`. **lower_expr delta**: 6508 → 5716 LOC (-792 in this commit; 6687 → 5716 cumulative across v0.5.337+v0.5.338 = -971, ~14.5% total reduction). **Unblocked refactors enabled**: the shared `compute_closure_captures` helper is now a clean target for the Tier 4 follow-up that fuses outer `collect_local_refs_stmt` + `collect_assigned_locals_stmt` into one walk (currently runs both separately on the body). **What remains in Tier 2.3**: the biggest arms — `Call` (3986 LOC, by far the largest), `Member` (405), `New` (393), `Assign` (312). Each has its own helper-fn cross-references that need careful coordination; doing them in a single PR would balloon the diff to >5k LOC. **Verified**: cargo build --release clean; cargo test --workspace 434/0 = baseline; gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline; smoke compile exercising arrow-with-capture, function expression, closed-shape object, spread object, computed key, and array-of-objects (`[1,2,3].map(n => ({ id: n, sq: n*n }))`) all match Node byte-for-byte. **Cumulative across this session** (v0.5.329-v0.5.338, ten commits): all plan items have shipped work; Tier 2.3 has now had two rounds of extractions and the pattern is well-established for the remaining bigger arms. +- **v0.5.337** — Tier 2.3 of the compiler-improvement plan (pilot scope): begins splitting the 6,687-line `lower::lower_expr` function in `crates/perry-hir/src/lower.rs` by extracting 8 self-contained AST variants — `Cond`, `Await`, `SuperProp`, `Update`, `Tpl`, `Seq`, `MetaProp`, `Yield` — into a new `lower/expr_misc.rs` sub-module. Each becomes a free `pub(super) fn lower_(ctx: &mut LoweringContext, node: &ast::) -> Result` taking the SWC AST node and returning the same `Result` the original arm produced. Recursion goes through `super::lower_expr`, matching the pattern from Tier 2.1 (`compile.rs` split) and Tier 2.2 (`ui_styling` extracted from `lower_call.rs`). The match arms in `lower_expr` collapse to one-line delegations like `ast::Expr::Cond(cond) => expr_misc::lower_cond(ctx, cond)`. **Pilot rationale**: the extracted 8 are the smallest, well-bounded variants — each between 4 and 64 LOC, none introducing nested helper fns of its own (the original `Update` arm's nested-`match` shape ports cleanly), all using only public methods on `LoweringContext`. The bigger arms (`Call` 3986 LOC, `Object` 479, `Member` 405, `New` 393, `Assign` 312, `Arrow` 178) are followups: each carries cross-references and helper fns that need careful coordination, and a single PR splitting all 32 arms would balloon the diff to >10k LOC. The pilot proves the extraction pattern works without the recursion-vs-borrow-checker wrestling that giant-arm extraction sometimes produces. **Files**: new `crates/perry-hir/src/lower/expr_misc.rs` (222 LOC = 8 helpers + module doc + imports). lower.rs delta: 13599 → 13415 LOC overall (-184); the lower_expr function specifically went 6687 → 6508 LOC (-179, ~2.7%). Net workspace LOC roughly unchanged (extracted code still exists, just in a focused module). The win is cognitive load: each extracted helper is now individually testable, future variant work (e.g. the `Update` arm's PrivateName/Computed branches) doesn't have to scroll past the 6000-line `lower_expr` body. **What's NOT done in the pilot**: the 5 biggest arms remain inline. Each is independently extractable using the same pattern; doing them later as focused PRs avoids one massive diff. **Verified**: `cargo build --release` clean; `cargo test --workspace` 434/0 = baseline; gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline; smoke compile of a TypeScript program exercising all 5 testable extracted variants (`cond`, `update`, `tpl`, `seq`, `yield` — Await/SuperProp/MetaProp don't have easy single-line repros) matches Node byte-for-byte. **Cumulative across this session** (v0.5.329-v0.5.337, nine commits): all 13 plan items shipped including the highest-risk lower_expr split (pilot scope). Tier 2.3 broader rollout is the only remaining followup; everything else from the plan is complete. +- **v0.5.336** — Tier 4 follow-up: completes the remaining three perf items the plan called out (4.3, 4.4, 4.6), now matching the four already shipped in v0.5.335. **4.6 Arc<I18nTable>**: pre-fix `crates/perry/src/commands/compile.rs` cloned the per-module `i18n_snapshot` tuple inside the `par_iter()` codegen loop — every clone duplicated the (potentially large) `Vec` of every translated string × every locale. New `pub i18n_table: Option, usize, usize, Vec, usize)>>` (was the bare tuple) on `CompileOptions`; `i18n_snapshot` is wrapped once at the top of the loop, the per-module clone is now a cheap Arc reference bump. The destructure at `crates/perry-codegen/src/codegen.rs::compile_module` was updated to `arc.as_ref()` deref. The cache-key derivation in `compute_object_cache_key` likewise now derefs through the Arc. Inner `I18nLowerCtx.translations` (codegen-side per-module copy) is still a Vec — wrapping it in Arc too would eliminate the second per-module clone but is a wider refactor tracked as a follow-up. Per-module saving: roughly 1 × `Vec` clone per module per build (was 2). On a project with 30 modules and 1000 translated strings, this saves ~30 redundant Vec allocations + their String contents per compile. **4.4 parallel `.ll` write**: `compile.rs` post-codegen used to `for result in compile_results { fs::write(...) }` — sequential I/O that bottlenecked when codegen finished producing bytes faster than a single thread could drain. Refactored to: (a) sequential partition into `to_write: Vec<(PathBuf, Vec)>` + error reporting (errors print in source order, preserved from pre-fix), (b) parallel write via `to_write.par_iter().map(|(p, b)| fs::write(p, b)).collect()` — the OS handles concurrent writes to distinct paths fine, (c) bail on first I/O error after the par_iter finishes (preserves the "fail fast on disk-full / permission" semantics), (d) sequential print + `obj_paths` collection (so output is grouped not interleaved). Wall-time saving scales with module count and disk-writev parallelism (~2-4x faster on a SSD with 50+ modules, less on slow storage). **4.3 fuse mutable-captures passes**: `crates/perry-hir/src/lower.rs::widen_mutable_captures_stmts` had three back-to-back `for stmt in stmts.iter()` loops, each populating a separate HashSet (`scope_mutable`, `scope_captured`, `scope_assigned_at_level`). Fused into a single iteration that calls all three `collect_*` helpers per statement. The collectors read disjoint Expr/Stmt fields with no ordering dependency, so the union is identical. Saves 2 full Stmt slice traversals per scope; this pass runs over `module.init` + every function body + every class method/getter/setter/static_method/ctor body, so the savings compound on a large project. The mutating pass at the bottom (`widen_mutable_captures_stmt`) still runs separately because it depends on the union of all three sets. **Tier 4 complete**: all six items shipped (4.1 + 4.2 + 4.5 in v0.5.335; 4.3 + 4.4 + 4.6 here). **Verified**: cargo build --release clean; cargo test --workspace 434/0 = baseline; gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline; multi-module #212 closure-capture smoke compile matches Node byte-for-byte (exercises widen_mutable_captures and the parallel codegen + write path). **Cumulative across this session** (v0.5.329-v0.5.336, eight commits): all 11 highest-leverage items in the compiler-improvement plan now shipped except Tier 2.3 (lower_expr split — biggest risk, deliberately left for a focused PR). +- **v0.5.335** — Tier 4 of the compiler-improvement plan (three perf wins): **4.1** fuses two `module.functions.iter()` passes in `perry_transform::inline::inline_functions` (Math.imul polyfill detection + inlinable-function candidate collection) into one iteration, and fuses two `module.classes.iter()` passes (inlinable-method collection + class-name lookup) into one iteration. Saves 2 full module scans per compile; per-compile savings scale with module size. Pre-fix the four scans were back-to-back over the same collections with no ordering dependency between them. **4.2** fuses five `ctx.native_modules.par_iter_mut().for_each(...)` calls in `crates/perry/src/commands/compile.rs` into two. The pre-fix sequence was: (1) `transform_js_imports`, (2) `fix_local_native_instances`, (3) `fix_cross_module_native_instances`, (4) `fix_local_native_instances` (re-run), (5) `monomorphize_module`. Pass A now fuses 1+2 (independent within each module); pass B fuses 3+4+5 (the cross-module step needs the export maps built between the two passes, but its result + the local-fix re-run + monomorphization are all intra-module operations once the maps exist). Saves three rayon scheduler round-trips per compile of a multi-module project. Behavior preserved exactly: the local-fix re-run still runs unconditionally (matching pre-fix semantics for the `has_native_exports = false` branch). The `_jsruntime` and `has_native_exports` gates inside the fused closures keep modules that don't need those passes paying only the cheap branch. **4.5** bounds the in-memory `ParseCache` (used by `perry dev` to skip reparsing unchanged files between rebuilds) at 500 entries with FIFO eviction. Pre-fix the cache was unbounded — a `perry dev` session that walked `node_modules` or any large dir would hold every parsed AST forever (potentially 100+ MB of SWC AST nodes). New `pub const DEFAULT_PARSE_CACHE_CAPACITY: usize = 500` + `ParseCache::with_capacity(n)` constructor for atypical projects (pass `usize::MAX` to opt out of eviction). Implementation: `VecDeque` tracks insertion order; on miss for a brand-new path, if `entries.len() >= max_entries` the front of the order queue is popped and removed from `entries` before insertion. Same-path re-inserts (the common case during edit-rebuild cycles) bypass eviction since the entry count is unchanged. FIFO over true LRU avoids a new `lru` crate dep and the per-hit re-ordering it would need; the perry-dev access pattern (a file's miss → re-insert puts it at the back, files not touched stay at front) makes them functionally equivalent. Two new unit tests pin the eviction invariant: `eviction_caps_entries_at_max_capacity` (insert 6 with cap=3, verify 3 oldest are evicted), `re_inserting_same_path_does_not_count_against_cap` (touch path A multiple times, then B + C with cap=2, verify A is evicted — not B/C). **What's NOT done in Tier 4 this session**: 4.3 (combine three mutable-captures passes in lower.rs — needs careful HIR analysis), 4.4 (parallelize per-module .ll write — small win, depends on file-system parallelism not rayon), 4.6 (`Arc` instead of cloning per worker — small win in already-fast i18n path). Each is independently extractable as a future PR. **Verified**: `cargo build --release` clean; `cargo test --workspace` 434/0 = baseline+2 (the two new parse_cache tests); gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline; multi-module smoke compile (`test_issue_212_class_method_capture.ts` — 10 sub-tests exercising class-method captures, generics, mixed types) matches `node --experimental-strip-types` byte-for-byte. **Cumulative across this session** (v0.5.329-v0.5.335, seven commits): Tier 1.1 + 1.2 + 1.3 + 2.1-partial + 2.2-partial + 3.1 + 4.1 + 4.2 + 4.5 — eight of the eleven highest-leverage items in the compiler-improvement plan now shipped. +- **v0.5.334** — Tier 2.2 of the compiler-improvement plan (scoped pilot): extracts the inline `style: { ... }` destructure family from `crates/perry-codegen/src/lower_call.rs` into a new `crates/perry-codegen/src/lower_call/ui_styling.rs` sub-module. The pre-extraction file was 6373 LOC (post-Tier-1.3); the extracted block is 510 LOC of UI styling logic that clusters together because every helper consumes `extract_options_fields`-shaped objects from the StyleProps interface (issue #185 Phase C lineage). The module exports `apply_inline_style` as `pub(super)`; the 7 internal helpers (`extract_perry_color`, `parse_color_string`, `fmt_float`, `lower_color_with_runtime_fallback`, `extract_padding_sides`, `extract_shadow_obj`, `extract_gradient_obj`) stay private to the module since they're only called from `apply_inline_style`. The two cross-module helpers it consumes (`get_raw_string_ptr`, `extract_options_fields`) stay in `lower_call.rs`'s parent scope and are imported via `super::` — `get_raw_string_ptr` was promoted from private `fn` to `pub(super) fn` for that reason; `extract_options_fields` was already `pub(crate)` so no visibility change. **lower_call.rs delta**: 6373 → 5869 LOC (-504; 8% reduction). When v0.5.332 (Tier 1.3) is combined: 7000+ → 5869 LOC (~16% total reduction since the dispatch tables also moved). Module-level docs explain the issue #185 Phase C lineage so the cluster's purpose is discoverable. **Out of scope (follow-up Tier 2.2 items)**: extracting `lower_native_method_call` (805 LOC) and `lower_builtin_new` (399 LOC) into their own sub-modules — these need more invasive work because they reference many helpers scattered across the file (`apply_field_initializers_recursive`, `lower_abort_controller_call`, `lower_fetch_native_method`, the `perry_*_table_lookup` family, `native_module_lookup`, `lower_native_module_dispatch`). Doing them safely is a separate session. **Verified**: `cargo build --release` clean; `cargo test --workspace` 432/0 = baseline; gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline; UI inline-style smoke compile (`Button("OK", () => {}, { backgroundColor: "#3B82F6", borderRadius: 8, padding: 12, opacity: 0.95 })`) produces a 0.8 MB macOS binary that exits 0 in `PERRY_UI_TEST_MODE=1` — confirms the live styling-destructure path through the new module works end-to-end. **Cumulative across this session** (v0.5.329-v0.5.334, six commits): Tier 1.1 (HIR walker, eliminates `_ => {}` bug class), Tier 1.2 (SSO unbox hygiene, 7 sites), Tier 3.1 (symbol-set strip-dedup), Tier 1.3 (centralized dispatch tables), Tier 2.1 partial (compile.rs split, 3 sub-modules), Tier 2.2 partial (lower_call.rs split, 1 sub-module). +- **v0.5.333** — Tier 2.1 of the compiler-improvement plan (partial): splits the `crates/perry/src/commands/compile.rs` mega-file (9391 LOC pre-split) by extracting three self-contained sub-concerns into a new `compile/` directory. Three new modules: (1) `compile/parse_cache.rs` (194 LOC) — `ParseCache` struct + `ParseCacheEntry` + `parse_cached` fn + the 8-test `parse_cache::tests` mod; this is the in-memory AST cache `perry dev` uses to skip reparsing unchanged files between rebuilds. Pre-split it lived inline at the top of compile.rs alongside the link-command code, with no relationship between them. (2) `compile/strip_dedup.rs` (518 LOC) — `parse_nm_archive_output` + `collect_archive_symbols_by_member` + `collect_archive_symbols_flat` + `strip_duplicate_objects_from_lib` + the 4-test `strip_dedup_tests` mod (the v0.5.331 Tier 3.1 work). Reaches `find_library` / `find_llvm_tool` / `find_stdlib_library` via `super::`. (3) `compile/library_search.rs` (704 LOC) — 20 functions: `find_llvm_tool` (env-var override → rustup sysroot → PATH), MSVC `link.exe` / `lld-link` / Windows SDK probing (`find_msvc_link_exe`, `find_lld_link`, `find_perry_windows_sdk`, `find_msvc_lib_paths`, `xwin_sysroot_lib_paths`, `windows_pe_subsystem_flag`, `msvc_vswhere_installation_path_args`), the static-lib search trio (`find_library_with_candidates`, `find_library`, `collect_library_candidates`), 4 per-lib wrappers (`find_runtime_library`, `find_stdlib_library`, `find_jsruntime_library`, `find_ui_library`), and the geisterhand integration (`find_geisterhand_lib`, `find_geisterhand_library`, `find_geisterhand_runtime`, `find_geisterhand_ui`, `build_geisterhand_libs`), plus the 1-test `windows_toolchain_tests` mod. Module-level docs on each new file explain the boundary and why the concerns belong together. **compile.rs delta**: 9391 → 8057 LOC (-1334; 14% reduction). Net workspace LOC essentially unchanged (the new modules add module headers + imports), but cognitive load drops sharply — link-command construction work no longer churns the same file as the parse cache, and the strip-dedup logic from v0.5.331 has its own discoverable home. **Visibility**: extracted fns become `pub(super)` so the compile.rs orchestrator can still call them; tests stay private to the modules they cover; `ParseCache` is re-exported as `pub` from compile.rs to preserve the existing public API for `perry dev`. **What's NOT split (follow-up Tier 2.x items)**: the codegen orchestrator (`compile_for_*` per-target functions, ~1200 LOC), `resolve_import` and the package-resolution family (~600 LOC), `compute_object_cache_key` + the `ObjectCache` struct (~700 LOC), `build_optimized_libs` + `build_link_command` (the actual link command construction, ~2000 LOC). Each is independently extractable with the same pattern; doing them all in one PR would balloon the diff. **Verified**: `cargo build --release -p perry-runtime -p perry-stdlib -p perry -p perry-ui-macos` clean; `cargo test --workspace` 432/0 (same baseline; 8 parse_cache tests + 4 strip_dedup tests now run from their new module locations); `cargo test --release -p perry --bin perry parse_cache` 8/0; `cargo test --release -p perry --bin perry strip_dedup` 4/0; gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline; UI smoke compile (`PERRY_UI_TEST_MODE=1`) ran end-to-end — strip-dedup output trimmed `libperry_ui_macos.a` 419 → 35 objects (same as v0.5.331), confirming the live path through both new modules works. +- **v0.5.332** — Tier 1.3 of the compiler-improvement plan (pilot): centralises four perry/ui + perry/system + perry/i18n dispatch tables in a new `crates/perry-dispatch` crate so the LLVM, JS, and WASM codegen backends share one source of truth. Pre-fix, adding a single perry/ui method (e.g. issue #191 `CameraView`) required synchronized edits in **four** files: `crates/perry-codegen/src/lower_call.rs::PERRY_UI_TABLE`, `crates/perry-codegen-js/src/emit.rs::emit_ui_method_call`, `crates/perry-codegen-wasm/src/emit.rs::map_ui_method`, and the runtime stubs. Drift was silent — a missing JS arm produced "unknown function" only when a user compiled for `--target web` (or wasm); JS's catch-all even tried to construct `__perry.perry_ui_(...)` which named-mismatched on every camelCase ↔ snake_case rename. New crate exports `MethodRow` + `ArgKind` + `ReturnKind` (verbatim moves from `lower_call.rs`'s `UiSig`/`UiArgKind`/`UiReturnKind`) plus the four tables `PERRY_UI_TABLE` (161 rows), `PERRY_UI_INSTANCE_TABLE` (24 rows), `PERRY_SYSTEM_TABLE` (20 rows), `PERRY_I18N_TABLE` (7 rows). Public helper `pub fn ui_method_to_runtime(method: &str) -> Option<&'static str>` searches UI → INSTANCE → SYSTEM in order and returns the runtime symbol. **LLVM** (`lower_call.rs`): re-exports the perry-dispatch types via local aliases (`UiArgKind = ArgKind`, `UiReturnKind = ReturnKind`, `UiSig = MethodRow`) so the rest of the file compiles unchanged; deletes 600+ lines of inline table data and the type defs. **JS emit** (`crates/perry-codegen-js/src/emit.rs`): `_ => ...` arm now tries `perry_dispatch::ui_method_to_runtime(method)` before the legacy `__perry.perry_ui_(...)` fallback — so any method added to PERRY_UI_TABLE / PERRY_UI_INSTANCE_TABLE / PERRY_SYSTEM_TABLE resolves on `--target web` with no parallel edit. **WASM emit** (`crates/perry-codegen-wasm/src/emit.rs::map_ui_method`): `_ => return "perry_ui_unknown"` becomes `_ => return perry_dispatch::ui_method_to_runtime(method).unwrap_or("perry_ui_unknown")` — the hard-error fallback now only fires for genuinely unknown methods. **Drift acceptance test** (`crates/perry-dispatch/tests/dispatch_drift.rs`, 5 tests): pins that every row in each table is reachable via `ui_method_to_runtime`, that no table has duplicate rows, and that JS + WASM emit source files both reference `perry_dispatch::ui_method_to_runtime` (proof the wiring is in place). The duplicate-row check **caught a pre-existing bug**: `scrollViewSetOffset` and `scrollViewScrollTo` each had two identical rows in PERRY_UI_TABLE — Rust array literals don't warn on duplicates, so the second row was dead code that never matched. Fixed in the same commit. **Per-backend hand-coded match arms preserved** for: (a) snake_case aliases like `app_create` / `vstack_create` that aren't in PERRY_UI_TABLE because the LLVM backend only registers canonical camelCase names; (b) JS-side State / Canvas inline expansions that route through `__perry.stateCreate` etc. instead of a runtime FFI; (c) WASM-side string-method memcalls (charAt, slice, …) that are entirely orthogonal to perry/ui. **What's NOT moved (follow-up)**: the much larger `NATIVE_MODULE_TABLE` (285 rows, different shape with `module`/`has_receiver`/`class_filter` fields for mysql2/pg/redis/etc) and `PERRY_PLUGIN_TABLE` (perry/plugin) stay in lower_call.rs for now — same pattern can be applied later, but this PR's pilot proves the approach with the highest-traffic table. Verified end-to-end: `cargo test --workspace` 432/0 (gained 9 from new perry-dispatch tests + perry-codegen unit tests); gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline; `--target web` smoke compile of a UI program produces a 182 KB WASM-in-HTML bundle with the expected `perry_ui_widget_set_corner_radius` / `perry_ui_button_create` / `perry_ui_text_create` symbols all dispatched correctly through the new lookup. New workspace member; new top-level `perry-dispatch` workspace dependency. Three commits ahead of origin/main combined with v0.5.329-v0.5.331 deliver the entire Tier 1 + Tier 3.1 set from the compiler-improvement plan. +- **v0.5.331** — Tier 3.1 of the compiler-improvement plan: rewrites the staticlib strip-dedup pass in `crates/perry/src/commands/compile.rs::strip_duplicate_objects_from_lib` from the v0.5.319/v0.5.320 name-pattern approach to evidence-based **symbol-set comparison**. Pre-fix the filter dropped staticlib members based on (a) the rlib's first-object name prefix and (b) the bundling staticlib's CGU member names matching `perry_runtime-` / `perry_stdlib-` patterns. Both shapes proved fragile: #181 part B (v0.5.320) had to delete the latter because Cargo re-monomorphizes generics into whichever crate's CGU first triggers them — a `hashbrown::raw::RawTable>::reserve_rehash` instantiation lives in `libperry_ui_gtk4.a`'s bundled `perry_runtime-*` CGU but NOT in standalone `libperry_runtime.a`, so name-pattern dropping silently stripped the only definition and bombed the link with "undefined reference to reserve_rehash". The fix at the time worked but kept the rlib-prefix shortcut, which has the same brittleness: if Cargo's CGU naming format changes, the prefix-match could miss or false-match. New approach: when `llvm-nm` is available, parse `llvm-nm --defined-only --format=just-symbols` output for (1) the rlib (when present), (2) standalone `libperry_stdlib.a`, (3) standalone `libperry_runtime.a` into a flat union of provided symbols; do the same per-member parse for the bundling staticlib; drop a member only if **every** symbol it defines is in the provided union. Members with even one unique symbol — generic monomorphizations, crate-specific Drop impls, etc. — are kept. Members with zero defined symbols (marker TUs, header-only ones) are kept defensively. Two new helpers in compile.rs: `parse_nm_archive_output(stdout)` (pure parser; testable in isolation) and `collect_archive_symbols_by_member(nm_path, archive_path)` (shells out + parses). The parser handles both bare `member.o:` headers and the wrapped `/path/lib.a(member.o):` form some llvm-nm versions emit. **Result on macOS** (`./perry compile docs/examples/.../basic.ts`): trimmed `libperry_ui_macos.a` down from 196 objects → 35 objects (419 → 35; 178 dropped by symbol-subset evidence + 222 by `compiler_builtins` short-circuit which is invariably toolchain-provided). Final binary unchanged at 856K — confirming the linker was already resolving these duplicates, but the trimmed lib is now small enough to feed `llvm-ar`+linker without extra noise. **Falls back gracefully** when llvm-nm isn't installed: `nm_works` probe runs `--version`; on failure, log a warning and route to the legacy `.dll`/`compiler_builtins`/rlib-prefix path. Falls back **safely** (smaller dedup, never over-prune) when nm runs but emits "Unknown attribute kind" errors due to LLVM bitcode version skew (Homebrew llvm@20 vs Rust's bundled LLVM 21.1.8) — the partial symbol set is still a valid subset, just smaller, so worst-case is reduced dedup not bad output. New unit tests in `strip_dedup_tests` mod (4 tests) pin: bare-member-header parsing, archive-wrapper-stripped parsing, empty-member skip, and the v0.5.320 invariant (a member with a unique generic monomorphization MUST be kept even when its name matches an excluded crate). Verified end-to-end: `cargo test --workspace` 423/0; gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline; `cargo test -p perry --bin perry strip_dedup_tests` 4/0; UI smoke compile produces 856K binary that exits 0 in `PERRY_UI_TEST_MODE=1`. `rustup component add llvm-tools-preview` is now the recommended install for full benefit (the bundled `rust-objcopy`/`rust-lld` set doesn't include `llvm-nm` on aarch64-apple-darwin) — the fallback keeps the old behavior when it's missing. +- **v0.5.330** — Tier 1.2 of the compiler-improvement plan: closes seven additional `unbox_to_i64`-on-string-operand SSO bug sites in the same lineage as #214 (`Array.indexOf` SSO crash, fixed v0.5.321). Pre-fix, every site silently miscompiled or segfaulted on short-string-optimization values (≤5-byte ASCII strings produced by `JSON.parse`, `.slice`, `String.fromCharCode`, etc.) because `unbox_to_i64` returns the lower 48 bits of the NaN-boxed double — correct `*StringHeader` for heap strings (STRING_TAG = 0x7FFF) but the inline length+payload bits for SSO values (SHORT_STRING_TAG = 0x7FF9). The runtime then dereferenced those payload bits as `*StringHeader.byte_len` → silent garbage or SIGSEGV. Bug surface: `[1,2,3].join(JSON.parse('"-"'))` returned empty string; `JSON.parse('"abc"').match(/b/)` SIGSEGV'd with exit 139; `obj[ssoKey]` read/write/delete also vulnerable; `process.env[ssoName]` likewise. Audit found seven sites (`crates/perry-codegen/src/lower_array_method.rs:44` and `:70` for `arr.join` / `arr.toString`; `crates/perry-codegen/src/expr.rs:1932` for `obj[stringKey]` IndexGet; `:2393` for IndexSet; `:3720` for the `Expr::ArrayJoin` HIR variant; `:4193` for `delete obj[stringKey]`; `:4521` for `string.match(re)`; `:6426` for `process.env[dynKey]`; `:7100/7120/7132` for crypto digest input — the last three are speculative defense since Perry's crypto chain dispatch is incomplete). Each converted from `unbox_to_i64` to `unbox_str_handle` (which routes through `js_get_string_pointer_unified`, materializing SSO to heap on demand — SSO-aware since v0.5.277). Per-site comments cite the specific runtime fn that derefs the result + the #214 lineage so future contributors can identify the pattern. Doc-comment on `unbox_to_i64` rewritten with a prominent ⚠ warning enumerating the SSO-vulnerable callsite families and pointing to `unbox_str_handle` as the safe alternative. New regression test `test-files/test_issue_214_followup_sso_unbox.ts` exercises 7 of the patterns (the crypto chain isn't covered because the chain itself doesn't fully resolve through Perry's dispatch yet — both literal and SSO inputs return "0" pre-existing) and matches `node --experimental-strip-types` byte-for-byte. Verified: cargo test --workspace 423/0 fails; gap tests 25/28 = baseline; doc-tests --skip-xcompile 80/82 = baseline. The pre-existing CLAUDE.md doc-comment on `unbox_to_i64` ("used by the method dispatch paths and the inline IndexGet/IndexSet/length code") missed the SSO trap entirely; the new doc forces a read of the trade-off before any new caller is added. Future Tier-1.x work: 1.3 LLVM/JS/WASM dispatch table centralization (4-site fan-out → 1-site). +- **v0.5.329** — Tier 1.1 of the compiler-improvement plan: closes the `_ => {}` catch-all bug class that drove issues #167 (Uint8Array params miscompile), #169 (Buffer index UB), #214 (SSO string array segfault), and #212 (class-method capture undercount). Pre-fix, four HIR walkers — `perry_transform::inline::substitute_locals`, `perry_transform::inline::find_max_local_id::check_expr`, `perry_hir::analysis::collect_local_refs_expr`, `perry_hir::analysis::remap_local_ids_in_expr` — each carried their own ad-hoc descent over the 178-variant `Expr` enum, ending in a silent `_ => {}` catch-all. Every new variant added to `ir.rs::Expr` was a runtime miscompile waiting for a user repro: the walker fell through to the catch-all, missed sub-expression descent (or LocalId tracking), and codegen later boxed the unknown as `TAG_UNDEFINED` / let stale ids collide / produced null box pointers. Each of the four cited issues was closed by adding the missed arms after a crash report; the bug class kept recurring because the four-place fan-out had no compile-time enforcement. Fix: new `crates/perry-hir/src/walker.rs` exporting `walk_expr_children_mut` + `walk_expr_children` — the **single source of truth** for "what are the direct sub-expressions of an `Expr` variant?" Both functions match the entire enum **exhaustively** (no `_` arm), so adding a new variant to `ir.rs` without listing it here is a compile error. The four consumer walkers refactored to keep only their explicit handling of LocalId-bearing variants (`LocalGet`, `LocalSet.id`, `Update.id`, `ArrayPush/PushSpread/Pop/Shift/Unshift/Splice/CopyWithin.array_id`, `SetAdd.set_id`, `Closure.captures + .mutable_captures + body`); all other descent delegates to the walker. Net diff: −1,830 lines of duplicated descent, +187 lines of consumer logic, +1,840 lines centralized in `walker.rs` (mut + immutable variants, exhaustively listed). Per-fn collapse: `find_max_local_id::check_expr` 225 → 57 lines, `substitute_locals` 553 → 80, `collect_local_refs_expr` 720 → 70, `remap_local_ids_in_expr` 542 → 85. Side benefit: 9 pre-existing `unreachable_pattern` warnings in `substitute_locals` (duplicate Math/JSON/Coerce arms from organic growth) gone — those duplicates collapsed naturally during the refactor. Walker handles `Closure` by visiting only `Param.default` exprs — body Stmts and `captures`/`mutable_captures` Vecs are consumer-managed because the four walkers want different semantics there (`replace_this_in_expr` skips closures entirely, `substitute_locals` retain_muts captures, `collect_local_refs_expr` uses a `visited` set). Verified: `cargo test --workspace --exclude perry-ui-{ios,windows,visionos,tvos,gtk4,android}` → 423 passed, 0 failed; gap tests 25/28 = pre-refactor baseline (the 3 fails — `array_methods`, `console_methods`, `typed_arrays` — exist independently); `doc-tests --skip-xcompile` 80/82 = baseline (1 pre-existing retina screenshot drift, 1 intentional WASM-only skip); release build of `perry-runtime + perry-stdlib + perry + perry-ui-macos` clean. Future Tier-1.x work: 1.2 `unbox_to_i64` vs `unbox_str_handle` hygiene (closes #214 class for array_method.rs's 29 callsites), 1.3 LLVM/JS/WASM dispatch table centralization. +- **v0.5.328** — Closes #192: wires the `Table` widget through every codegen path so user code calling `Table(...)` / `tableSetColumnHeader` / `tableSetColumnWidth` / `tableSetOnRowSelect` / `tableGetSelectedRow` / `tableUpdateRowCount` resolves on every target instead of failing with "unknown function" at compile time. Pre-fix the runtime impl had landed long ago — `crates/perry-ui-macos/src/widgets/table.rs` (NSTableView + NSScrollView, real cell rendering via the user's render closure resolved as a child Widget handle) plus no-op stubs in every other backend (`perry-ui-{ios,gtk4,windows,android,tvos,visionos,watchos}`) — but no codegen had a dispatch row, so calls reached the receiver-less early-out and silently returned 0.0 / 0. Three-part landing: (1) **LLVM codegen** — 6 new rows in `PERRY_UI_TABLE` (`crates/perry-codegen/src/lower_call.rs`): `Table` → `perry_ui_table_create` (`F64×2 + Closure` args, returns Widget — row count, col count, render callback), `tableSetColumnHeader` → `perry_ui_table_set_column_header` (`Widget + I64Raw + Str` — handle, col index, title), `tableSetColumnWidth` → `perry_ui_table_set_column_width` (`Widget + I64Raw + F64`), `tableUpdateRowCount` → `perry_ui_table_update_row_count` (`Widget + I64Raw`), `tableSetOnRowSelect` → `perry_ui_table_set_on_row_select` (`Widget + Closure`), `tableGetSelectedRow` → `perry_ui_table_get_selected_row` (`Widget` → `I64AsF64`). The `I64Raw` for col indexes / counts matches the macOS FFI signatures (`col: i64`, `count: i64`) — Perry's NaN-boxed JS numbers come in as f64 but the runtime expects i64, so the codegen emits an `fptosi` conversion via the `I64Raw` arm. (2) **JS codegen** (`crates/perry-codegen-js/src/emit.rs`) — 6 new arms routing `Table` and the 5 helper names. WASM emit (`crates/perry-codegen-wasm/src/emit.rs`) already had all 6 entries since they were added when the wasm runtime impl landed; the wasm runtime (`crates/perry-codegen-wasm/src/wasm_runtime.js`) ships HTML ``-backed implementations of all 6 with `_onRowSelect` / `_selectedRow` state cached on the DOM element, so `--target web` works end-to-end. (3) **TS surface + docs** — `types/perry/ui/index.d.ts` adds the 6 declarations under a new "Table (issue #192)" section, with the render callback typed as `(row, col) => Widget` (matches the runtime's actual contract — the callback returns `Text(...)` or any other widget which becomes the cell view, not a string as the original docs implied); `docs/examples/ui/table/snippets.ts` (compile-only with `run: false` since visible behavior needs an attached AppKit run loop) covers basic-table / column-headers / column-widths / row-selection / dynamic-rows / complete-example via 6 anchors; `docs/src/ui/table.md` drops the "not yet wired into any codegen path" callout, replaces it with the real platform-support matrix (macOS + Web wired, others stubbed), converts all 6 `,no-test` fences to `{{#include}}` extracts, and rewrites the rendering examples to use the actual `(row, col) => Widget` shape rather than the never-implemented string-returning shape. Verified end-to-end: cargo build clean; `./perry compile` on the example produces a 0.9 MB macOS binary; `./target/release/doc-tests --filter table` passes 1/1; full doc-tests sweep is 80/82 — same baseline as v0.5.327 plus the new compile-only table example, no regressions. Real-table wiring on iOS / Android / Linux / Windows / tvOS / visionOS / watchOS (UIKit `UITableView`, RecyclerView, GtkColumnView, ListView, etc.) tracked as separate follow-ups — the codegen surface is now stable. The "string ergonomics" extension the issue mentioned (cell renderer returning a plain string) is deferred: the existing Widget-returning shape is more general (lets cells be composites) and `Text(s)` is the trivial wrapper for the string case, so the win from a string fast-path is mostly cosmetic. +- **v0.5.327** — Closes #191: wires the `CameraView` widget through every codegen path (LLVM, JS, WASM) so user code calling `CameraView()` / `cameraStart` / `cameraStop` / `cameraFreeze` / `cameraUnfreeze` / `cameraSampleColor` / `cameraSetOnTap` resolves on every target instead of failing with "unknown function" at compile time. Pre-fix the runtime FFI was implemented in `crates/perry-ui-ios/src/lib.rs` (AVCaptureSession) and `crates/perry-ui-android/src/lib.rs` (Camera2) but no codegen had a dispatch row; calls reached the receiver-less early-out and silently returned 0.0. Five-part landing: (1) **LLVM codegen** — 7 new rows in `PERRY_UI_TABLE` (`crates/perry-codegen/src/lower_call.rs`): `CameraView` → `perry_ui_camera_create` (no args, returns Widget), `cameraStart`/`cameraStop`/`cameraFreeze`/`cameraUnfreeze` → matching `perry_ui_camera_*` setters (Widget arg, Void return), `cameraSampleColor` → `perry_ui_camera_sample_color` (F64×2 args, F64 return — packed RGB or -1), `cameraSetOnTap` → `perry_ui_camera_set_on_tap` (Widget + Closure). (2) **JS codegen** (`crates/perry-codegen-js/src/emit.rs`) + **WASM codegen** (`crates/perry-codegen-wasm/src/emit.rs::map_ui_method`) — 7 new arms in each routing the TS names to the canonical `perry_ui_camera_*` symbols. (3) **WASM runtime stubs** (`crates/perry-codegen-wasm/src/wasm_runtime.js`) — `perry_ui_camera_create` allocates a placeholder `
` so layout slots reserve the same space they would for a real preview, `perry_ui_camera_sample_color` returns -1 (matching the documented "no frame available" sentinel), the rest are no-ops. All 7 registered in `__perryUiDispatch`. (4) **Desktop no-op stubs** added to `perry-ui-macos/src/lib.rs`, `perry-ui-gtk4/src/lib.rs`, `perry-ui-windows/src/lib.rs` — `perry_ui_camera_create` returns 0, `perry_ui_camera_sample_color` returns -1.0, the rest are no-ops. tvOS / visionOS / watchOS already had stubs from earlier audit work. (5) **TS surface** — `types/perry/ui/index.d.ts` adds the 7 declarations under a new "Camera (issue #191)" section with platform-support documentation; `docs/examples/ui/camera/snippets.ts` (compile-only with `run: false` banner since the camera needs hardware permissions) pins every name down so a future rename / drop trips a link error in CI; `docs/src/ui/camera.md` drops the "Status: not yet wired into any codegen path" callout, replaces it with the platform-support matrix (iOS+Android real, others no-op stubs), and converts all 10 `,no-test` fences to `{{#include}}` extracts using anchor markers. Verified end-to-end: `cargo build --release -p perry-runtime -p perry-stdlib -p perry-ui-macos -p perry` clean; `./perry compile --no-link` on the example produces an .o with all 7 `_perry_ui_camera_*` symbols as undefined references (codegen wired); full link produces a 0.9 MB macOS executable that exits 0 in `PERRY_UI_TEST_MODE=1`; `./target/release/doc-tests --filter camera` passes (1/1, 1080 ms compile-only); full doc-tests sweep `./target/release/doc-tests --skip-xcompile` is 79/81 — same baseline as v0.5.325 (the lone fail remains the pre-existing `ui/gallery.ts` retina screenshot drift, the lone skip remains `platforms/wasm_snippets.ts` which opts out of the macOS host run phase). Real-camera wiring on macOS / Linux / Windows / Web (AVFoundation, GStreamer/V4L2, Media Foundation, `getUserMedia`) tracked as separate follow-ups — the codegen surface is now stable and contributors can light up each backend incrementally without touching the dispatcher. Version slot: bumped above origin's v0.5.326 (slider FFI calling-convention fix, parallel-track on origin) per the merge precedent for collisions. +- **v0.5.326** — Fix latent calling-convention mismatch on `perry_ui_slider_create` across all 7 backends. Surfaced as `GLib-GObject-CRITICAL: value "nan" of type 'gdouble' is invalid or out of range for property 'value'` on Linux GTK4 when running `docs/examples/ui/styling/visual_test.ts` (section 10). Root cause: codegen at `crates/perry-codegen/src/lower_call.rs:5094` declares Slider with 3 args (`UiArgKind::F64, F64, Closure`) per the TS surface `Slider(min, max, onChange)` in `types/perry/ui/index.d.ts:224`, but every native backend's FFI shim declared 4 (`min, max, initial, on_change`) and called `widgets::slider::create(min, max, initial, on_change)`. Verified by `PERRY_NO_CACHE=1 PERRY_SAVE_LL=...` dump: emitted IR is `declare i64 @perry_ui_slider_create(double, double, double)` + `call i64 @perry_ui_slider_create(double 0.0, double 100.0, double %r480_callback)`. The 4th `initial` parameter at the FFI boundary therefore reads whatever the SysV ABI's xmm3 holds at the call site (uninitialized → frequently NaN). On macOS / iOS / Android / Windows that NaN flowed into `slider.setDoubleValue(NaN)` → silently clamped to min by AppKit/UIKit/Material/Win32 — no symptom. On GTK4 it flowed into `Adjustment::new(value=NaN, lower=0, upper=100, ...)` → GTK validated the range and emitted the loud warning. Fix: 5 native FFI shims (`crates/perry-ui-{gtk4,macos,ios,android,windows}/src/lib.rs`) drop the `initial` parameter and pass `min` to the internal `widgets::slider::create(min, max, min, on_change)`. Internal `widgets/slider.rs::create` keeps its 4-arg form for any future caller that wants explicit-initial semantics. Web (`crates/perry-codegen-js/src/web_runtime.js:275`) and wasm (`crates/perry-codegen-wasm/src/wasm_runtime.js:2579`) `perry_ui_slider_create` JS functions also drop their `initial` param — pre-fix Web set `el.value = undefined` (browser silently coerced to empty string then probably to min), wasm set `el.value = initial || 0` (worked coincidentally for `[0..N]` ranges, broke silently for `[10..100]`). Default-to-`min` matches the most common pattern (slider starts at the low end of its range) and the cross-backend behavior the existing native impls were already implicitly providing. Verified end-to-end on the visual_test repro: pre-fix `PERRY_UI_TEST_MODE=1 ./visual_test` printed the GLib-GObject-CRITICAL once before exit-0; post-fix the binary exits silent. Full Linux ui/ doc-tests: 44/45 pass / 0 fail / 1 skip (the lone skip is the pre-existing `image_symbol.ts` Linux banner exclusion). No regressions on the 12 other styling examples. Cross-backend FFI signatures now agree with codegen — closes the latent UB across all 7 platforms in one change. Version slot: bumped above origin's v0.5.325 (#219 doc-tests banner fix, parallel-track on origin) per the merge precedent for collisions. +- **v0.5.325** — Closes #219: `docs/examples/platforms/wasm_snippets.ts` was failing every host doc-tests gate (macOS, GTK4, Windows) because its `// platforms: macos, linux, windows` banner enabled the host run phase, but the file's `declare function bloom_init_window/bloom_draw_rect` FFIs only resolve as WASM imports under `--target wasm` / `--target web` — on a native compile the host linker can't find them and bombs with `undefined reference to bloom_init_window`. Two-part fix: (1) `crates/perry-doc-tests/src/main.rs::read_banner` now tracks a `platforms_seen` flag — an explicit empty `// platforms:` directive (no values) suppresses the default-fill of `[macos, linux, windows]`, letting an example opt out of the host run phase entirely. Pre-fix the parser couldn't distinguish "directive absent" from "directive present but empty". (2) `docs/examples/platforms/wasm_snippets.ts` banner switched from `// platforms: macos, linux, windows` to `// platforms:` + `// targets: wasm, web` — the cross-compile phase still drives both targets (catching API drift in `declare function`, `fetch()`'s options shape, and `parallelMap`), but the host phase skips. Verified end-to-end: `./target/release/doc-tests --filter wasm_snippets --verbose` now reports `SKIP platform 'macos' not listed in banner` + `XCOMPILE_PASS target='wasm'` (504 ms) + `XCOMPILE_PASS target='web'` (101 ms); `strings target/perry-doc-tests/platforms_wasm_snippets__wasm.wasm` confirms `bloom_init_window` and `bloom_draw_rect` are present as WASM imports (per the issue's spot-check criterion). Full sweep `./target/release/doc-tests --skip-xcompile` now 78/80 pass with the lone fail being the pre-existing `ui/gallery.ts` retina screenshot baseline mismatch — unchanged from prior baselines, no regression from the parser change. +- **v0.5.324** — Issue #185 follow-up: `box-shadow` on macOS (sections 4 of `docs/examples/ui/styling/visual_test.ts`) was invisible despite the FFI args being correct in the IR dump. Root cause: `crates/perry-ui-macos/src/widgets/mod.rs::set_shadow` set `shadowColor` / `shadowOpacity` / `shadowRadius` / `shadowOffset` on the view's CALayer but never set `setMasksToBounds: false` — and CALayer shadows are clipped by `masksToBounds`. The iOS / tvOS / visionOS mirror impls all set this; the macOS one was missing it. One-line fix: `let _: () = objc2::msg_send![layer, setMasksToBounds: false];` after the four shadow setters. Also adds (1) `docs/examples/ui/styling/visual_test.ts` — single-window comprehensive 13-section visual styling test exercising every styling prop family (colors hex/named/object/alpha/runtime, borders, padding, **shadow** (the case that surfaced the bug), gradient, opacity, typography, buttons, inputs, controls, image symbols, hidden/opacity-0 states, runtime-resolved colors via Phase C step 7); (2) `docs/examples/ui/styling/visual_test.spec.md` — LLM-debuggable per-cell expected-values manifest with 13 numbered sections matching the .ts and a "Visible signature" string per cell so a screenshot can be compared cell-by-cell against the spec; (3) `scripts/run_visual_test_check.sh` — drift check that asserts the .ts's `labeled("N. ...", ...)` calls and the spec's `### N.` headers stay 1:1 in lockstep, fails CI when either file adds/removes a row without updating the other; (4) wired into `.github/workflows/test.yml` after the existing UI styling matrix step. Verified end-to-end via 3 rounds of `shadow_repro.ts` (high-contrast yellow shadow against gray cells) — round 1 was inconclusive low-contrast, round 2 confirmed shadows missing, round 3 (post-fix) confirmed shadows render correctly. Cross-platform compile sanity check: web/wasm + iOS-simulator both produce valid binaries from the same `visual_test.ts` source; tvOS/watchOS sim builds need nightly Rust + `-Zbuild-std` (tier-3 targets), deferred. iOS simulator launch crash observed by user — investigation deferred so Windows/Linux validation can run; the macOS fix here is independently shippable and only touches the macOS UI crate. +- **v0.5.323** — Closes #212: class declared inside a function whose method body references an enclosing-fn local now compiles + runs correctly (pre-fix: `Error compiling module ... lowering body of method 'C::log': ArrayPush(0): local not in scope`). The codegen emitted each class method as a stand-alone Function with no capture wiring, so `class C { log(s) { captured.push(s); } }` inside `function test() { const captured = []; ... }` failed at the LocalGet site for `captured`. Fix is a HIR-level rewrite in `lower_class_decl` (`crates/perry-hir/src/lower_decl.rs`): (1) walk every instance member (methods, getters, setters, constructor) for outer-scope LocalIds via the new `collect_method_captures` helper — same `collect_local_refs_stmt` walk the existing closure capture analysis uses, filtered against a snapshot of `ctx.locals` taken at class-decl-end so inner-closure params (which `collect_local_refs_*` descends through indiscriminately) don't get falsely captured. (2) Add a hidden `__perry_cap_` instance field per captured outer id; field name is keyed off the OUTER id so every method/ctor agrees on which field reads which capture, independent of per-method fresh ids. (3) For each method/getter/setter, allocate a FRESH method-local `LocalId` per captured outer id, run a global LocalId remap on the body (the new `analysis::remap_local_ids_in_stmts_with_field_propagation`, which mirrors `perry_transform::inline::substitute_locals`'s variant coverage so specialized HIR shapes like `Expr::ArrayJoin { array }`, `Expr::ArrayMap`, etc. don't silently fall through and skip the rewrite — the soft-fallback `double_literal(0.0)` for unrecognized LocalIds was producing array handle 0 at runtime), then prepend `Stmt::Let { id: fresh_id, init: PropertyGet(This, "__perry_cap_") }`. The body's existing `LocalGet(outer_id)` now resolves to a method-local slot at codegen. PER-METHOD fresh ids are essential — a `Stmt::Let { id: outer_id }` in a method that has a closure mutating the captured value would mark `outer_id` as boxed *globally* via the module-wide `module_boxed_vars` union, which then makes the outer fn's plain (non-boxed) read of `outer_id` segfault on a `js_box_get` of a non-box pointer. The rebind let preserves the outer's `Type` (looked up in `ctx.locals`) so typed-array fast paths like `out.length` / `out[i]` keep firing instead of falling off into generic by-name dispatch. (4) Constructor: append a fresh-id param per captured outer id, prepend `this.__perry_cap_ = LocalGet(fresh_param_id)` after any `super(...)` call (so derived classes initialize `this` first), and apply the same body-remap. (5) Register the class in a new `LoweringContext::class_captures` registry; the `Expr::New { class_name }` lowering site (`crates/perry-hir/src/lower.rs`) now appends `LocalGet(outer_id)` per captured outer id at every construction site (the outer scope's actual id, since we're lowering inside it). Static methods aren't included because they have no `this` to read captures from — if a static method body references an outer local, the original codegen error fires (out of scope for #212). Mutation note: every top-level `LocalSet(outer_id, v)` and `Expr::Update { id: outer_id, .. }` in a method body is wrapped in a `Sequence` that also writes through to `this.__perry_cap_` via the new `analysis::remap_local_ids_in_stmts_with_field_propagation`, so subsequent method calls re-reading the field see the latest value (the canonical `set value(v) { stored = v; } get value() { return stored; }` pattern works — getter after setter sees the just-written value). Mutations still don't propagate back to the OUTER scope's slot — `stored` in the enclosing function still reads its own original snapshot — that's the residual JS divergence. The propagation only fires at top-level expression positions (statement, return value, condition); deeply-nested captured writes like `(stored = v).toString()` only update the local copy. Reference-type mutation (array.push, obj.x = ...) works regardless because the method-local copy and the outer binding hold the same reference. Side effect: removed the v0.5.317 silent-drop guard for `[Symbol.dispose]` / `[Symbol.asyncDispose]` methods at `lower_decl.rs:744` — the dispose family was being dropped when nested in a function and capturing outer locals (the `disposed: string[]` pattern in `test_gap_async_advanced.ts`), which is now redundant: the same hidden-field rewrite that lets `log() { captured.push(...) }` work also lets `[Symbol.dispose]() { disposed.push(this.label); }` work. Inheritance: `class Derived extends Base` where Base has captures works too — at child class lowering, the parent's `__perry_cap_` field declarations are detected via `lookup_class_field_names` and skipped in the child's `fields` (otherwise the keys array would carry two same-named entries at different offsets, the parent's method body would read its index while the child's ctor wrote to the child's index — the inherited-class-shared-capture bug). The child's synthesized ctor still takes the inherited capture as a param + emits `this.__perry_cap_ = LocalGet(param)`; the runtime's by-name PropertySet writes to the (single) parent-declared field. For disjoint-capture inheritance (Base captures one local, Derived captures a different one), the child's lowering unions the parent's `class_captures` registry into its own captures_vec — without this, the child's synthesized ctor wouldn't take the parent's capture as a param and the parent's field would never initialize. New regression test `test-files/test_issue_212_class_method_capture.ts` (10 cases — issue's exact repro, multi-capture, user-written ctor with capture-using body, multiple instances per outer-fn invocation, nested closure inside capturing method, dispose-hook pattern from #154, shared-capture inheritance, disjoint-capture inheritance, captured-primitive reassignment via setter/getter, async method with capture — all byte-for-byte against `node --experimental-strip-types`). Existing `test_issue_154_using_dispose.ts` also still passes byte-for-byte; gap tests still 26/28 (no regressions); doc-tests 79/80 (the lone fail is the pre-existing retina screenshot baseline mismatch in `ui/gallery.ts`). `analysis::remap_local_ids_in_stmts` is a new public HIR helper documented to require a new arm whenever a HIR variant carrying a LocalId-bearing sub-expr is added — same class of bug as the one fixed here. Version slot: bumped above v0.5.322 (GTK4 FFIs, #216/#217/#218) per the merge precedent for collisions — origin's pull-and-cherry-pick on top of my v0.5.322 commit ate it before push, so this re-applies as v0.5.323. +- **v0.5.322** — Closes #216, #217, #218: 7 GTK4 FFIs that the doc-tests gate on Linux CI was tripping over. Each issue's symptom was an `undefined reference to perry_ui_` link error against `libperry_ui_gtk4.a` for an FFI the macOS twin already wired. (1) **#216** `perry_ui_open_folder_dialog` (`docs/examples/ui/dialogs/snippets.ts`): factored `crates/perry-ui-gtk4/src/file_dialog.rs::open_dialog` into a private `open_dialog_with_action(callback, title, action, accept_label)` helper, then added `open_folder_dialog` that calls it with `FileChooserAction::SelectFolder` + "Choose Folder" / "Choose" labels. Same callback contract as `open_dialog`: NaN-boxed string on success, `TAG_UNDEFINED` (0x7FFC_0000_0000_0001) on cancel — matches the macOS `NSOpenPanel`-with-`canChooseDirectories: YES, canChooseFiles: NO` shape. (2) **#217** 4 widget-tree FFIs (`docs/examples/ui/layout/snippets.ts`): all in `crates/perry-ui-gtk4/src/widgets/mod.rs`. `widget_remove_child` dispatches by parent kind (`Box::remove`, `ScrolledWindow::set_child(None)`, `Overlay::set_child(None)` for main child or `remove_overlay` for overlays, `Frame` inner-box `remove`); the WIDGETS vec doesn't shrink because handles are positional indices, only the GTK4 parent link is severed (matches `removeFromSuperview`). `widget_reorder_child(parent, from_index, to_index)` snapshots `Box` siblings via `first_child()` walk, locates the child at `from_index`, then calls `Box::reorder_child_after(&child, anchor)` where the anchor index is `to` when moving forward or `to-1` when moving back (the macOS `arrangedSubviews + insertArrangedSubview:atIndex:` shape doesn't translate directly because `reorder_child_after` is positional-relative-to-sibling, not absolute-by-index). Out-of-range indices are clamped: `from > N-1` → no-op, `to >= N` → moves to end. `widget_add_overlay`: if parent is `gtk4::Overlay` → `add_overlay`; otherwise log a warning + fall through to `add_child` (GTK4 widgets have a single immutable parent slot — can't retroactively wrap a non-Overlay parent). `widget_set_overlay_frame(handle, x, y, w, h)`: GTK4's layout model is constraint-based not absolute-frame, so we approximate with `halign/valign = Start` + `set_margin_start(x)` + `set_margin_top(y)` + `set_size_request(w, h)` — works correctly when the parent is a ZStack (which is backed by `gtk4::Overlay` and honors child halign/valign), an OK approximation elsewhere. (3) **#218** SplitView (`docs/examples/ui/layout/snippets.ts`): new `crates/perry-ui-gtk4/src/widgets/splitview.rs` backed by `gtk4::Paned::new(Orientation::Horizontal)` (matches macOS `NSSplitView`'s vertical-divider/side-by-side default). `splitview_create(_left_width)` accepts the macOS arg for signature parity but ignores it — `Paned::set_position` is a separate setter. `splitview_add_child`: first call → `set_start_child`, second → `set_end_child`, third+ → no-op + warning. **2-vs-N caveat**: GTK4 `Paned` supports exactly 2 children where `NSSplitView` supports N; users wanting >2 panes on Linux nest SplitViews recursively. Verified: `cargo build --release -p perry-ui-gtk4` clean (only pre-existing `js_string_from_bytes` signature redeclaration warnings between `audio.rs` and `lib.rs`); all 7 new symbols present in `nm --defined-only target/release/libperry_ui_gtk4.a` (`perry_ui_open_folder_dialog`, `perry_ui_widget_remove_child`, `_reorder_child`, `_add_overlay`, `_set_overlay_frame`, `perry_ui_splitview_create`, `_add_child`); `cargo run --release -p perry-ui --bin styling-matrix -- --check` clean (none of these FFIs are styling props — folder dialog is "system", layout/splitview are container management — so no matrix rows are added); `PERRY_UI_TEST_MODE=1 ./perry compile docs/examples/ui/dialogs/snippets.ts -o /tmp/dialogs_snippets` produces a 0.9 MB ELF that exits 0 in test mode; same for `docs/examples/ui/layout/snippets.ts` → 1.0 MB ELF, exit 0. The layout snippet emits the documented `widget_add_overlay on non-Overlay parent — falling back to add_child` warnings at runtime because the example uses `widgetAddOverlay` on an HStack/VStack rather than a ZStack — the warning surface I added makes this discoverable so example authors can wrap with ZStack to get true float-above semantics on GTK4 (separate sample-update follow-up; not blocking the link gate the issues filed for). +- **v0.5.321** — Closes #214: `string[]` element access miscompile when elements were SSO (short-string-optimization) values from `JSON.parse`. Two reported symptoms shared one root cause: (1) `arr.indexOf(s)` returned -1 for present elements; (2) `arr[i]` "SIGSEGV'd at 0x10000000028" — really the next consumer (string concat, ===, .toUpperCase()) treating the SSO bits as a `*StringHeader`. Pre-fix, the codegen emitted `bits & POINTER_MASK_I64` for any "I have a NaN-boxed string handle, need a `*StringHeader`" call site, which returns the lower 48 bits — correct for STRING_TAG (0x7FFF) heap strings, but for SHORT_STRING_TAG (0x7FF9) values those 48 bits encode the inline len + payload, not a pointer. JSON.parse emits SSO inline for strings ≤5 bytes (v0.5.216), so `JSON.parse('["hello", "world"]')[0]` returned SSO bits, then any string consumer dereferenced the inline bytes as a `*StringHeader` → segfault at the inline-payload-derived address (the user's "$" repro produced 0x10000000028 because SSO("$") → lower-48 = 0x010000000024, +offset 4 = 0x010000000028). For indexOf, `js_array_indexOf_f64` did raw bit compare, so SSO array element bits never matched the heap-string needle bits → -1. Fix in 5 codegen sites: (a) new `unbox_str_handle(blk, val)` helper in `crates/perry-codegen/src/expr.rs` calls `js_get_string_pointer_unified` (already SSO-aware — has been since v0.5.277, materializes SSO to heap on demand). (b) Both `Array.indexOf` arms (`crates/perry-codegen/src/lower_array_method.rs:293` + `crates/perry-codegen/src/expr.rs::Expr::ArrayIndexOf`) flip from `js_array_indexOf_f64` → `js_array_indexOf_jsvalue` (mirrors what `includes` already did at line 276). New `js_array_indexOf_jsvalue` extern in `runtime_decls.rs`. (c) String concat fast paths (`lower_string_concat`, `lower_string_self_append`, `lower_string_coerce_concat`) use `unbox_str_handle` for string operands. (d) Both-strings === / !== / loose eq arm (`expr.rs:1495`) and both-strings < / > / <= / >= arm (`expr.rs:1522`) use `unbox_str_handle`. (e) Every `unbox_to_i64(blk, &recv_box)` / `unbox_to_i64(blk, &)` site in `crates/perry-codegen/src/lower_string_method.rs` (38 occurrences across slice/substring/charAt/repeat/replace/at/charCodeAt/codePointAt/lastIndexOf/padStart/padEnd/normalize/localeCompare/search/match/matchAll/isWellFormed/toWellFormed/concat/substr/startsWith/endsWith/includes) now uses `unbox_str_handle`. The runtime perf cost is one extra function call per string operand (`js_get_string_pointer_unified` returns the heap pointer in ~1 ns when STRING_TAG, materializes when SSO); the call is dwarfed by the runtime work that follows in every case. The slice-no-end-arg length-read at `lower_string_method.rs:81` also routed through `unbox_str_handle` so length reads on SSO receivers materialize first. New regression test `test-files/test_issue_214_string_array_sso.ts` covers indexOf / array-index-into-concat / strict equality / != / .toUpperCase() / .length / .startsWith / .indexOf / push-then-indexOf, all byte-for-byte against `node --experimental-strip-types`. Gap-test side-effect: `/tmp/run_gap_tests.sh` jumps from the documented 18/28 baseline → 26/28 — 8 previously-failing tests (`array_methods`, `async_advanced`, `error_extensions`, `fetch_response`, `global_apis`, `map_set_extended`, `object_methods`, `string_methods`) were all symptoms of this same SSO unboxing bug, fixed transitively. Remaining 2 (`console_methods` ci-env diff, `typed_arrays` categorical gap) unrelated. Version slot: bumped above v0.5.319/v0.5.320 (#181 Linux GTK4 link + strip-dedup, parallel-track on origin) per the merge precedent for collisions. +- **v0.5.320** — #181 part B (Linux strip-dedup over-prune): the `strip_duplicate_objects_from_lib` filter at `crates/perry/src/commands/compile.rs:985-986` was dropping every staticlib member whose name matched `m.contains("perry_runtime-")` or `m.contains("perry_stdlib-")` on the assumption that the standalone `libperry_runtime.a` / `libperry_stdlib.a` would re-supply those objects' symbols. That assumption is false for generic monomorphizations. When `perry-ui-gtk4` is built as a staticlib, Cargo re-monomorphizes generics (e.g. `hashbrown::raw::RawTable::reserve_rehash` for GTK4-specific HashMap key/value types) into whichever crate's CGU first triggers the instantiation — sometimes a `perry_runtime-*` CGU bundled inside `libperry_ui_gtk4.a`. Standalone `libperry_runtime.a` (which has only ever seen perry_runtime's *own* type instantiations) doesn't define those GTK4-specific monomorphizations, so dropping the staticlib's bundled `perry_runtime-*` CGU silently removes the only definition. Link bombs with `undefined reference to hashbrown::raw::RawTable::reserve_rehash` — exactly the Arch Linux comment on issue #181. The drop was added 2026-03-26 (commit 7a2e27ca) to placate lld-link's Windows-cross-compile duplicate-symbol rejection, but `strip-dedup` is now skipped entirely on Windows (see `is_windows` guard at the call site), making the filter dead-weight that only hurts Linux/macOS where `--allow-multiple-definition` (ELF) / `ld64` first-wins (Mach-O) handle real duplicates safely. Fix: delete the two `m.contains("perry_runtime-")` / `m.contains("perry_stdlib-")` early-returns. Reproduced locally on Fedora 43 by setting `PERRY_LLVM_AR` so strip-dedup actually runs (default search path doesn't pick up `/usr/lib64/rocm/llvm/bin/llvm-ar`); pre-fix produced the issue's exact `reserve_rehash` undefined reference, post-fix produces a 0.9 MB ELF that links cleanly with one extra `perry_runtime-*` CGU retained (152 → 152 final objects, kept count 135 → 136). Verified the missing symbol `reserve_rehash17h1c96b8c6ae9855b6E` lives in `perry_runtime-4d338a77f5f2cc14.perry_runtime.838b7969b3e5446c-cgu.0.rcgu.o` inside libperry_ui_gtk4.a but is *not* present in standalone libperry_runtime.a (`llvm-nm --defined-only` across all 352 members returns 0 hits) — confirming the dead-symbol-via-pattern-drop diagnosis. Combined-mode regression also verified: `PERRY_LLVM_AR=… PKG_CONFIG_PATH=/nonexistent` exercises both Bug A (v0.5.319) and Bug B (this commit) at the same time and produces a clean executable. +- **v0.5.319** — #181 part A (Linux/Ubuntu GTK4 link): the `pkg-config --libs gtk4` invocation in `crates/perry/src/commands/compile.rs:7142` only had a fallback for the spawn-failure case (`if let Ok(out) = ... { if out.status.success() { use it } } else { fallback }`). When pkg-config existed but `gtk4.pc` wasn't on its search path (status != 0), the code silently emitted no GTK link flags and the linker bombed with hundreds of `g_object_unref` / `gtk_widget_set_*` / `g_signal_connect_data` / `gtk_window_*` undefined references — exactly the failure shape from issue #181. Fix: track success via a `got_gtk_libs` flag, fall back when *either* spawn fails *or* status != 0, print a warning so the user knows pkg-config didn't help, and expand the fallback list from the original 4 libs (`-lgtk-4 -lgobject-2.0 -lglib-2.0 -lgio-2.0`) to the 11 libs `pkg-config --libs gtk4` actually returns on a standard libgtk-4-dev install (adds `-lpangocairo-1.0 -lpango-1.0 -lharfbuzz -lgdk_pixbuf-2.0 -lcairo-gobject -lcairo -lgraphene-1.0` — pre-fix the short fallback would still leave pango/cairo/gdk_pixbuf/graphene refs undefined). Reproduced locally on Fedora 43 by forcing `PKG_CONFIG_PATH=/nonexistent PKG_CONFIG_LIBDIR=/nonexistent` — pre-fix produced the exact issue's error signature, post-fix produces a 0.9 MB ELF executable with one informational warning. Happy path (pkg-config finds `gtk4.pc`) is unchanged. +- **v0.5.318** — Closes #98 (warm-path): `notificationOnBackgroundReceive(cb: (payload: object) => Promise): void` for remote notifications delivered while the app is backgrounded (or terminated, on iOS). New TS export in `types/perry/system/index.d.ts` + dispatch row in `crates/perry-codegen/src/lower_call.rs::PERRY_SYSTEM_TABLE` routing to `perry_system_notification_on_background_receive`. **iOS** (`crates/perry-ui-ios/src/`): new `application:didReceiveRemoteNotification:fetchCompletionHandler:` delegate method (`app.rs`); `notifications.rs` adds `dispatch_remote_payload_with_completion` that JSON-decodes the userInfo NSDictionary, invokes the user callback, and gates the iOS completion block on the returned Promise. The block is `Block_copy`'d via `RcBlock::copy` so it outlives the delegate stack frame; a per-handle id stored in `PENDING_COMPLETIONS: HashMap>` lets a Promise.then trampoline (`perry_ios_notification_completion_trampoline`) look the block back up when the user's Promise settles. Trampoline is itself a real Perry closure constructed via `perry_runtime::closure::js_closure_alloc` with two i64 captures (handle, result-code), so it slots into `perry_runtime::promise::js_promise_then`'s `ClosurePtr` API directly — no second runtime path needed. Three terminal cases: no callback registered → `.NoData` immediately; callback returned a Promise → `.NewData` on resolve, `.Failed` on reject; callback returned synchronously → `.NewData` immediately. **Android** (`crates/perry-ui-android/src/`): new `notification_on_background_receive` registers via `crate::callback::register` keyed off `NOTIFICATION_BACKGROUND_RECEIVE_KEY`; new `Java_com_perry_app_PerryBridge_nativeNotificationBackgroundReceive` JNI dispatches through the same JSON-payload shape as the foreground handler and pumps microtasks up to 8 times after the call so synchronously-attached `.then` chains fire before the FCM service returns. `PerryFirebaseMessagingService.kt::onMessageReceived` now calls *both* `nativeNotificationReceive` and `nativeNotificationBackgroundReceive` so users who register one or both handlers see the right shape; FCM doesn't natively split foreground vs background at the service layer (unlike iOS's two delegate methods), so dual-fire is the closest match. **Stub platforms** — macOS, tvOS, visionOS, watchOS, GTK4, Windows: no-op `perry_system_notification_on_background_receive` exports added so cross-platform user code compiles + links. **Out of scope (#98 follow-ups)**: (1) cold-start on Android (FCM waking a terminated app — `UnsatisfiedLinkError` branch in the FCM service still drops the message; needs `Application.onCreate` to load the native lib at process spawn). (2) iOS cold-start boot benchmark. (3) Doze-mode cooperation analysis on Android. (4) An equivalent of UIBackgroundFetchResult on Android (no native equivalent — FCM service runtime budget governs how long async chains have to complete after the JNI call returns). Compile-link verified across 6 non-Windows perry-ui-* crates; iOS builds cleanly for `aarch64-apple-ios-sim`; perry-ui-windows pre-existing cross-compile-from-macOS issue (text.rs:332 `windows` crate resolution) is unrelated. Snippet added to `docs/examples/system/snippets.ts` (`// ANCHOR: notification-background`) demonstrating the canonical pattern: persist payload via `preferencesSet` then optionally hit a server with `await fetch(...)`. +- **v0.5.317** — Closes #154: `using` / `await using` (ES2024 explicit resource management). Pre-fix, Perry parsed the binding form but lowered it as a plain `const` — `[Symbol.dispose]()` / `[Symbol.asyncDispose]()` hooks never ran, and the `Symbol.dispose` / `Symbol.asyncDispose` accessors weren't recognized as well-known symbols. Three pieces: (1) `symbol_well_known_key` (`crates/perry-hir/src/lower_decl.rs`) and the `Symbol.` member-expression lowerer (`crates/perry-hir/src/lower.rs:9264`) extended to recognize `dispose` / `asyncDispose` — same `@@__perry_wk_` SymbolFor pattern as the existing `iterator` / `toPrimitive` family. (2) `lower_class_method` renames computed-key methods `[Symbol.dispose]` → `__perry_dispose__` and `[Symbol.asyncDispose]` → `__perry_async_dispose__` — stable string-keyed names so the using-block desugarer can dispatch via plain method-call. (3) New `lower_stmts_using_aware` helper in `lower_decl.rs` rewires `lower_block_stmt` / `lower_block_stmt_scoped`: when a using-decl is found, lowers its bindings via the existing `lower_var_decl_with_destructuring` (so `Type::Named("Resource")` flows through and static class-method dispatch fires), then recursively lowers the remaining statements as a try-body wrapped in N nested `Stmt::Try { catch: None, finally: ... }` — one per binding, innermost-first — so disposal runs in reverse declaration order. Each finally checks `id !== null && id !== undefined` before calling the dispose method (per spec). For `await using`, the call is wrapped in `Expr::Await`. Class-method-captures-enclosing-fn-local has its own pre-existing codegen gap (filed as #212): the dispose method is silently dropped when `ctx.scope_depth > 0` AND the body references locals not in `own_locals` — preserves the pre-fix "test compiles, produces no disposed output" baseline for `test_gap_async_advanced.ts`'s class-in-async-fn pattern instead of newly breaking compile. Module-level classes (the canonical pattern) work end-to-end. New regression test `test-files/test_issue_154_using_dispose.ts` covers sync `using`, `await using`, multi-binding (`using a = e1, b = e2, c = e3` — rightmost disposes first), null-skip, byte-for-byte against `node --experimental-strip-types`. SuppressedError chaining (when body throws and a disposer also throws) is out of scope for v1 — Perry's existing `try { ... } finally { ... }` (no catch) doesn't currently re-propagate exceptions to outer catches anyway, so even spec-compliant disposer-error wrapping would behave the same. +- **v0.5.316** — Closes #167: silent SIGSEGV in tight `buf.readInt32BE(i*4)` / `buf.writeInt32BE(...)` loops past ~250k–300k iterations on macOS arm64 (8 MB stack). Two `alloca [N x double]` sites in `crates/perry-codegen/src/lower_call.rs` (the `js_native_call_method` dispatch at line 1755 + the class-dispatch fallback at line 1203) emitted into whatever basic block `ctx.block()` / `blk` currently represented. When the call site lived inside a loop, that's the loop body — and LLVM lowers a non-entry-block alloca as a runtime `sub %rsp, N` with no matching `add %rsp, N`, so every iteration permanently shrank the stack by 16 bytes (the args-array size, AArch64 16-byte-aligned). At ~250k–500k iterations the stack was exhausted → SIGSEGV with no error output (the crash happens after a flushed `console.log`, exit code propagates as 0 through shell pipes — easy to miss). Math from the issue's investigation: write loop (2-arg call) + read loop (1-arg call) × 16 bytes/iter × 250k each ≈ 8 MB → survives barely; 300k → SIGSEGV. Fix: new `LlFunction::alloca_entry_array(elem_ty, count)` helper in `crates/perry-codegen/src/function.rs` (4 LOC, mirrors the existing scalar `alloca_entry`); both call sites now hoist the args-array alloca to the function entry block where it's executed exactly once at function prologue. Verified end-to-end on the issue's repro: pre-fix N=300k crashed silently after "fill ok"; post-fix N=100k / 300k / 500k / 1M / 2M all complete cleanly with correct sums. New regression test `test-files/test_issue_167_loop_alloca_stack_eat.ts` runs N=500k (decisive pre-fix failure, <100 ms post-fix). No runtime change — pure codegen IR-emission fix that affects every dynamic method-call site routing through `js_native_call_method` (Buffer / Uint8Array numeric ops, Map / Set methods on plain object fields, any user-class method call where the receiver class id misses the static dispatch tower). +- **v0.5.315** — Closes #106 properly: `--target watchos-simulator --features watchos-game-loop` now links cleanly even when no native lib is configured. Pre-fix the runtime-only path failed with `Undefined symbols: _perry_register_native_classes / _perry_scene_will_connect` because `crates/perry-runtime/src/watchos_game_loop.rs` declared both as `extern "C"` imports and called them unconditionally from `main()` and the fallback `applicationDidFinishLaunching` — but no object in the runtime's own .a archive exported them, so the linker had nothing to resolve against. The original v0.5.114 commit (4b297092) shipped the contract assuming Bloom-style native libs would always be linked alongside; users who tried the issue's literal acceptance test with no native lib hit the wall and the issue stayed open. Fix: add weak no-op fallbacks via `core::arch::global_asm!` at the top of `watchos_game_loop.rs` — `.weak_definition _perry_register_native_classes` + `.weak_definition _perry_scene_will_connect`, both single-`ret` arm64 stubs (no params read since they take c_void). Mach-O resolution rule: weak symbol + strong symbol → strong wins, so any native lib's strong impls override these defaults at link time. With no native lib, the .app bundle now produces correctly (`/tmp/WatchOSGameLoopTest.app/WatchOSGameLoopTest` — 795 KB Mach-O arm64, exports `__perry_user_main` + the two weak stubs); add Bloom and the FFI hooks become live without touching anything else. Also, one-line UX cleanup at `crates/perry/src/commands/compile.rs:8602` — added `!is_watchos` to the strip-skip condition (the watchOS bundle code at line 8330 moves `exe_path` into the .app and removes the original, so the post-link `strip exe_path` was always emitting a noisy `can't open file` error after the success-path "Wrote watchOS app bundle" message). iOS has the same extern-without-fallback shape but isn't fixed here — separate scope; iOS users hitting the acceptance test always plumb in a native lib (Bloom/etc.), and a follow-up can mirror this pattern at `ios_game_loop.rs` if anyone reports the same UX gap there. +- **v0.5.314** — Closes #169: SIGTRAP when mixing Buffer + Uint8Array-typed function params. `substitute_locals` in `crates/perry-transform/src/inline.rs` had no arms for `Uint8ArrayGet`/`Uint8ArraySet`/`Uint8ArrayLength`/`Uint8ArrayNew(Some)` — they fell through to `_ => {}` at line 1795, so inlining a function taking a Uint8Array param left a stale `LocalGet(param_id)` in the body. Codegen's soft fallback boxed the unknown id as `TAG_UNDEFINED` (`expr.rs:702-720`), unbox-pointer reduced it to address 1, `safe_load_i32_from_ptr` returned len=0, and the slow-path bounds check fired `@llvm.assume(i1 false)` — UB → trap. Repro from issue prints `param sum: 1207680` then traps with exit 133 in `firstBytes(big)`. Fix: 4 arms in `substitute_locals` after the existing `BufferIndexGet`/`BufferIndexSet` arms (line 1761). Also mirrored the same arms in `find_max_local_id::check_expr` (line 604, latent ID-collision bug — `find_max_local_id` was undercounting fresh-id high-water mark when LocalGet was nested inside Uint8ArrayGet, which would have surfaced as collision-induced miscompiles in larger programs) and `inline_calls_in_expr` (line 953, missed-optimization — Calls nested inside `buf[clamp(i)]`-style index expressions weren't being inlined). New regression test `test-files/test_inline_uint8array_param.ts` covers all four shapes (Get / Length / Set / Buffer→Uint8Array param mix) plus the original issue's reverse ordering, compares byte-for-byte against `node --experimental-strip-types`. No runtime or codegen changes — pure HIR transform pass fix. +- **v0.5.313** — Fix non-unwinding panic in `Button` inline `color:` style (regression from Phase C step 6/7). The codegen at `crates/perry-codegen/src/lower_call.rs:3231-3247` dispatches every `color: ...` inline-style prop to `perry_ui_text_set_color` for all widgets, with a comment claiming "no-op on widgets that ignore it." But `crates/perry-ui-macos/src/widgets/text.rs::set_color` did an unchecked `(view as *const NSTextField)` cast and called `setTextColor:`. On `NSButton` that selector doesn't exist — ObjC raises `unrecognized selector`, objc2 panics across the FFI boundary, the panic is non-unwinding (`extern "C"`) and the process aborts. Repro: `Button("X", () => {}, { color: "white" })`. Surfaced by `docs/examples/ui/styling/{hex_gradient,dynamic_color}.ts` failing the doc-tests harness with `thread caused non-unwinding panic. aborting.`. Fix: `set_color` now probes the widget's runtime class via `isKindOfClass:` — `NSButton` routes to `button::set_text_color` (which exists and works), `NSTextField` follows the original code path, anything else silently no-ops (matching the codegen comment's stated intent). Same `AnyClass::get(c"NSButton") + msg_send![view, isKindOfClass:]` pattern already used in `widgets/mod.rs::perry_ui_read_widget_value` for the geisterhand widget-value reader. Doc-tests now 78/79 pass (up from 76/79); the lone fail remains the pre-existing `ui/gallery.ts` retina baseline mismatch. iOS / tvOS / visionOS / watchOS likely have the same shape — left untouched here since they're not exercised by the doc-tests harness on a macOS host; should be a follow-up sweep using the same class-probe pattern. +- **v0.5.311** *(origin parallel-track — PR #211)* — Issue #209: `file:` deps inside cwd no longer strip FFI imports on `--target web`. Two-part fix in `crates/perry/src/commands/compile.rs`: (1) new `find_file_dep_in_package_json` fallback in `resolve_import` after the `node_modules` walk fails — reads the project's `package.json` for `"": "file:"` entries and resolves directly against the package.json directory, sidestepping the symlink chain that the pre-fix logic depended on; (2) `is_in_compiled_pkg` extension in `collect_modules` so files in a perry.nativeLibrary package reached via an inside-cwd file: dep are still classified for native compilation. Verified end-to-end on `Bloom-Engine/jump` + `engine` repos: with `vendor/bloom -> /tmp/engine` and no `node_modules/bloom` symlink, pre-fix produced `Found 1 module(s)` + 11 bloom_* imports + 7 unresolved-import warnings; post-fix produces 10 modules + 159 imports — matches `file:../engine/` outside-cwd baseline exactly. Version slot collides with local v0.5.311 (Phase C step 7); kept under origin's slot since both code paths landed under that patch. Closes #209. +- **v0.5.310** — Merge `origin/main` into local: integrates parallel-track commits #196 / #197 / #200 / #201 (Linux jsruntime test fix, json_polyglot RSS extractor, doc-tests `--app-bundle-id` for widget cross-compile [closes #194], HIR error on widget method-chain modifier syntax [closes #195]) on top of the local Phase B + Phase C + docs-audit stack. Both branches independently used v0.5.297 and v0.5.298 for unrelated work — the version-slot collision is resolved in favor of local for the styling/i18n surface (Phase A+B + i18n wrappers) and origin's entries are kept below as parallel-track entries with explicit *(origin parallel-track)* annotation. `widgets/components.md` taken from origin (the `Text("hi", { font })` inline-options form #201 chose closes #195 properly). Cargo.toml + CLAUDE.md "Current Version:" both bump to 0.5.310 above all colliding slots. +- **v0.5.312** — Issue #185 docs + tracking issues for the remaining platform-specific gaps. (1) Filed issue [#210](https://github.com/PerryTS/perry/issues/210) tracking the **Windows deferred-paint family** — 5 stubs (`shadow`, `opacity`, `border_color`, `border_width`, `text.decoration`) that share one blocker (custom `WM_PAINT` rendering pass for child controls). Mirrors the existing `apply_corner_radius` deferred-paint shape at `crates/perry-ui-windows/src/widgets/mod.rs:843`; per-stub `apply_*` functions follow once the WM_PAINT hook lands. (2) Rewrote `docs/src/ui/styling.md` to lead with the new inline `style: { ... }` API as the recommended pattern — the verbose imperative `widgetSet*` setters become an "underlying API" section for fine-grained control. New table documents every `StyleProps` key + the FFI symbol it maps to; new sections cover the 4 color forms (hex literal, named, PerryColor object, runtime variable), padding shapes (single number vs per-side object), and container styling (VStack/HStack with `style` after children array). New CSS→Perry mapping table updated to the inline-style equivalents (`background: #3B82F6` → `backgroundColor: "#3B82F6"`, `box-shadow` → `shadow: {...}`, `text-decoration` → `textDecoration`, etc.). New "Platform support" section with the current matrix headline (43/43 on 7 platforms, GTK4 39/43, Windows 38/43+5 stubs) and explicit links to issues [#202](https://github.com/PerryTS/perry/issues/202) (GTK4 4 missing) and [#210](https://github.com/PerryTS/perry/issues/210) (Windows deferred-paint). (3) Added `// ANCHOR: button-inline-full` and `// ANCHOR: stack-inline-full` markers to `docs/examples/ui/styling/button_inline_style.ts` and `stack_inline_style.ts` so the new doc sections can `{{#include path:NAME}}` them in the standard mdbook anchor syntax (the line-range form initially used didn't match the rest of the repo). Both files still compile to 0.9 MB macOS binaries clean — anchors are inert comments. **Phase C epilogue**: with the issue tracking in place and docs reflecting the new API as primary, issue #185 is fully delivered. Two open follow-ups (#202 GTK4, #210 Windows paint) are tracked, scoped, and reproducibly verifiable in CI via the styling-matrix drift test. +- **v0.5.311** — Issue #185 Phase C step 7: runtime parseColor for non-literal color values. Closes the final ergonomic gap from Phase C. New `crates/perry-runtime/src/color_parse.rs` exports `js_color_parse_channel(value: f64, channel: i32) -> f64` — takes a NaN-boxed JSValue holding a CSS color string + a channel index (0=r, 1=g, 2=b, 3=a), returns the f64 channel value. Mirrors the codegen-side `parse_color_string` algorithm exactly (named colors + hex 3/4/6/8). Reads StringHeader-prefixed UTF-8 via the existing `js_get_string_pointer_unified`. Per-channel API (rather than 4-output pointer or packed-u64) lets the codegen stay trivial — single `call double @js_color_parse_channel(...)` per channel, no stack-alloca-of-array machinery. Slight overhead (parses the string 4× per dynamic-color use) is acceptable because dynamic colors are rare; the common case (literal strings + object literals) still goes through compile-time folding with zero runtime cost. New codegen helper `lower_color_with_runtime_fallback(ctx, val)` in `crates/perry-codegen/src/lower_call.rs` tries `extract_perry_color` first, falls back to lowering the value once + 4 channel calls when needed. Wired into all 3 top-level color arms (`backgroundColor`, `color`, `borderColor`). 5 unit tests in `color_parse::tests` pin the parser invariants (hex 6-char, hex 8-char alpha, named white, named transparent, garbage rejection). Verified end-to-end on `docs/examples/ui/styling/dynamic_color.ts` — emitted LLVM IR shows the expected pattern: `%r13-r16 = call @js_color_parse_channel(%runtime_value, 0..3)` then `%r17 = call @perry_ui_widget_set_background_color(handle, %r13, %r14, %r15, %r16)`. Mixed compile-time-folded literals (`color: "white"` → direct `(1.0, 1.0, 1.0, 1.0)`) and runtime variables (`backgroundColor: themeColor` → 4 channel calls) work side-by-side in the same `apply_inline_style` invocation. Compiles to 0.9 MB macOS binary clean. **Phase C is now fully complete**: literal colors (compile-time, step 6), object literals (step 3), runtime expressions (step 7 here). Users can now write any of these forms and they Just Work: `{ backgroundColor: "#3B82F6" }`, `{ backgroundColor: { r: 0.231, g: 0.510, b: 0.965, a: 1 } }`, `{ backgroundColor: themeVar }`. Issue #185's headline ergonomic ask is fully delivered across every Widget-returning constructor on every native platform with the underlying setters wired (per the styling matrix). +- **v0.5.310** — Issue #185 Phase C step 6: compile-time string color parsing + gradient destructure. Closes the headline ergonomic gap from Phase C steps 1-5 — `backgroundColor: "#3B82F6"` (string literal) now parses to 4 baked-in float args at HIR time, no runtime cost. Two new functions in `crates/perry-codegen/src/lower_call.rs`: (1) **`parse_color_string(s)`** handles `#RGB` / `#RGBA` / `#RRGGBB` / `#RRGGBBAA` hex forms (each-nibble-doubled for short forms; pair-byte for long forms) and 10 common named colors (`white`, `black`, `red`, `green`, `blue`, `yellow`, `cyan`, `magenta`, `gray`/`grey`, `transparent`). Returns `Option<(String,String,String,String)>` of f64-formatted strings ready for direct LLVM IR emission. New `fmt_float` helper enforces LLVM's at-least-one-digit-after-decimal-point rule. `extract_perry_color` now matches `Expr::String(s)` first, falling back to the existing `{r,g,b,a}` object-literal path; runtime expressions (`backgroundColor: someVar`) still fall through to the catch-all (deferred to future runtime parseColor hook). (2) **`extract_gradient_obj(ctx, val)`** destructures `gradient: { angle, stops: [c1, c2] }` into the 10-arg tuple `widgetSetBackgroundGradient` takes. Inner `c1` / `c2` go through `extract_perry_color` so they accept hex strings, named colors, or `{r,g,b,a}` objects. Runtime FFI is 2-color only — extra stops are ignored, missing stops default to fully transparent black. New `gradient` arm in `apply_inline_style` calls `extract_gradient_obj` and emits the 10-arg `perry_ui_widget_set_background_gradient(handle, r1,g1,b1,a1, r2,g2,b2,a2, angle)`. Verified end-to-end on `docs/examples/ui/styling/hex_gradient.ts` — emitted LLVM IR shows hex `"#3B82F6"` → `text_set_color(0.231, 0.510, 0.965, 1.0)`, named `"gray"` → `text_set_color(0.502, 0.502, 0.502, 1.0)`, `"#3B82F6FF"` → `set_background_color(0.231, 0.510, 0.965, 1.0)`, and gradient `{angle: 135, stops: ["#3B82F6", "#8B5CF6"]}` → `set_background_gradient(0.231, 0.510, 0.965, 1.0, 0.545, 0.360, 0.965, 1.0, 135.0)` — all values precise to the f64 that `byte/255.0` produces, no rounding. Compiles to a 0.9 MB macOS binary. **Phase C is now substantively complete**: scalar props (step 2), color/padding/shadow object literals (step 3), every widget constructor (step 4), VStack/HStack containers (step 5), string colors + gradient (step 6) — users can now write `Button("Save", () => {}, { backgroundColor: "#3B82F6", borderRadius: 8, padding: 12, gradient: { angle: 90, stops: ["#3B82F6", "#8B5CF6"] }, shadow: {...} })` and it Just Works on every platform that has the underlying setters wired. **What remains**: dynamic-value runtime parseColor hook for `backgroundColor: someVar` patterns (HIR-level runtime emission, not compile-time destructure) — non-blocking for the headline ask since users typically author StyleProps inline anyway. +- **v0.5.309** — Issue #185 Phase C step 5: inline `style: { ... }` on VStack and HStack containers. Lands the style position AFTER the children array so the variadic-arg disambiguation logic at `crates/perry-codegen/src/lower_call.rs:2439` (`Some(Expr::Array(_)) ⇒ children_idx = 0` vs `Some(other_number) ⇒ children_idx = 1`) doesn't have to handle a third style-vs-children ambiguity. Both shapes work: `VStack(children, style?)` lands style at `args[1]`; `VStack(spacing, children, style?)` lands style at `args[2]`. New `style_idx = children_idx + 1` lookup → calls the existing `apply_inline_style` helper on the parent_slot-loaded handle (`apply_inline_style` no-ops on non-object trailing args, so the call is safe even when args.len() doesn't actually have a style). Verified end-to-end via `docs/examples/ui/styling/stack_inline_style.ts` — emitted LLVM IR shows `vstack_create(8.0)` → 3× `widget_add_child(parent, child)` → `set_background_color(0.96, 0.97, 0.99, 1.0)` → `set_corner_radius(12.0)` → `set_edge_insets(16×4)` → `set_shadow(0, 0, 0, 0.1, 8.0, 0, 2.0)` (partial-shadow defaults applied — missing offsetX defaults to 0) for the spacing-first VStack form, and `hstack_create(8.0)` → 2× `widget_add_child` → `set_background_color(0.2, 0.2, 0.2, 1.0)` → `set_edge_insets(8, 16, 8, 16)` (per-side padding object) → `set_corner_radius(6.0)` for the children-only HStack form. Compiles to a 0.9 MB macOS binary clean. **Phase C broadly complete**: every Widget-returning constructor in PERRY_UI_TABLE (Text, Toggle, Slider, TextField, SecureField, Spacer, Divider, Picker, ImageFile, ImageSymbol, ProgressView, NavStack, ZStack, Canvas, ...) gets inline style via the v0.5.308 generic table-call hook; Button has its own dedicated arm (v0.5.306-307); VStack/HStack containers added here. **What's still pending**: string colors (`backgroundColor: "#3B82F6"`) need a runtime `parseColor` integration so codegen can emit the parse + 4-arg setter at runtime instead of compile-time destructure; gradient stops arrays need similar runtime support. Both deferred to a future Phase C step 6 — the helpers are in `packages/perry-styling/src/color.ts` already, just need a HIR-level runtime hook. +- **v0.5.308** — Issue #185 Phase C step 4: roll out inline `style: { ... }` to every Widget-returning constructor in `PERRY_UI_TABLE`. One mechanical change to `lower_perry_ui_table_call` (`crates/perry-codegen/src/lower_call.rs:5089`): when `args.len() == sig.args.len() + 1` AND `sig.ret == UiReturnKind::Widget`, treat the trailing arg as an inline style object and apply via the same `apply_inline_style` helper Button has been using since v0.5.306. Iterates declared sig args via `args.iter().take(declared_arg_count)` so the trailing style isn't double-counted; `apply_inline_style(ctx, &handle, style_arg)?` runs after the create call, then `nanbox_pointer_inline` boxes the handle as before. Same `let handle = { let blk = ctx.block(); blk.call(...) };` scoping trick as the Button arm to release the mutable `ctx` borrow before `apply_inline_style` re-borrows. Cross-widget end-to-end verified via `PERRY_NO_CACHE=1 PERRY_SAVE_LL=...` on `docs/examples/ui/styling/cross_widget_inline_style.ts` — emitted LLVM IR shows clean style cascades on **Text** (`perry_ui_text_create` → `text_set_color`/`set_edge_insets`/`set_corner_radius`), **Toggle** (`toggle_create` → `set_corner_radius`/`set_opacity`/`set_tooltip`), **Slider** (`slider_create` → `set_border_color`/`set_border_width`/`set_corner_radius`/`set_edge_insets`), and **ProgressView** (`progressview_create` → `set_background_color`/`set_corner_radius`/`set_opacity`) — every widget in the table picked up the inline-style support with no per-widget edits. Compiles to a 0.9 MB macOS binary clean. **What's left in Phase C**: VStack/HStack take a children array as their final arg so the style needs to land in a different position (e.g., `VStack(spacing, children, style)`) — separate dedicated arm work. String colors (`backgroundColor: "#3B82F6"`) and gradient stops arrays still defer to a future runtime parseColor + dynamic-value path. +- **v0.5.307** — Issue #185 Phase C step 3: multi-arg destructure for inline `style: { ... }` on Button. Extends `apply_inline_style` (`crates/perry-codegen/src/lower_call.rs`) with: (1) **PerryColor object literals** — `backgroundColor` / `color` / `borderColor` accept `{r, g, b, a?}` shapes; new `extract_perry_color(ctx, val)` helper destructures via the existing `extract_options_fields` and lowers each component into the 4 setter args at HIR time. (2) **`padding`** — accepts a single number (expands to all 4 sides) OR a per-side object `{top?, right?, bottom?, left?}` (missing sides default to 0). New `extract_padding_sides` helper. **Bug caught during step 3**: Perry's HIR distinguishes `Expr::Number(f64)` from `Expr::Integer(i64)` for numeric literals; my initial match arm only handled `Number`, so `padding: 12` (integer) fell through silently. Match arm widened to `Number | Integer`. (3) **`shadow`** — full 4-field destructure of `{color?, blur?, offsetX?, offsetY?}` (with the inner color being itself a PerryColor object); new `extract_shadow_obj` helper produces the 7-arg tuple `widgetSetShadow` takes. Defaults: black 25% opacity, blur 0, offset (0, 0). (4) **`textDecoration`** — string literal `"underline"` / `"strikethrough"` / `"none"` mapped to the integer `textSetDecoration` takes (0/1/2). String colors and gradient (`gradient: { angle, stops }`) deferred to step 4 — the helpers above only handle object-literal shapes; runtime expressions (`backgroundColor: someVar`) fall through and skip emission. **Verified end-to-end** via `PERRY_NO_CACHE=1 PERRY_SAVE_LL=...` on the updated `docs/examples/ui/styling/button_inline_style.ts` — emitted LLVM IR shows the full 9-call cascade: `button_create`, `set_background_color(r, g, b, a)`, `set_border_color(r, g, b, a)`, `set_border_width(w)`, `set_corner_radius(8.0)`, `set_edge_insets(12.0×4)`, `set_opacity(0.95)`, `set_shadow(r, g, b, 0.25, 12.0, 0.0, 4.0)`, `set_tooltip`, `set_enabled` — exactly the setter sequence the verbose imperative pattern would produce. Compiles to a 0.9 MB macOS binary clean. +- **v0.5.306** — Issue #185 Phase C step 2: HIR-level `style: { ... }` destructure for the Button constructor. New `apply_inline_style(ctx, &handle, style_arg)` helper in `crates/perry-codegen/src/lower_call.rs` mirrors the v0.5.x `App({title, width, height, body})` HIR pass — calls the existing `extract_options_fields(ctx, &args[2])` to get `Vec<(String, Expr)>`, then per-key routing emits a setter call against the just-created button handle. Step 2 supports the single-value scalar props that don't need multi-arg destructure: `borderRadius`, `opacity`, `borderWidth`, `tooltip`, `hidden`, `enabled`. Codegen-side: `Button("Save", onPress)` arm at `lower_call.rs:2547` reorganized to scope the create-call's `blk` borrow into a `let handle = { let blk = ctx.block(); blk.call(...) };` block so `apply_inline_style(ctx, ...)` can re-borrow `ctx` cleanly; `nanbox_pointer_inline` re-acquires `blk` after. End-to-end verified via `PERRY_NO_CACHE=1 PERRY_SAVE_LL=...`: emitted LLVM IR shows the destructure cascade exactly — `%r7 = call @perry_ui_button_create(...)`, `%r8 = call @perry_ui_widget_set_corner_radius(i64 %r7, double 8.0)`, `%r9 = call @perry_ui_widget_set_border_width(i64 %r7, double 1.0)`, `%r10 = call @perry_ui_widget_set_opacity(i64 %r7, double 0.95)`, `%r13 = call @perry_ui_widget_set_tooltip(i64 %r7, i64 %r12)`, `%r16 = call @perry_ui_widget_set_enabled(i64 %r7, i64 %r15)` — exactly the setter sequence the verbose imperative pattern would produce, but inferred from one inline object literal. New doc-example `docs/examples/ui/styling/button_inline_style.ts` compiles to a 0.9 MB macOS binary clean. **Out of scope (step 3)**: color props (`backgroundColor` / `color` / `borderColor` need PerryColor object destructure or string parseColor), `padding` (single-number expansion to 4-arg edge insets, or per-side object), `shadow` (color + blur + 2 offsets = 4 nested fields), `gradient` (angle + stops array), `fontSize` / `fontWeight` (text-only — different setter dispatch). Plus the rest of the widget constructors (Text, VStack, HStack, TextField, Toggle, Slider, ...) — Button is the proof-of-concept; rolling out to other widgets is a mechanical sweep using the same `apply_inline_style` helper. +- **v0.5.305** — Issue #185 Phase C step 1: type-surface groundwork. New `StyleProps` interface in `types/perry/ui/index.d.ts` documents the future inline `Button("Save", onPress, { style })` shape — IDE autocomplete + typo-safety today, no runtime change. Every prop (`backgroundColor`, `borderColor` + `borderWidth` joint, `borderRadius`, `padding` (single or per-side), `fontSize`, `fontWeight`, `fontFamily`, `opacity`, `shadow.{color,blur,offsetX,offsetY}`, `textDecoration`, `gradient.{angle,stops}`, `hidden`, `enabled`, `tooltip`) maps 1:1 to existing setters wired in v0.5.297-v0.5.303. New `PerryColor` interface (`{r, g, b, a?}`) for typed color objects; string color inputs are deferred to runtime parsing once `parseColor` (already in `packages/perry-styling/src/color.ts`) gets HIR-pass integration. New doc-example `docs/examples/ui/styling/style_props.ts` demonstrates the verbose-but-works pattern: typed `StyleProps` object → manually unpacked into individual setter calls (`widgetSetBackgroundColor`, `setCornerRadius`, `widgetSetShadow`, etc.). When the Phase C codegen pass lands, the same `StyleProps` value will be acceptable as a trailing arg to widget constructors with no rename needed. End-to-end smoke: `./perry compile docs/examples/ui/styling/style_props.ts` produces a 0.9 MB macOS binary clean. Codegen-side inline `style: {...}` destructure (mirrors the existing `App({title, width, height, body})` HIR pass at `crates/perry-codegen/src/lower_call.rs:2629` using `extract_options_fields`) tracked as the Phase C step 2 follow-up. Also filed issue #202 documenting the 4 GTK4 styling gaps surfaced by the Phase B audit (`widget.on_click`, `button.content_tint_color`, `button.image_position`, `stack.detaches_hidden`) — needs a Linux contributor since macOS host can't reasonably cross-compile GTK4. +- **v0.5.304** — Docs audit: every TypeScript fence on the docs site is now either compile-verified by the doc-tests harness or carries an honest non-`,no-test` fence (`text` for "Perry rejects this" / "external library" / "API not yet wired in codegen", `javascript` for Node.js comparison blocks). 132 inline `,no-test` fences → 0; 28 verified examples → 73 (45 new files under `docs/examples/`). **By directory** — `language/`: `limitations.md` 10 reject snippets converted to `text` (decorators, eval, Reflect, Symbol, WeakMap/WeakRef, Proxy, prototype manipulation, computed property keys, dynamic require/import); `supported-features.md` Modules section now extracts to a real two-file pair (`docs/examples/language/modules/{utils,main}.ts`, both compile); JSX section flagged `text` because parser+HIR support it but runtime `_jsx`/`_jsxs` aren't linked yet. `stdlib/`: 12 not-yet-wired fences across `utilities.md`/`other.md`/`crypto.md`/`overview.md`/`fs.md` → `text` with status notes pointing at #193 (lodash, dayjs, moment, sharp, cheerio, zlib, cron, worker_threads, ethers.Wallet.createRandom, jsEval, fs.rmRecursive, fileURLToPath/import.meta.url). `plugins/`: all 25 fences across overview/creating-plugins/hooks-and-events/native-extensions/appstore-review → `text` (entire section unwired in codegen, see #189). `widgets/`: built `docs/examples/widgets/snippets.ts` with 15 anchors (`minimal`, `image-stack`, `hstack-row`, `gauge`, `data-fetch`, `watchos-complication`, `wearos-tile`, `weather-widget`, `conditional-render`, `template-literal`, `city-weather-config`, `stock-widget`, `stats-widget`, `top-sites-widget`, `weather-provider`); confirmed `Widget({...})` lowers to a no-op on host LLVM so compile-link is real verification of the codegen-glance/wear-tiles/swiftui shapes; converted 46 fences across overview/creating-widgets/components/configuration/data-fetching/watchos/wearos to `{{#include path:NAME}}`; method-chain modifier examples (`.font().color()`) kept as `text` since #195 documents the silent-drop bug at HIR lowering. `threading/`: type-signature stubs collapsed to inline prose; reject examples → `text`; Node.js worker_threads comparison block → `javascript`. `ui/{canvas,camera,table,theming}.md`: 25 not-yet-wired fences → `text` with status notes pointing at #190/#191/#192 and the perry-styling external package. `platforms/tvos.md`: Bloom external-library snippet → `text`. **Restoration note**: original docs-audit work was lost when Phase B took the v0.5.299 slot; recovered from `stash@{0}` (62 docs/* file edits) plus `1b03c1cf` untracked-files pseudo-commit (38 example `.ts` files plus `_expected` stdouts that `git stash` without `-u` had dropped). Filtered the stash patch to docs/ only via per-chunk `diff --git` filtering so the Phase B FFI deltas already in v0.5.297-v0.5.303 didn't get re-applied. **GitHub issues filed for the codegen-blocked gaps**: #188 i18n format wrappers (closed by v0.5.298); #189 perry/plugin codegen dispatch; #190 Canvas in LLVM codegen; #191 CameraView codegen; #192 Table codegen; #193 stdlib helpers (lodash/dayjs/sharp/etc.); #194 doc-tests `--app-bundle-id` plumbing for widget cross-compile targets; #195 HIR method-chain modifier silent-drop. When those land, the corresponding `text` fences flip to `{{#include}}` extracts; `` HTML comments make the conversion sites discoverable by grep. Final harness: `./target/release/doc-tests --skip-xcompile` → 71/72 pass (the lone fail is the pre-existing `ui/gallery.ts` retina-vs-non-retina screenshot baseline, unrelated); `./target/release/doc-tests --lint docs/src` → clean. +- **v0.5.303** — Issue #185 Phase B closure 11: Web alias sweep — flip the Web matrix column from 6/43 → **43/43 Wired**. Audit revealed the Phase A Web column was overly pessimistic; only 6 of 43 styling props were truly missing Web dispatch, and the other 37 were already covered by existing `crates/perry-codegen-wasm/src/emit.rs` rows pointing at JS functions. Closure adds 8 new dispatch table rows (4 to existing JS funcs: `widgetSetBackgroundGradient` / `textSetSelectable` to canonical names, `textfieldSetBackgroundColor` / `textfieldSetTextColor` / `textfieldSetFontSize` reused via the generic `perry_ui_set_background` / `_foreground` / `_font_size` since DOM input takes the same `el.style.*` props as a generic element) plus 2 new tiny JS funcs in `wasm_runtime.js`: `perry_ui_textfield_set_borderless` (`el.style.border = "none" + outline none`) and `perry_ui_stack_set_alignment` (CSS `align-items`: 0=stretch, 1=center, 2=flex-start, 3=flex-end, 4=baseline). Plus matrix-only correction: `W_ALL_NATIVE_WEB_TODO` const flipped from `[..., Missing]` to `[..., Wired]`, which cascaded the 37 already-dispatched rows from Web=Missing → Web=Wired automatically; 4 explicit-status rows (`on_click` / `content_tint_color` / `image_position` / `detaches_hidden`) had their Web cells flipped manually. **Matrix headline post-landing**: macOS / iOS / tvOS / visionOS / watchOS / Android / Web all at **43/43 Wired** — first time the styling matrix is fully green for every primary user-facing platform. GTK4 at 39/43 (4 remaining Missing are unrelated splitview/tabbar/qrcode rows that aren't actually styling props but slipped into the matrix during Phase A categorization), Windows at 38/43 + 5 Stub (the deferred-paint family). Drift integration test still clean across all 8 native platforms. +- **v0.5.302** — Issue #185 Phase B closure 10: fix the `hidden` row in the styling matrix that surfaced as Missing on every platform except Windows. Audit revealed the FFI is named `perry_ui_set_widget_hidden` on every backend (matches `crates/perry-codegen/src/lower_call.rs::NATIVE_MODULE_TABLE`'s `widgetSetHidden` row + the WASM dispatch table); the Phase A matrix had the inverted-word-order name `perry_ui_widget_set_hidden` which only Windows exports as a secondary alias, leaving every other backend looking Missing despite all 8 having `set_widget_hidden` Wired. Single-line fix: change `ffi: "perry_ui_widget_set_hidden"` → `"perry_ui_set_widget_hidden"` in `crates/perry-ui/src/styling_matrix.rs` and flip all 9 cells to `Wired`. Drift test runs clean. **Matrix headline**: every Apple platform + Android now at **43/43 Wired** — first time the full styling-prop set is green for the canonical primary platforms. GTK4 at 39/43 (4 remaining Missing are unrelated splitview / tabbar / qrcode rows that aren't styling-prop rows but slipped in during Phase A categorization), Windows at 38/43 + 5 Stub (the deferred-paint family), Web at 6/43 (CSS column tracked separately). +- **v0.5.300** — Issue #185 Phase B closures 7+8+9: opacity + borders + text decoration. Three closures landing together because they all close the same shape (the GTK4 / Windows / Web cells the v0.5.297 shadow closure left Missing) plus add a brand-new aspirational row. **B7 opacity** (`perry_ui_widget_set_opacity`): GTK4 wired via built-in `Widget::set_opacity` (single-line passthrough — no CSS provider needed). Windows is stub-with-state — `OPACITY_VALUES: Mutex>` mirrors the existing `CORNER_RADII` deferred-application pattern; real per-widget opacity needs `WS_EX_LAYERED` + `SetLayeredWindowAttributes` per-class wiring. Web aliased to the existing `perry_ui_set_opacity` JS function via the WASM emitter dispatch table — `"setOpacity" | "set_opacity" | "widgetSetOpacity"` all route to the same symbol; no new JS code. Matrix opacity row goes from `[Wired×6, Missing×3]` to `[Wired×7, Stub_Windows, Wired_Web]`. **B8 borders** (`perry_ui_widget_set_border_color` + `perry_ui_widget_set_border_width`): joint per-handle (color, width) state on every backend because CSS won't render a border unless `border-style: solid` + a non-zero width + a color all land in the same rule — calling either setter alone with naive cascading would clobber the other. GTK4 uses thread-local `BORDER_STATE: HashMap, Option)>` + a regenerated per-handle `perry-bd-{h}` CSS class with `CssProvider` re-emitted on every change; mirrors the v0.5.297 shadow `perry-sh-{h}` pattern. Web adds two new JS functions `perry_ui_widget_set_border_color` / `perry_ui_widget_set_border_width` backed by a module-level `__perryBorderState` Map and a `__perry_apply_border` helper that reads color + width together and writes `el.style.border = \`${w}px solid rgba(R,G,B,a)\``. Defaults match CALayer-ish behavior: missing color = black, missing width = 1px so calling either setter alone still produces a visible border. Windows is stub-with-state with joint `BORDER_STATE: Mutex, Option))>>`. Matrix flips both border rows from `[Wired×6, Missing×3]` to `[Wired×7, Stub_Windows, Wired_Web]`. **B9 text decoration** (`perry_ui_text_set_decoration` — new aspirational row from Phase A flipped to live): values `0=none, 1=underline, 2=strikethrough` map to each backend's native mechanism. Apple (macOS / iOS / tvOS / visionOS) build an `NSAttributedString` via `[NSDictionary dictionaryWithObject:numberWithInt:1 forKey:@"NSUnderline"]` (or `@"NSStrikethrough"`) + `[NSAttributedString alloc] initWithString:attributes:` then `setAttributedStringValue:` (NSTextField) or `setAttributedText:` (UILabel); `decoration = 0` resets back to plain `setStringValue:` / `setText:`. Pattern mirrors the existing `button::set_text_color` raw-pointer JNI bridge so the objc2 trait bounds compose. watchOS adds a `text_decoration: i64` field to `NodeData` for the SwiftUI host to consume. Android calls `View.getPaint().setFlags(8|16)` (`Paint.UNDERLINE_TEXT_FLAG`=8, `Paint.STRIKE_THRU_TEXT_FLAG`=16) + `View.invalidate()` over JNI — simpler than building a `SpannableString` and gives the same visual result. GTK4 uses `pango::AttrInt::new_underline(Single|None)` + `new_strikethrough(bool)` inserted into the existing `Label::attributes()` AttrList. Web emits `el.style.textDecoration = "underline" | "line-through" | "none"`. Windows is stub-with-state — `DECORATION_VALUES: Mutex>` stores the value but rebuilding the HFONT with `lfUnderline`/`lfStrikeOut` set requires `GetObjectW` + LOGFONT mod + `CreateFontIndirectW` + `WM_SETFONT`, deferred. Matrix flips the `decoration` row from all-Missing baseline to `[Wired×7, Stub_Windows, Wired_Web]`. **Codegen + TS surface**: new `textSetDecoration` row in `crates/perry-codegen/src/lower_call.rs::NATIVE_MODULE_TABLE` with `[Widget, I64Raw]` arg kinds; new TS export `textSetDecoration(widget, decoration)` in `types/perry/ui/index.d.ts`; `widgetSetBorderColor` / `widgetSetBorderWidth` get explicit Web aliases in `crates/perry-codegen-wasm/src/emit.rs` so user code calling them resolves to the new JS functions on the web target. **Matrix summary** post-landing: every Apple platform + Android at **42/43 Wired** (1 remaining gap = `hidden`, pre-existing audit miss), GTK4 at 38/43 (5 remaining unrelated Missing — splitview, tabbar etc.), Windows at 38/43 + 5 Stub (shadow + opacity + border_color + border_width + text decoration — the deferred-paint family), Web at 5/43 (CSS columns intentionally tracked separately). Drift integration test still clean across all 8 native platforms. +- **v0.5.298** — Issue #188: wire codegen dispatch for `perry/i18n` format wrappers. New `PERRY_I18N_TABLE` in `crates/perry-codegen/src/lower_call.rs` routes `Currency`/`Percent`/`FormatNumber`/`ShortDate`/`LongDate`/`FormatTime`/`Raw` to per-name `perry_i18n_format_*_default` runtime exports added in `crates/perry-runtime/src/i18n.rs` (single-arg shape — each wrapper folds in `LOCALE_INDEX` so codegen stays uniform). New `UiReturnKind::Str` variant returns the runtime's `*mut StringHeader` NaN-boxed with `STRING_TAG`. Pre-fix, `Currency(99.99)` reached the receiver-less early-out at `lower_call.rs:2849` and returned NaN-boxed `undefined`; now it returns `"$99.99"`. Doc snippet `docs/examples/i18n/format_wrappers.ts` + golden stdout added to keep the wiring drift-checked. (Pre-existing FP-precision quirk in `format_number_locale` — `1234567.89` → `1,234,567.8899999999` — unchanged; that's formatter math, separate from dispatch.) +- **v0.5.297** — Issue #185 Phases A + B (consolidated landing): perry/ui styling audit infrastructure + cross-platform `widgetSetShadow`. **Phase A** — new `crates/perry-ui/src/styling_matrix.rs` declarative matrix (43 styling-relevant FFI rows × 9 platforms macOS/iOS/tvOS/visionOS/watchOS/Android/GTK4/Windows/Web, each cell `Status::{Wired,Stub,Missing,NotApplicable}`); machine-derived initial state by scanning each backend's `lib.rs` exports — caught two prior-doc errors (the older `perry-ui-test::FEATURES` table claimed Apple platforms + Android lacked CALayer borders + opacity entirely, while reality is that GTK4 + Windows are the actual gaps). Drift-check binary `crates/perry-ui/src/bin/styling-matrix.rs` (`--check`/`--gen`/`--diff` modes) handles both multi-line and single-line `#[no_mangle] pub extern "C" fn ...` declarations (watchOS uses single-line). Integration test `crates/perry-ui/tests/styling_matrix_drift.rs` makes drift fail `cargo test --workspace`. `scripts/run_ui_styling_matrix.sh` + `.github/workflows/test.yml` step with `git diff --exit-code` guard so a forgotten `--gen` regen also fails CI. Auto-generated `docs/src/ui/styling-matrix.md`. Sibling `perry-ui-test::FEATURES` left untouched — consolidation deferred to Phase C. **Phase B** — `widgetSetShadow(handle, r, g, b, a, blur, offset_x, offset_y)` wired across all 9 platforms in 5 closures: (1) macOS / iOS / tvOS / visionOS via CALayer.shadow* (`setShadowColor:` opaque + `setShadowOpacity:` so non-1 alpha doesn't double-multiply, `setShadowRadius:` for blur, `setShadowOffset:` as CGSize where +y = downward to match HTML `box-shadow`, `setMasksToBounds: NO` so corner-radius widgets don't clip the shadow). (2) watchOS stores shadow in the `NodeData` introspection tree (new `shadow: Option<(f64,f64,f64,f64,f64,f64,f64)>` field + initializer) so the SwiftUI host can apply `.shadow(...)` modifier. (3) Web via CSS `box-shadow` in `crates/perry-codegen-wasm/src/wasm_runtime.js` — emits `el.style.boxShadow = \`${dx}px ${dy}px ${blur}px rgba(R,G,B,a)\`` with a dispatch-table row in `emit.rs` plus three runtime dynamic-dispatch maps registered + the export list updated. (4) GTK4 via per-handle CSS class `perry-sh-{h}` + fresh `CssProvider` emitting `box-shadow: px px px rgba(R,G,B,a);`, mirroring `set_corner_radius`'s display-priority pattern for buttons + widget-priority for others. (5) Android via Material `setElevation(blur/2 dp)` over JNI + opportunistic `setOutlineSpotShadowColor`/`setOutlineAmbientShadowColor` for color tinting on API 28+ (silent no-op on older API). Offset is intentionally ignored on Android — the device-level light source owns shadow direction. (6) Windows is **stub-with-state**: `static SHADOW_PARAMS: Mutex>` mirrors the existing `CORNER_RADII` deferred-application pattern; FFI symbol resolves and parameters are stored, but real DirectComposition / WM_PAINT alpha-blit rendering deferred to a follow-up. Matrix marks Windows `Status::Stub` (not `Wired`) so users know the prop is honored but not visible. Cross-platform code that calls `widgetSetShadow` compiles + links + matrix-drift-checks clean on every backend; when the Windows paint pass lands, every previously-stored handle gets its shadow applied automatically. New codegen dispatch row in `crates/perry-codegen/src/lower_call.rs::NATIVE_MODULE_TABLE` routes `widget.setShadow(...)` to `perry_ui_widget_set_shadow` with `[Widget, F64×7]` arg kinds. New TS export `widgetSetShadow` in `types/perry/ui/index.d.ts`. End-to-end smoke: `./perry compile docs/examples/ui/styling/shadow.ts` produces a 0.9 MB macOS binary; `--target web` produces a 179 KB self-contained HTML with the boxShadow CSS exactly where expected (5 occurrences of `perry_ui_widget_set_shadow` in the emitted HTML, matching `perry_ui_set_corner_radius`'s reference count). Matrix shadow row goes from all-Missing baseline to `[Wired×5_Apple, Wired_Android, Wired_GTK4, Stub_Windows, Wired_Web]` — every cell non-Missing for the first time. Per-platform Wired summary post-shadow: 41/43 Apple platforms + Android, 38/43 + 1 stub Windows, 34/43 GTK4 (still has 9 missing — borders, opacity, etc., from earlier audit gaps that are Phase B's remaining work), 1/43 Web (Web column intentionally tracked separately as it uses CSS string emission rather than FFI). Restoration note: this commit consolidates the v0.5.295/296/297/298 work that was lost in a stash/rebase race against the v0.5.296 FUNDING.yml commit; `git fsck` recovered the 6 new files from `stash@{0}^3` (the untracked-files component of `pre-rebase stash for v0.5.300 push`) and the Phase B FFI deltas were restored from `stash@{0}` proper, leaving an orthogonal docs-drift stash unmerged for separate review. +- **v0.5.298** *(origin parallel-track entry — Linux bench fix #197)*: `benchmarks/json_polyglot/run.sh` was reporting `FAILED (no successful runs)` for every cell on Linux because it hardcoded BSD `/usr/bin/time -l` for RSS capture — Linux ships GNU time which only accepts `-v`. Every workload invocation died on `time: invalid option -- 'l'` before the bench even started. Fix: detect `uname` once, pick `-l`/`-v` accordingly, route the platform-specific RSS-line through a `rss_extract` shim that normalizes Linux's KB to bytes (so the `worst_rss` arithmetic and `rss_mb` printout downstream don't care). Also handles distros where `/usr/bin/time` isn't installed (workload runs directly, RSS=0). Sibling compute bench `benchmarks/polyglot/run_all.sh` was already Linux-clean and didn't need touching. +- **v0.5.297** *(origin parallel-track entry — Linux test fix #196)*: drop the redundant `tests::test_runtime_init` from `crates/perry-jsruntime/src/lib.rs` — `cargo test -p perry-jsruntime --lib` segfaulted on Linux because deno_core/V8 don't tolerate a second `JsRuntime::new()` in the same process across cargo's per-test worker threads, and this test ran *after* `interop::tests::test_runtime_init` had already initialized V8 once. The "double-init tolerance" the test claimed to verify is trivially provided by the `if opt.is_none()` guard in `ensure_runtime_initialized`. macOS-only CI didn't catch it. +- **v0.5.296** — Add `.github/FUNDING.yml` with `ko_fi: perryts` so the repo's Sponsor button routes to Ko-fi while the GitHub Sponsors application is in review. +- **v0.5.295** — Linux build fix: `find_clang()` / `find_llvm_tool()` (`crates/perry-codegen/src/linker.rs`) now search common Linux LLVM install prefixes (`/usr/lib64/rocm/llvm/bin`, `/usr/lib/llvm-{17,18,19}/bin`) alongside the existing Homebrew/Windows paths, so `.ll` → `.o` works without `PERRY_LLVM_CLANG`. Removed 3 AOT stubs (`js_sqlite_transaction`, `_commit`, `_rollback`) from `perry-runtime/src/closure.rs` — they collided with the real implementations in `perry-stdlib/src/sqlite.rs` and broke `cargo test --workspace` with duplicate-symbol linker errors under GNU ld (origin's macos-only main test job didn't catch it). +- **v0.5.294** — Release-blocker fix surfaced by v0.5.293's failed publish: `_js_stdlib_process_pending` link error on macOS doc-tests + iOS simulator tests. Root cause was Cargo feature unification — when CI's auto-optimize runs `cargo build -p perry-runtime -p perry-stdlib` together, perry-stdlib's `Cargo.toml` declares `perry-runtime = { features = ["stdlib"] }`, which activates `stdlib` on perry-runtime. That activates the `#[cfg(not(feature = "stdlib"))]` gate at `crates/perry-runtime/src/lib.rs:65` and excludes `mod stdlib_stubs;`, removing `_js_stdlib_process_pending` from `libperry_runtime.a`. Perry's compile then enters runtime-only link mode (libperry_runtime.a + libperry_ui_macos.a, no libperry_stdlib.a) and the symbol is undefined. Local builds didn't catch it because `cargo build -p perry-runtime` alone doesn't unify with stdlib's feature requirements. Fix: 18 files across `perry-ui-{macos,ios,tvos,visionos,gtk4}` switched from hard-linking `js_stdlib_process_pending` to calling `js_run_stdlib_pump`, the existing always-exported trampoline at `lib.rs:121` that dispatches via the registered-callback pattern (same shape `js_stdlib_has_active_handles` already uses). Also re-added `test_gap_console_methods` to `test-parity/known_failures.json` as `ci-env` — the v0.5.290 drop was premature; it passes locally through `normalize_output` but the CI runner produces a variant that escapes it. Re-tagging for `release-packages.yml` since v0.5.293's GH release shipped no binaries. +- **v0.5.293** — Repo hygiene: untrack 465 Android Gradle cache files (`android-build/.gradle/`, `android-build/app/build/`, `android-build/build/`) that were churning on every Gradle invocation, and add the matching `.gitignore` rules. Also gitignored: `docs/examples/_reports/` (CI-generated doc-test report), `/assets/` + `benchmarks/suite/assets/` (external game-project assets the user keeps adjacent for perry-ui-* manual testing — never source), and stray repro binaries `enum_repro`/`no_pragma_test`. Bench methodology: `json_polyglot/run.sh` precompiles Node TS to `.mjs` (esbuild → npx-esbuild → tsc fallback chain) as untimed setup so Node isn't charged for `--experimental-strip-types`'s per-launch parse on every run — Perry is AOT and Bun strips natively, so neither pays this; falls back to the old `--experimental-strip-types` invocation with a banner if no stripper is available. `polyglot/bench.rs` gains an FP-contract caveat block on `bench_loop_data_dependent` documenting the FMA-contract (Apple Clang, Go) vs no-contract (Rust, Swift, Perry, Node, Bun, Java) clustering. Plus `tests/test_array_index_loop.sh` runner companion to the existing `.ts` regression test. +- **v0.5.292** — CLAUDE.md hygiene: migrated 124 verbose Recent Changes entries (~242 KB) to CHANGELOG.md verbatim, condensed the section to the last 22 versions at 1-2 lines each. CLAUDE.md 254 KB → 12 KB (95% reduction). Save the always-loaded context budget for actual project guidance. +- **v0.5.291** — Land the actual workflow code for v0.5.289's CI disk-space fix. +- **v0.5.290** — Stub audit: `test_gap_console_methods` removed from `known_failures.json` — passes through the parity-runner's `normalize_output` despite the raw diff showing different timer values. +- **v0.5.289** — CI hygiene: stop the `Tests` workflow's macos-14 jobs from OOM'ing on disk space. +- **v0.5.288** — Stub audit: `test_json` removed from `known_failures.json`, incidentally fixed by v0.5.286's `JSON.stringify()` segfault fix. +- **v0.5.286** — Stub audit: `JSON.stringify()` segfaulted. +- **v0.5.285** — Bench docs prose pass on `benchmarks/README.md`. +- **v0.5.284** — Stub audit: two correctness bugs in the Promise microtask runner. +- **v0.5.283** — Bench docs: rewrote the f64 bullet in `benchmarks/README.md` §Strengths so it doesn't carry contradictory framing. +- **v0.5.281** — Stub audit: two distinct bugs in the NaN/number-formatting family. +- **v0.5.280** — Stub audit: NaN/Infinity ToInt32 coercion. +- **v0.5.279** — #187 follow-up (stub audit): SSO + property-read NaN bug. +- **v0.5.278** — Stub audit: `is_inlinable` in `crates/perry-transform/src/inline.rs:213` was inlining functions with rest parameters even though the inliner's `param_map` mechanism only handles 1:1 formal-to-actual arg mapping — so `function… +- **v0.5.277** — Stub audit: `fs.readFileSync(path)` (no encoding) now returns a real Buffer, matching Node. +- **v0.5.276** — Bench docs: footnote on `04_array_read`'s 211 MB peak RSS row + new `benchmarks/polyglot/ARRAY_READ_NOTES.md` with analytic working-set math (10M f64 doubling fill, 8M-cap + 16M-cap coexist mid-grow → 192 MB arena peak + ~13 MB overhead),… +- **v0.5.275** — #187 follow-up: async-factory pattern for `pg`'s `Client`/`Pool` and `mongodb`'s `MongoClient` — the npm-compatible `new T(config); await t.connect()` shape. +- **v0.5.274** — Bench credibility: add the two comparison rows the page was missing. +- **v0.5.273** — Stub audit: closure-null family fix. +- **v0.5.272** — Bench refactor (code landing): the v0.5.271 entry below described two new benchmarks and a README restructure, but only metadata changes (CLAUDE.md, Cargo.toml) actually shipped under v0.5.271 due to a race during commit. +- **v0.5.271** — Bench refactor: add the two benchmarks that address the most-likely skeptic objections to this README within 30 seconds of reading it. +- **v0.5.270** — #187 follow-up: `Redis` (ioredis) end-to-end + fixes a pre-existing dispatch-table-symbol-mismatch bug. + +Older entries → CHANGELOG.md. diff --git a/Cargo.lock b/Cargo.lock index e6fd52135..0b367431d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4160,7 +4160,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "atty", @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "log", @@ -4218,7 +4218,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "perry-hir", @@ -4226,7 +4226,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "perry-dispatch", @@ -4236,7 +4236,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "perry-hir", @@ -4245,7 +4245,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "base64", @@ -4258,7 +4258,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "perry-hir", @@ -4266,7 +4266,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.370" +version = "0.5.371" dependencies = [ "serde", "serde_json", @@ -4274,11 +4274,11 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.370" +version = "0.5.371" [[package]] name = "perry-doc-tests" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "clap", @@ -4292,7 +4292,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "perry-diagnostics", @@ -4305,7 +4305,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "deno_core", @@ -4324,7 +4324,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "perry-diagnostics", @@ -4336,7 +4336,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "base64", @@ -4357,7 +4357,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.370" +version = "0.5.371" dependencies = [ "aes", "aes-gcm", @@ -4423,7 +4423,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "perry-hir", @@ -4433,7 +4433,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.370" +version = "0.5.371" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -4441,11 +4441,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.370" +version = "0.5.371" [[package]] name = "perry-ui-android" -version = "0.5.370" +version = "0.5.371" dependencies = [ "itoa", "jni", @@ -4459,7 +4459,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.370" +version = "0.5.371" dependencies = [ "rand 0.8.5", "serde", @@ -4469,7 +4469,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.370" +version = "0.5.371" dependencies = [ "cairo-rs", "gtk4", @@ -4481,7 +4481,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.370" +version = "0.5.371" dependencies = [ "block2", "libc", @@ -4496,7 +4496,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.370" +version = "0.5.371" dependencies = [ "block2", "libc", @@ -4514,11 +4514,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.370" +version = "0.5.371" [[package]] name = "perry-ui-tvos" -version = "0.5.370" +version = "0.5.371" dependencies = [ "block2", "libc", @@ -4533,7 +4533,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.370" +version = "0.5.371" dependencies = [ "block2", "libc", @@ -4548,7 +4548,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.370" +version = "0.5.371" dependencies = [ "block2", "libc", @@ -4561,7 +4561,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.370" +version = "0.5.371" dependencies = [ "libc", "perry-runtime", @@ -4573,7 +4573,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.370" +version = "0.5.371" dependencies = [ "base64", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index bb85cc2cc..4abaa6d61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,7 +113,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.370" +version = "0.5.371" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/codegen.rs b/crates/perry-codegen/src/codegen.rs index 94ea60e22..9655b65d7 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -469,6 +469,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> body: Vec::new(), is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -1389,6 +1390,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> body: ctor_body.1, is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: ctor_body.2, decorators: Vec::new(), diff --git a/crates/perry-hir/src/ir.rs b/crates/perry-hir/src/ir.rs index 3f0fe55d4..efad0775a 100644 --- a/crates/perry-hir/src/ir.rs +++ b/crates/perry-hir/src/ir.rs @@ -677,6 +677,12 @@ pub struct Function { pub captures: Vec, /// Decorators applied to this function/method pub decorators: Vec, + /// Issue #256: true if this function was originally a plain async function + /// that the async_to_generator pre-pass rewrote into a generator. The + /// generator state-machine transform reads this flag and wraps the + /// resulting iterator in an async-step driver so the function returns + /// a Promise that respects spec microtask ordering. + pub was_plain_async: bool, } /// A function parameter diff --git a/crates/perry-hir/src/lower.rs b/crates/perry-hir/src/lower.rs index 9e2e1e79d..806f2ac22 100644 --- a/crates/perry-hir/src/lower.rs +++ b/crates/perry-hir/src/lower.rs @@ -742,6 +742,7 @@ impl LoweringContext { body: ctor_body, is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index c5540660e..d27ade30a 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -355,6 +355,7 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R body, is_async: method.function.is_async, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), diff --git a/crates/perry-hir/src/lower_decl.rs b/crates/perry-hir/src/lower_decl.rs index 136655619..3a7f80bd3 100644 --- a/crates/perry-hir/src/lower_decl.rs +++ b/crates/perry-hir/src/lower_decl.rs @@ -413,6 +413,7 @@ pub(crate) fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> body, is_async: fn_decl.function.is_async, is_generator: fn_decl.function.is_generator, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -692,6 +693,7 @@ pub(crate) fn lower_class_decl(ctx: &mut LoweringContext, class_decl: &ast::Clas body, is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -784,6 +786,7 @@ pub(crate) fn lower_class_decl(ctx: &mut LoweringContext, class_decl: &ast::Clas body, is_async: false, is_generator: true, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -875,6 +878,7 @@ pub(crate) fn lower_class_decl(ctx: &mut LoweringContext, class_decl: &ast::Clas body, is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -1221,6 +1225,7 @@ pub(crate) fn lower_class_decl(ctx: &mut LoweringContext, class_decl: &ast::Clas body: Vec::new(), is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -1475,6 +1480,7 @@ pub(crate) fn lower_class_from_ast(ctx: &mut LoweringContext, class: &ast::Class body, is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -1879,6 +1885,7 @@ pub(crate) fn lower_constructor(ctx: &mut LoweringContext, class_name: &str, cto body, is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -2151,6 +2158,7 @@ pub(crate) fn lower_class_method(ctx: &mut LoweringContext, method: &ast::ClassM is_exported: false, captures: Vec::new(), decorators, + was_plain_async: false, }) } @@ -2215,6 +2223,7 @@ pub(crate) fn lower_getter_method(ctx: &mut LoweringContext, method: &ast::Class body, is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -2267,6 +2276,7 @@ pub(crate) fn lower_setter_method(ctx: &mut LoweringContext, method: &ast::Class body, is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -2362,6 +2372,7 @@ pub(crate) fn lower_private_method(ctx: &mut LoweringContext, method: &ast::Priv body, is_async: method.function.is_async, is_generator: method.function.is_generator, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -2398,6 +2409,7 @@ pub(crate) fn lower_private_getter(ctx: &mut LoweringContext, method: &ast::Priv body, is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -2441,6 +2453,7 @@ pub(crate) fn lower_private_setter(ctx: &mut LoweringContext, method: &ast::Priv body, is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), diff --git a/crates/perry-hir/src/monomorph.rs b/crates/perry-hir/src/monomorph.rs index 12180b86c..9541fdf4e 100644 --- a/crates/perry-hir/src/monomorph.rs +++ b/crates/perry-hir/src/monomorph.rs @@ -1509,6 +1509,7 @@ pub fn specialize_function( body: substitute_stmts(&func.body, &substitutions), is_async: func.is_async, is_generator: func.is_generator, + was_plain_async: false, is_exported: false, // Specialized versions are internal captures: func.captures.clone(), decorators: func.decorators.clone(), @@ -1562,6 +1563,7 @@ pub fn specialize_class( body: substitute_stmts(&ctor.body, &substitutions), is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: ctor.captures.clone(), decorators: ctor.decorators.clone(), @@ -1583,6 +1585,7 @@ pub fn specialize_class( body: substitute_stmts(&m.body, &substitutions), is_async: m.is_async, is_generator: m.is_generator, + was_plain_async: false, is_exported: false, captures: m.captures.clone(), decorators: m.decorators.clone(), @@ -1598,6 +1601,7 @@ pub fn specialize_class( body: substitute_stmts(&f.body, &substitutions), is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: f.captures.clone(), decorators: f.decorators.clone(), @@ -1619,6 +1623,7 @@ pub fn specialize_class( body: substitute_stmts(&f.body, &substitutions), is_async: false, is_generator: false, + was_plain_async: false, is_exported: false, captures: f.captures.clone(), decorators: f.decorators.clone(), @@ -2945,6 +2950,7 @@ mod tests { body: vec![Stmt::Return(Some(Expr::LocalGet(0)))], is_async: false, is_generator: false, + was_plain_async: false, is_exported: true, captures: vec![], decorators: vec![], @@ -3000,6 +3006,7 @@ mod tests { body: vec![Stmt::Return(Some(Expr::LocalGet(0)))], is_async: false, is_generator: false, + was_plain_async: false, is_exported: true, captures: vec![], decorators: vec![], @@ -3055,6 +3062,7 @@ mod tests { body: vec![Stmt::Return(Some(Expr::LocalGet(0)))], is_async: false, is_generator: false, + was_plain_async: false, is_exported: true, captures: vec![], decorators: vec![], @@ -3123,6 +3131,7 @@ mod tests { body: vec![Stmt::Return(Some(Expr::LocalGet(0)))], is_async: false, is_generator: false, + was_plain_async: false, is_exported: true, captures: vec![], decorators: vec![], diff --git a/crates/perry-runtime/src/promise.rs b/crates/perry-runtime/src/promise.rs index 4a8332143..2e16c492f 100644 --- a/crates/perry-runtime/src/promise.rs +++ b/crates/perry-runtime/src/promise.rs @@ -383,7 +383,29 @@ pub extern "C" fn js_promise_run_microtasks() -> i32 { let result = crate::closure::js_closure_call1(callback, value); crate::exception::js_try_end(); if !(*promise).next.is_null() { - js_promise_resolve((*promise).next, result); + // Spec: when a .then callback returns a thenable + // (a Promise), the chained `next` promise must + // adopt the thenable's eventual state, not store + // the Promise pointer as its value. Issue #256: + // pre-fix Perry's runtime stored the Promise + // pointer directly, so the async-step driver's + // recursive `step()` returns produced + // `Promise>` chains that never + // unwrapped to the real value. Without this + // unwrap, `await fn()` of an async function with + // multiple `await`s observes a Pending Promise as + // the resolved value. + if js_value_is_promise(result) != 0 { + let inner = crate::value::js_nanbox_get_pointer(result) + as *mut Promise; + if !inner.is_null() && inner != (*promise).next { + js_promise_resolve_with_promise((*promise).next, inner); + } else { + js_promise_resolve((*promise).next, result); + } + } else { + js_promise_resolve((*promise).next, result); + } } } else { // Callback threw — convert to rejection of next diff --git a/crates/perry-transform/src/async_to_generator.rs b/crates/perry-transform/src/async_to_generator.rs new file mode 100644 index 000000000..0cf45f6e8 --- /dev/null +++ b/crates/perry-transform/src/async_to_generator.rs @@ -0,0 +1,552 @@ +//! Issue #256: spec-compliant microtask ordering for plain async functions. +//! +//! ## What this pass does +//! +//! Pre-pass that runs before `transform_generators`. For every top-level +//! function with `is_async = true && !is_generator`: +//! +//! 1. **Hoists non-top-level awaits**: any `await x` not in a top-level +//! statement position (let init, expr stmt, return) is lifted into a +//! fresh `let __awaitN = await x;` placed before the containing +//! statement, and the original site is replaced with `LocalGet(__awaitN)`. +//! Without this, expressions like `console.log("x: " + await y)` lower +//! to `console.log("x: " + 0)` because the generator transform's +//! `linearize_body` only recognises yields at top-level positions; a +//! yield buried inside a concat operator hits codegen's +//! `Expr::Yield => double_literal(0.0)` arm instead. +//! 2. **Rewrites await→yield**: every `Expr::Await(x)` becomes +//! `Expr::Yield { value: Some(x), delegate: false }`. +//! 3. **Flips the flags**: `is_async = false`, `is_generator = true`, +//! `was_plain_async = true`. +//! +//! After this pass, the existing generator state-machine transform lifts +//! the function into a `{ next, return, throw }` iterator. The +//! `was_plain_async` flag tells the generator transform to wrap the +//! iterator in an async-step driver so the function returns a Promise +//! that resolves to the user's return value, with each yield/await +//! suspending into a microtask. +//! +//! ## Why this fixes the spec gap +//! +//! Pre-fix Perry's async functions ran their entire body synchronously on +//! the calling thread, with each `await` lowered to a busy-wait poll loop +//! on the awaited Promise. This diverges from spec semantics: an `await` +//! should always yield to the microtask queue, even on already-resolved +//! Promises, so synchronous code following an unawaited async call runs +//! before the awaited body's continuation. +//! +//! Post-fix the async function becomes a state machine. The first state +//! runs synchronously (matching spec). Each `await x` lowers to a yield +//! that suspends the state machine and chains the continuation through +//! `Promise.resolve(x).then(continuation)`, which puts the rest of the +//! body in a microtask. The microtask runs after all currently-executing +//! synchronous code finishes — exactly the spec ordering. +//! +//! ## Scope and limitations (v1) +//! +//! - **Top-level functions only**: nested async closures (arrow/function +//! expressions assigned to locals) are NOT yet rewritten. They keep +//! the pre-fix direct-call/busy-wait behavior. Follow-up. +//! - **No new HIR variants or runtime helpers**: the rewrite produces +//! only existing variants (Yield, Closure, Promise.then chains via +//! GlobalGet(0)). The async-step driver is built inline in the +//! generator transform. This sidesteps the LLVM constant-folding +//! mystery the prior prototype hit (issue #256 background section 1). + +use perry_hir::ir::*; +use perry_types::{LocalId, Type}; + +/// Run the pre-pass on every async function in the module. +pub fn transform_async_to_generator(module: &mut Module) { + // Conservative module-level scope: skip the rewrite ENTIRELY if the + // module has classes with __perry_cap_* fields (the v0.5.323 issue + // #212 capture rewrite). The async-step driver's fresh LocalId + // allocations can collide with the v0.5.323 method-local rebind + // ids — manifests as `[PERRY WARN] js_box_set: null box pointer` + // when the colliding LocalGet for the async-step's `__iter` returns + // the captured-by-class-method box pointer instead of the iter + // object. The collision is path-dependent on which ids `next_local_id` + // happened to land on; safer to bail on the whole module than to + // ship a coin-flip fix. Issue #212-style capturing classes are the + // ONLY known trigger, so this scope is tight enough that the issue + // #256 microtask-ordering reproducer (no classes) still gets the + // fix. + if module_has_capturing_classes(module) { + return; + } + let mut next_local_id = compute_max_local_id(module) + 1; + for func in &mut module.functions { + if func.is_async && !func.is_generator { + // Per-function conservative scope: skip if the body has a + // nested closure with captures (forEach pattern, etc.). + if body_has_capturing_closure(&func.body) { + continue; + } + let mut had_await = false; + // First, hoist non-top-level awaits in every statement so + // every Await ends up in a top-level position the generator + // transform's `linearize_body` can split states at. + hoist_awaits_in_stmts(&mut func.body, &mut next_local_id); + // Then rewrite all awaits (now in top-level positions) to + // yields and flip the flag. + rewrite_stmts(&mut func.body, &mut had_await); + // Even if the body had no awaits, the function is still async + // semantically (its return value gets wrapped in a Promise). + // Without awaits, the existing direct-call path is correct + // and cheaper, so we leave is_async alone in that case. + if had_await { + func.is_async = false; + func.is_generator = true; + func.was_plain_async = true; + } + } + } +} + +/// Detect if the module has any classes with `__perry_cap_*` instance +/// fields — the marker that the v0.5.323 issue #212 capture rewrite was +/// applied. These classes have method bodies with method-local rebind +/// LocalIds that share the global LocalId namespace; my pre-pass's +/// fresh-id allocations can collide with them. +fn module_has_capturing_classes(module: &Module) -> bool { + for class in &module.classes { + for field in &class.fields { + if field.name.starts_with("__perry_cap_") { + return true; + } + } + } + false +} + +// ─── Conservative scope: detect nested capturing closures ──────────────── + +fn body_has_capturing_closure(stmts: &[Stmt]) -> bool { + stmts.iter().any(stmt_has_capturing_closure) +} + +fn stmt_has_capturing_closure(stmt: &Stmt) -> bool { + match stmt { + Stmt::Let { init: Some(e), .. } => expr_has_capturing_closure(e), + Stmt::Expr(e) | Stmt::Throw(e) => expr_has_capturing_closure(e), + Stmt::Return(Some(e)) => expr_has_capturing_closure(e), + Stmt::If { condition, then_branch, else_branch } => { + expr_has_capturing_closure(condition) + || body_has_capturing_closure(then_branch) + || else_branch.as_ref().map_or(false, |eb| body_has_capturing_closure(eb)) + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + expr_has_capturing_closure(condition) || body_has_capturing_closure(body) + } + Stmt::For { init, condition, update, body } => { + init.as_ref().map_or(false, |i| stmt_has_capturing_closure(i)) + || condition.as_ref().map_or(false, |c| expr_has_capturing_closure(c)) + || update.as_ref().map_or(false, |u| expr_has_capturing_closure(u)) + || body_has_capturing_closure(body) + } + Stmt::Try { body, catch, finally } => { + body_has_capturing_closure(body) + || catch.as_ref().map_or(false, |c| body_has_capturing_closure(&c.body)) + || finally.as_ref().map_or(false, |f| body_has_capturing_closure(f)) + } + Stmt::Switch { discriminant, cases } => { + expr_has_capturing_closure(discriminant) + || cases.iter().any(|c| body_has_capturing_closure(&c.body)) + } + Stmt::Labeled { body, .. } => stmt_has_capturing_closure(body), + _ => false, + } +} + +fn expr_has_capturing_closure(expr: &Expr) -> bool { + // Treat ANY nested Closure as risky, regardless of captures: even + // empty-captures closures may interact with the async-step driver in + // subtle ways (e.g. forEach/map/filter passing the closure through a + // native dispatch call where the closure gets stored). Better safe. + if matches!(expr, Expr::Closure { .. }) { + return true; + } + let mut found = false; + perry_hir::walker::walk_expr_children(expr, &mut |e| { + if !found && expr_has_capturing_closure(e) { + found = true; + } + }); + found +} + +/// Compute the max LocalId already used in the module so we can allocate +/// fresh ids for hoisted awaits without colliding. Mirrors +/// `generator::compute_max_local_id` but inlined here to avoid a +/// pub-visibility bump on the generator helper. +fn compute_max_local_id(module: &Module) -> LocalId { + let mut max_id: LocalId = 0; + for func in &module.functions { + for param in &func.params { + max_id = max_id.max(param.id); + } + scan_stmts(&func.body, &mut max_id); + } + for stmt in &module.init { + scan_stmt(stmt, &mut max_id); + } + for global in &module.globals { + max_id = max_id.max(global.id); + } + // Also scan class member bodies — they share the LocalId namespace. + // The v0.5.323 issue #212 fix allocates method-local "fresh ids" via + // ctx.fresh_local() for the per-method rebinds of captured outer + // locals (`let X = this.__perry_cap_`). Those ids are NOT in + // module.functions, but they DO live in the same global LocalId + // space my pre-pass allocates fresh ids from. Without this scan, my + // pre-pass's allocations for the async-step driver collide with + // class-method rebind ids — at codegen, the colliding LocalGet for + // the async-step's `__iter` returns the captured-by-class-method + // box pointer instead of the iter object, surfacing as the same + // `[PERRY WARN] js_box_set: null box pointer` chain that the + // forEach-inner-closure-captures-outer-array pattern produces. + for class in &module.classes { + for method in &class.methods { + for param in &method.params { + max_id = max_id.max(param.id); + } + scan_stmts(&method.body, &mut max_id); + } + for static_method in &class.static_methods { + for param in &static_method.params { + max_id = max_id.max(param.id); + } + scan_stmts(&static_method.body, &mut max_id); + } + if let Some(ctor) = &class.constructor { + for param in &ctor.params { + max_id = max_id.max(param.id); + } + scan_stmts(&ctor.body, &mut max_id); + } + for getter in &class.getters { + for param in &getter.1.params { + max_id = max_id.max(param.id); + } + scan_stmts(&getter.1.body, &mut max_id); + } + for setter in &class.setters { + for param in &setter.1.params { + max_id = max_id.max(param.id); + } + scan_stmts(&setter.1.body, &mut max_id); + } + } + max_id +} + +fn scan_stmts(stmts: &[Stmt], m: &mut LocalId) { + for s in stmts { scan_stmt(s, m); } +} + +fn scan_stmt(stmt: &Stmt, m: &mut LocalId) { + match stmt { + Stmt::Let { id, init, .. } => { + *m = (*m).max(*id); + if let Some(e) = init { scan_expr(e, m); } + } + Stmt::Expr(e) | Stmt::Throw(e) => scan_expr(e, m), + Stmt::Return(e) => { if let Some(e) = e { scan_expr(e, m); } } + Stmt::If { condition, then_branch, else_branch } => { + scan_expr(condition, m); + scan_stmts(then_branch, m); + if let Some(eb) = else_branch { scan_stmts(eb, m); } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + scan_expr(condition, m); + scan_stmts(body, m); + } + Stmt::For { init, condition, update, body } => { + if let Some(i) = init { scan_stmt(i, m); } + if let Some(c) = condition { scan_expr(c, m); } + if let Some(u) = update { scan_expr(u, m); } + scan_stmts(body, m); + } + Stmt::Try { body, catch, finally } => { + scan_stmts(body, m); + if let Some(c) = catch { + if let Some((id, _)) = c.param { *m = (*m).max(id); } + scan_stmts(&c.body, m); + } + if let Some(f) = finally { scan_stmts(f, m); } + } + Stmt::Switch { discriminant, cases } => { + scan_expr(discriminant, m); + for case in cases { scan_stmts(&case.body, m); } + } + Stmt::Labeled { body, .. } => scan_stmt(body, m), + _ => {} + } +} + +fn scan_expr(expr: &Expr, m: &mut LocalId) { + if let Expr::LocalGet(id) | Expr::LocalSet(id, _) = expr { + *m = (*m).max(*id); + } + if let Expr::Closure { params, captures, mutable_captures, body, .. } = expr { + for p in params { *m = (*m).max(p.id); } + for c in captures { *m = (*m).max(*c); } + for c in mutable_captures { *m = (*m).max(*c); } + scan_stmts(body, m); + return; + } + perry_hir::walker::walk_expr_children(expr, &mut |e| scan_expr(e, m)); +} + +fn alloc_local(next_id: &mut LocalId) -> LocalId { + let id = *next_id; + *next_id += 1; + id +} + +// ─── Hoist non-top-level awaits ────────────────────────────────────────── +// +// A "top-level" position is one of: +// - The full init expression of a `Stmt::Let { init: Some(_) }` +// - The full operand of a `Stmt::Expr(_)` +// - The full operand of a `Stmt::Return(Some(_))` +// +// In any other position (an arg of a Call, an operand of a BinOp, an +// element of an Object/Array literal, a condition of an If/While, etc.), +// the `Await` gets hoisted into a fresh `let __await{id} = await ` +// placed immediately before the containing statement, and the original +// site is replaced with `LocalGet(__await{id})`. +// +// We process statements one at a time and use mem::take + Vec splicing to +// insert the hoisted lets. Inner blocks (then/else/while-body/etc.) are +// processed recursively so awaits inside a nested `if (cond) { x = y + +// await z; }` are hoisted into the inner block, not the outer scope. + +fn hoist_awaits_in_stmts(stmts: &mut Vec, next_id: &mut LocalId) { + let mut out: Vec = Vec::with_capacity(stmts.len()); + for stmt in std::mem::take(stmts) { + let mut hoisted: Vec = Vec::new(); + let new_stmt = hoist_awaits_in_stmt(stmt, next_id, &mut hoisted); + for h in hoisted { out.push(h); } + out.push(new_stmt); + } + *stmts = out; +} + +fn hoist_awaits_in_stmt( + mut stmt: Stmt, + next_id: &mut LocalId, + hoisted: &mut Vec, +) -> Stmt { + match &mut stmt { + // Top-level positions: don't hoist the *outer* await but do + // hoist any nested awaits inside the operand. + Stmt::Let { init: Some(e), .. } => { + hoist_awaits_avoiding_top_level(e, next_id, hoisted); + } + Stmt::Expr(e) => { + hoist_awaits_avoiding_top_level(e, next_id, hoisted); + } + Stmt::Return(Some(e)) => { + hoist_awaits_avoiding_top_level(e, next_id, hoisted); + } + Stmt::Throw(e) => { + // `throw await x` — we treat this like a return: the outer + // await stays in place, inner awaits hoisted. + hoist_awaits_avoiding_top_level(e, next_id, hoisted); + } + Stmt::If { condition, then_branch, else_branch } => { + // The condition is NOT a top-level await position (it's + // nested in If) — fully hoist all awaits in it. + hoist_awaits_in_expr_full(condition, next_id, hoisted); + hoist_awaits_in_stmts(then_branch, next_id); + if let Some(eb) = else_branch { + hoist_awaits_in_stmts(eb, next_id); + } + } + Stmt::While { condition, body } => { + // While condition: fully hoist all awaits. The hoisted + // lets land before the while statement, but re-evaluating + // them on each iteration requires the await to fire each + // pass. JS spec: condition with await runs on every + // iteration. We don't currently support this — see the + // limitation in the doc comment. Single hoist per loop + // entry is the safe-but-incomplete approximation. + hoist_awaits_in_expr_full(condition, next_id, hoisted); + hoist_awaits_in_stmts(body, next_id); + } + Stmt::DoWhile { body, condition } => { + hoist_awaits_in_stmts(body, next_id); + hoist_awaits_in_expr_full(condition, next_id, hoisted); + } + Stmt::For { init, condition, update, body } => { + if let Some(i) = init { + let mut inner_hoisted = Vec::new(); + let i_replaced = hoist_awaits_in_stmt( + (**i).clone(), + next_id, + &mut inner_hoisted, + ); + for h in inner_hoisted { hoisted.push(h); } + *i = Box::new(i_replaced); + } + if let Some(c) = condition { hoist_awaits_in_expr_full(c, next_id, hoisted); } + if let Some(u) = update { hoist_awaits_in_expr_full(u, next_id, hoisted); } + hoist_awaits_in_stmts(body, next_id); + } + Stmt::Try { body, catch, finally } => { + hoist_awaits_in_stmts(body, next_id); + if let Some(c) = catch { + hoist_awaits_in_stmts(&mut c.body, next_id); + } + if let Some(f) = finally { + hoist_awaits_in_stmts(f, next_id); + } + } + Stmt::Switch { discriminant, cases } => { + hoist_awaits_in_expr_full(discriminant, next_id, hoisted); + for case in cases.iter_mut() { + if let Some(t) = &mut case.test { + hoist_awaits_in_expr_full(t, next_id, hoisted); + } + hoist_awaits_in_stmts(&mut case.body, next_id); + } + } + Stmt::Labeled { body, .. } => { + let mut inner = Vec::new(); + let body_taken = std::mem::replace(body.as_mut(), Stmt::Break); + let new_body = hoist_awaits_in_stmt(body_taken, next_id, &mut inner); + for h in inner { hoisted.push(h); } + **body = new_body; + } + _ => {} + } + stmt +} + +/// Hoist all awaits in an expression INCLUDING any at the top level of +/// the expression itself. Used for non-statement-positioned operands +/// (If condition, While condition, Switch discriminant, etc.). +fn hoist_awaits_in_expr_full(expr: &mut Expr, next_id: &mut LocalId, hoisted: &mut Vec) { + if matches!(expr, Expr::Closure { .. }) { + // Don't descend into closure bodies; nested closures are out of + // scope for the v1 plain-async pre-pass. + return; + } + // Recurse into children first (innermost-first hoisting). + perry_hir::walker::walk_expr_children_mut(expr, &mut |child| { + hoist_awaits_in_expr_full(child, next_id, hoisted); + }); + if matches!(expr, Expr::Await(_)) { + let id = alloc_local(next_id); + let original = std::mem::replace(expr, Expr::LocalGet(id)); + hoisted.push(Stmt::Let { + id, + name: format!("__await_{}", id), + ty: Type::Any, + mutable: false, + init: Some(original), + }); + } +} + +/// Hoist nested awaits but leave a top-level await alone. Used for +/// statement-positioned operands (Let init, Stmt::Expr operand, etc.) +/// where the outer await is something the generator transform handles. +fn hoist_awaits_avoiding_top_level( + expr: &mut Expr, + next_id: &mut LocalId, + hoisted: &mut Vec, +) { + if let Expr::Await(inner) = expr { + // Outer is an await — keep it but recursively hoist nested awaits + // inside the operand fully (they are nested, not top-level). + hoist_awaits_in_expr_full(inner.as_mut(), next_id, hoisted); + return; + } + if matches!(expr, Expr::Closure { .. }) { + return; + } + // Outer is NOT an await. Children may contain awaits which ARE + // nested — fully hoist them. + perry_hir::walker::walk_expr_children_mut(expr, &mut |child| { + hoist_awaits_in_expr_full(child, next_id, hoisted); + }); +} + +// ─── Rewrite await → yield ─────────────────────────────────────────────── +// +// Runs after hoisting, so every Await is now in a top-level position the +// generator transform can split states at. + +fn rewrite_stmts(stmts: &mut [Stmt], had_await: &mut bool) { + for stmt in stmts.iter_mut() { + rewrite_stmt(stmt, had_await); + } +} + +fn rewrite_stmt(stmt: &mut Stmt, had_await: &mut bool) { + match stmt { + Stmt::Let { init: Some(e), .. } => rewrite_expr(e, had_await), + Stmt::Expr(e) => rewrite_expr(e, had_await), + Stmt::Return(Some(e)) => rewrite_expr(e, had_await), + Stmt::Throw(e) => rewrite_expr(e, had_await), + Stmt::If { condition, then_branch, else_branch } => { + rewrite_expr(condition, had_await); + rewrite_stmts(then_branch, had_await); + if let Some(eb) = else_branch { + rewrite_stmts(eb, had_await); + } + } + Stmt::While { condition, body } => { + rewrite_expr(condition, had_await); + rewrite_stmts(body, had_await); + } + Stmt::DoWhile { body, condition } => { + rewrite_stmts(body, had_await); + rewrite_expr(condition, had_await); + } + Stmt::For { init, condition, update, body } => { + if let Some(i) = init { rewrite_stmt(i, had_await); } + if let Some(c) = condition { rewrite_expr(c, had_await); } + if let Some(u) = update { rewrite_expr(u, had_await); } + rewrite_stmts(body, had_await); + } + Stmt::Try { body, catch, finally } => { + rewrite_stmts(body, had_await); + if let Some(c) = catch { + rewrite_stmts(&mut c.body, had_await); + } + if let Some(f) = finally { + rewrite_stmts(f, had_await); + } + } + Stmt::Switch { discriminant, cases } => { + rewrite_expr(discriminant, had_await); + for case in cases.iter_mut() { + rewrite_stmts(&mut case.body, had_await); + } + } + Stmt::Labeled { body, .. } => rewrite_stmt(body, had_await), + _ => {} + } +} + +fn rewrite_expr(expr: &mut Expr, had_await: &mut bool) { + if matches!(expr, Expr::Await(_)) { + *had_await = true; + if let Expr::Await(inner) = std::mem::replace(expr, Expr::Undefined) { + let mut inner = *inner; + rewrite_expr(&mut inner, had_await); + *expr = Expr::Yield { + value: Some(Box::new(inner)), + delegate: false, + }; + } + return; + } + if matches!(expr, Expr::Closure { .. }) { + return; + } + perry_hir::walker::walk_expr_children_mut(expr, &mut |e| rewrite_expr(e, had_await)); +} diff --git a/crates/perry-transform/src/generator.rs b/crates/perry-transform/src/generator.rs index 03b569dbe..61597faea 100644 --- a/crates/perry-transform/src/generator.rs +++ b/crates/perry-transform/src/generator.rs @@ -39,6 +39,44 @@ fn compute_max_local_id(module: &Module) -> LocalId { for global in &module.globals { max_id = max_id.max(global.id); } + // Also scan class member bodies — they share the LocalId namespace. + // The v0.5.323 issue #212 fix allocates method-local rebind ids per + // class method per captured outer local; without this scan, the + // generator transform's freshly-allocated state/done/sent/wrapper + // ids could collide with those rebind ids and corrupt unrelated + // class-method codegen. + for class in &module.classes { + for method in &class.methods { + for param in &method.params { + max_id = max_id.max(param.id); + } + scan_stmts_for_max_local(&method.body, &mut max_id); + } + for static_method in &class.static_methods { + for param in &static_method.params { + max_id = max_id.max(param.id); + } + scan_stmts_for_max_local(&static_method.body, &mut max_id); + } + if let Some(ctor) = &class.constructor { + for param in &ctor.params { + max_id = max_id.max(param.id); + } + scan_stmts_for_max_local(&ctor.body, &mut max_id); + } + for getter in &class.getters { + for param in &getter.1.params { + max_id = max_id.max(param.id); + } + scan_stmts_for_max_local(&getter.1.body, &mut max_id); + } + for setter in &class.setters { + for param in &setter.1.params { + max_id = max_id.max(param.id); + } + scan_stmts_for_max_local(&setter.1.body, &mut max_id); + } + } max_id } @@ -335,6 +373,68 @@ fn scan_expr_for_max_func(expr: &Expr, max_id: &mut FuncId) { } /// Allocate a fresh local ID. +/// Recursively rewrite `Stmt::Let { id, init: Some(...) }` to +/// `Stmt::Expr(LocalSet(id, init))` for any id in `hoisted_ids`. Walks +/// into nested control-flow (For init/body, While body, If branches, +/// Try body/catch/finally, Switch case bodies, Labeled body) so a Let +/// nested inside a for-of's desugared loop body still gets routed +/// through the captured box. Issue #256. +fn rewrite_hoisted_lets_in_stmts( + stmts: &mut [Stmt], + hoisted_ids: &std::collections::HashSet, +) { + for stmt in stmts.iter_mut() { + rewrite_hoisted_lets_in_stmt(stmt, hoisted_ids); + } +} + +fn rewrite_hoisted_lets_in_stmt( + stmt: &mut Stmt, + hoisted_ids: &std::collections::HashSet, +) { + if let Stmt::Let { id, init: Some(init_expr), .. } = stmt { + if hoisted_ids.contains(id) { + *stmt = Stmt::Expr(Expr::LocalSet(*id, Box::new(init_expr.clone()))); + return; + } + } + match stmt { + Stmt::If { then_branch, else_branch, .. } => { + rewrite_hoisted_lets_in_stmts(then_branch, hoisted_ids); + if let Some(eb) = else_branch { + rewrite_hoisted_lets_in_stmts(eb, hoisted_ids); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + rewrite_hoisted_lets_in_stmts(body, hoisted_ids); + } + Stmt::For { init, body, .. } => { + if let Some(i) = init { + rewrite_hoisted_lets_in_stmt(i, hoisted_ids); + } + rewrite_hoisted_lets_in_stmts(body, hoisted_ids); + } + Stmt::Try { body, catch, finally } => { + rewrite_hoisted_lets_in_stmts(body, hoisted_ids); + if let Some(c) = catch { + rewrite_hoisted_lets_in_stmts(&mut c.body, hoisted_ids); + } + if let Some(f) = finally { + rewrite_hoisted_lets_in_stmts(f, hoisted_ids); + } + } + Stmt::Switch { cases, .. } => { + for case in cases.iter_mut() { + rewrite_hoisted_lets_in_stmts(&mut case.body, hoisted_ids); + } + } + Stmt::Labeled { body, .. } => { + rewrite_hoisted_lets_in_stmt(body, hoisted_ids); + } + _ => {} + } +} + fn alloc_local(next_id: &mut u32) -> LocalId { let id = *next_id; *next_id += 1; @@ -469,14 +569,15 @@ fn transform_generator_function(func: &mut Function, next_local_id: &mut u32, ne // variables inside state bodies. Without this, the Let creates a fresh local that // shadows the captured box, and subsequent mutations in other states don't see the // update. + // + // Issue #256: must recurse into nested control-flow (For/While/If/Try/Switch + // bodies). A for-of loop inside a state body desugars to a `for (let i = 0; + // i < arr.length; ++i) { let v = arr[i]; ... }` shape; without the recursion + // the inner `let v` and `let i` stay as Lets and create shadow slots that + // hide the outer captured box. Manifested as `for (const v of arr) sum += v` + // returning sum=0 inside transformed async functions (test_issue_233). for state in &mut states { - for stmt in &mut state.body { - if let Stmt::Let { id, init: Some(init_expr), .. } = stmt { - if hoisted_ids.contains(id) { - *stmt = Stmt::Expr(Expr::LocalSet(*id, Box::new(init_expr.clone()))); - } - } - } + rewrite_hoisted_lets_in_stmts(&mut state.body, &hoisted_ids); } // Build the if-chain inside while(true) @@ -732,17 +833,282 @@ fn transform_generator_function(func: &mut Function, next_local_id: &mut u32, ne is_async: false, }; - // return { next: , return: , throw: } - new_body.push(Stmt::Return(Some(Expr::Object(vec![ + // Build the iterator object expression. + let iter_obj = Expr::Object(vec![ ("next".to_string(), next_closure), ("return".to_string(), return_closure), ("throw".to_string(), throw_closure), - ])))); + ]); + + if func.was_plain_async { + // Issue #256: this function was originally a plain async function; + // the async_to_generator pre-pass rewrote await→yield. Wrap the + // iterator in an async-step driver so the function returns a + // Promise that respects spec microtask ordering. See + // `build_async_step_driver` for the structure. + let wrapper_stmts = build_async_step_driver(iter_obj, next_local_id, next_func_id); + for s in wrapper_stmts { + new_body.push(s); + } + func.was_plain_async = false; // consumed + } else { + // Plain generator: return the iterator object directly. + new_body.push(Stmt::Return(Some(iter_obj))); + } func.body = new_body; func.is_generator = false; } +/// Build the async-step driver (issue #256). Returns the statements that +/// take the place of the plain `return iter_obj` that a normal generator +/// would emit. Equivalent TypeScript: +/// +/// ```ts +/// const __iter = ; +/// let __step; +/// __step = (value, isError) => { +/// let r; +/// try { +/// r = isError ? __iter.throw(value) : __iter.next(value); +/// } catch (e) { +/// return Promise.reject(e); +/// } +/// if (r.done) return Promise.resolve(r.value); +/// return Promise.resolve(r.value).then( +/// v => __step(v, false), +/// e => __step(e, true), +/// ); +/// }; +/// return __step(undefined, false); +/// ``` +/// +/// The two-step `let __step; __step = ...;` pattern is required because +/// Perry's closure-capture analysis silently produces `NaN` for the +/// `const f = (...)=>f(...)` form (verified at v0.5.362 — see issue #256 +/// background investigation). With the two-step pattern, the closure +/// captures `__step` mutably; by the time `__step(undefined, false)` is +/// invoked at the outer return site, the box holds the closure value and +/// the recursive references inside `.then` callbacks resolve correctly. +fn build_async_step_driver( + iter_obj: Expr, + next_local_id: &mut u32, + next_func_id: &mut u32, +) -> Vec { + let iter_id = alloc_local(next_local_id); + let step_id = alloc_local(next_local_id); + + // Step closure params + locals + let value_param_id = alloc_local(next_local_id); + let is_error_param_id = alloc_local(next_local_id); + let r_id = alloc_local(next_local_id); + let catch_e_id = alloc_local(next_local_id); + + // Inner .then arrow params + let then_v_param_id = alloc_local(next_local_id); + let then_e_param_id = alloc_local(next_local_id); + + let step_func_id = { let id = *next_func_id; *next_func_id += 1; id }; + let then_v_func_id = { let id = *next_func_id; *next_func_id += 1; id }; + let then_e_func_id = { let id = *next_func_id; *next_func_id += 1; id }; + + let any_ty = Type::Any; + let bool_ty = Type::Boolean; + + // Helper builders + let promise_global = || Expr::GlobalGet(0); + let promise_resolve = |arg: Expr| Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(promise_global()), + property: "resolve".to_string(), + }), + args: vec![arg], + type_args: vec![], + }; + let promise_reject = |arg: Expr| Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(promise_global()), + property: "reject".to_string(), + }), + args: vec![arg], + type_args: vec![], + }; + + // Build the two .then arrows: (v) => __step(v, false) and (e) => __step(e, true) + let then_v_arrow = Expr::Closure { + func_id: then_v_func_id, + params: vec![perry_hir::Param { + id: then_v_param_id, + name: "__step_v".to_string(), + ty: any_ty.clone(), + is_rest: false, + default: None, + }], + return_type: any_ty.clone(), + body: vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::LocalGet(step_id)), + args: vec![Expr::LocalGet(then_v_param_id), Expr::Bool(false)], + type_args: vec![], + }))], + captures: vec![step_id], + mutable_captures: vec![step_id], + captures_this: false, + enclosing_class: None, + is_async: false, + }; + let then_e_arrow = Expr::Closure { + func_id: then_e_func_id, + params: vec![perry_hir::Param { + id: then_e_param_id, + name: "__step_e".to_string(), + ty: any_ty.clone(), + is_rest: false, + default: None, + }], + return_type: any_ty.clone(), + body: vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::LocalGet(step_id)), + args: vec![Expr::LocalGet(then_e_param_id), Expr::Bool(true)], + type_args: vec![], + }))], + captures: vec![step_id], + mutable_captures: vec![step_id], + captures_this: false, + enclosing_class: None, + is_async: false, + }; + + // step body + // let r; + // try { + // r = isError ? __iter.throw(value) : __iter.next(value); + // } catch (e) { + // return Promise.reject(e); + // } + // if (r.done) return Promise.resolve(r.value); + // return Promise.resolve(r.value).then(, ); + let iter_throw_call = Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(iter_id)), + property: "throw".to_string(), + }), + args: vec![Expr::LocalGet(value_param_id)], + type_args: vec![], + }; + let iter_next_call = Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(iter_id)), + property: "next".to_string(), + }), + args: vec![Expr::LocalGet(value_param_id)], + type_args: vec![], + }; + let dispatch_iter = Expr::Conditional { + condition: Box::new(Expr::LocalGet(is_error_param_id)), + then_expr: Box::new(iter_throw_call), + else_expr: Box::new(iter_next_call), + }; + + let step_body: Vec = vec![ + // let r; + Stmt::Let { + id: r_id, + name: "__step_r".to_string(), + ty: any_ty.clone(), + mutable: true, + init: None, + }, + // try { r = ...; } catch (e) { return Promise.reject(e); } + Stmt::Try { + body: vec![Stmt::Expr(Expr::LocalSet(r_id, Box::new(dispatch_iter)))], + catch: Some(CatchClause { + param: Some((catch_e_id, "__step_catch_e".to_string())), + body: vec![Stmt::Return(Some(promise_reject(Expr::LocalGet(catch_e_id))))], + }), + finally: None, + }, + // if (r.done) return Promise.resolve(r.value); + Stmt::If { + condition: Expr::PropertyGet { + object: Box::new(Expr::LocalGet(r_id)), + property: "done".to_string(), + }, + then_branch: vec![Stmt::Return(Some(promise_resolve(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(r_id)), + property: "value".to_string(), + })))], + else_branch: None, + }, + // return Promise.resolve(r.value).then(, ); + Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(promise_resolve(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(r_id)), + property: "value".to_string(), + })), + property: "then".to_string(), + }), + args: vec![then_v_arrow, then_e_arrow], + type_args: vec![], + })), + ]; + + let step_closure = Expr::Closure { + func_id: step_func_id, + params: vec![ + perry_hir::Param { + id: value_param_id, + name: "__step_value".to_string(), + ty: any_ty.clone(), + is_rest: false, + default: None, + }, + perry_hir::Param { + id: is_error_param_id, + name: "__step_is_error".to_string(), + ty: bool_ty.clone(), + is_rest: false, + default: None, + }, + ], + return_type: any_ty.clone(), + body: step_body, + captures: vec![iter_id, step_id], + mutable_captures: vec![step_id], + captures_this: false, + enclosing_class: None, + is_async: false, + }; + + // Outer wrapper: + // let __iter = ; + // let __step; // declared, init=undefined + // __step = ; + // return __step(undefined, false); + vec![ + Stmt::Let { + id: iter_id, + name: "__async_iter".to_string(), + ty: any_ty.clone(), + mutable: false, + init: Some(iter_obj), + }, + Stmt::Let { + id: step_id, + name: "__async_step".to_string(), + ty: any_ty.clone(), + mutable: true, + init: None, + }, + Stmt::Expr(Expr::LocalSet(step_id, Box::new(step_closure))), + Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::LocalGet(step_id)), + args: vec![Expr::Undefined, Expr::Bool(false)], + type_args: vec![], + })), + ] +} + struct State { num: u32, body: Vec, @@ -1008,10 +1374,26 @@ fn linearize_body( // first catch encountered will run on .throw(). Catches themselves // must not yield — they run to completion inside the throw closure. Stmt::Try { body, catch, finally } - if body_contains_yield(body) => + if body_contains_yield(body) + || finally.as_ref().map_or(false, |f| body_contains_yield(f)) => { - // Linearize the try body directly (yields become normal states) - linearize_body(body, states, current, state_num, state_id, next_local_id, sent_id, catches); + // Issue #256: widen the guard to also fire when yields live ONLY + // in the finally block. `await using` desugars to + // `try { body } finally { await dispose() }` — the body may have + // no awaits while the finally has one, and pre-fix this fell into + // the catch-all which compiled the whole try/finally as a single + // unit inside one state — the yield-in-finally then hit the + // codegen `Expr::Yield => double_literal(0.0)` arm and the await + // was silently fire-and-forgotten. + if body_contains_yield(body) { + // Linearize the try body directly (yields become normal states) + linearize_body(body, states, current, state_num, state_id, next_local_id, sent_id, catches); + } else { + // Body has no yields: push as-is to current state. + for s in body { + current.push(s.clone()); + } + } // Stash the catch so transform_generator_function can inline it // into the .throw() closure later. @@ -1020,10 +1402,15 @@ fn linearize_body( catches.push((param_id, catch_clause.body.clone())); } - // Finally block always runs + // Finally block: linearize if it has yields (await-using path), + // otherwise push as-is. if let Some(fin) = finally { - for s in fin { - current.push(s.clone()); + if body_contains_yield(fin) { + linearize_body(fin, states, current, state_num, state_id, next_local_id, sent_id, catches); + } else { + for s in fin { + current.push(s.clone()); + } } } } diff --git a/crates/perry-transform/src/lib.rs b/crates/perry-transform/src/lib.rs index 400b6892a..107ffb49f 100644 --- a/crates/perry-transform/src/lib.rs +++ b/crates/perry-transform/src/lib.rs @@ -6,12 +6,14 @@ //! - Optimization passes (function inlining) //! - i18n string localization +pub mod async_to_generator; pub mod closure; pub mod generator; pub mod i18n; pub mod inline; // Re-export main transformation functions +pub use async_to_generator::transform_async_to_generator; pub use closure::convert_closures; pub use generator::transform_generators; pub use i18n::{apply_i18n, I18nStringTable, I18nDiagnostic}; diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 53a49920b..6effac7df 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -11,7 +11,7 @@ use anyhow::{anyhow, Result}; use perry_hir::ModuleKind; -use perry_transform::{inline_functions, transform_generators}; +use perry_transform::{inline_functions, transform_async_to_generator, transform_generators}; use std::collections::HashSet; use std::fs; use std::path::PathBuf; @@ -177,6 +177,14 @@ pub(super) fn collect_modules( // Apply function inlining optimization inline_functions(&mut hir_module); + // Issue #256: rewrite plain async functions into generators with + // was_plain_async set, so the generator transform below produces + // a state machine wrapped in an async-step driver. Must run AFTER + // inline_functions (so inlined async bodies are also rewritten) + // and BEFORE transform_generators (which consumes the generator + // shape we produce). + transform_async_to_generator(&mut hir_module); + // Transform generator functions into state machines transform_generators(&mut hir_module); } diff --git a/test-files/test_issue_256_microtask_ordering.ts b/test-files/test_issue_256_microtask_ordering.ts new file mode 100644 index 000000000..96396298c --- /dev/null +++ b/test-files/test_issue_256_microtask_ordering.ts @@ -0,0 +1,19 @@ +// Issue #256: spec-compliant microtask ordering for async/await. +// Expected (Node): main-1 / inner-1 / top-1 / top-2 / inner-2 / main-2 +// Pre-fix Perry: main-1 / inner-1 / inner-2 / main-2 / top-1 / top-2 + +async function inner() { + console.log("inner-1"); + await Promise.resolve(); + console.log("inner-2"); +} + +async function main() { + console.log("main-1"); + await inner(); + console.log("main-2"); +} + +main(); +console.log("top-1"); +console.log("top-2");