Repository navigation
perry/ui iOS: NavStack(state, routes) state-driven shape renders nothing #612
Description
Summary
NavStack(state, routes) — the state-driven shape documented in
perry/ui/index.d.ts and used as the navigation primitive for state-driven
multi-screen apps — renders nothing on iOS / iOS-simulator. The window
shows just the white background; no widgets from any route appear.
The same code on the macOS host build renders the active route correctly.
A custom router that pre-builds all bodies and flips
widgetSetHidden(body, hidden) on state change also fails on iOS sim
(VStack collapses the visible body to zero height when its siblings are
hidden; ZStack-with-hidden-children produces the same blank).
This blocks any non-trivial Perry iOS app — every multi-screen flow
needs some form of routing.
Repro
perry --version → perry 0.5.696
perry compile bug.ts --target ios-simulator -o probe
xcrun simctl install booted probe.app
xcrun simctl launch booted <bundle-id>
xcrun simctl io booted screenshot screen.pngimport {
App, NavStack, VStack, Text, Spacer, State,
setPadding, widgetSetBackgroundColor,
textSetFontSize, textSetColor, textSetFontWeight,
} from "perry/ui";
function screen(label: string, color: { r: number; g: number; b: number }): Widget {
const headline = Text(label);
textSetFontSize(headline, 36);
textSetFontWeight(headline, 700);
textSetColor(headline, color.r, color.g, color.b, 1.0);
const stack = VStack(16, [Spacer(), headline, Spacer()]);
setPadding(stack, 24, 24, 24, 24);
widgetSetBackgroundColor(stack, 1.0, 1.0, 1.0, 1.0);
return stack;
}
const route = State<string>("a");
App({
title: "NavStackProbe",
width: 390, height: 844,
body: NavStack(route, [
{ name: "a", body: screen("HELLO FROM ROUTE A", { r: 0.79, g: 0.07, b: 0.26 }) },
{ name: "b", body: screen("HELLO FROM ROUTE B", { r: 0.07, g: 0.40, b: 0.79 }) },
]),
});Output
(blank white iOS-sim screen — only the iOS status bar is visible)
Expected
The "HELLO FROM ROUTE A" headline rendered in the brand-red color, vertically
centered. Setting route.set("b") should swap to the blue B headline.
Confirmation that the screen widget itself renders
If we remove the NavStack wrapper and mount the screen directly as
App.body:
App({
title: "NoNavStack", width: 390, height: 844,
body: screen("HELLO FROM ROUTE A", { r: 0.79, g: 0.07, b: 0.26 }),
});… the headline renders correctly on iOS sim. So the issue is isolated to
the NavStack layer, not the underlying widget tree.
Workaround attempts that also failed on iOS sim
- Custom Router with VStack +
widgetSetHidden— flips visibility on
state change. Same blank result; the visible child collapses to zero
height. - Custom Router with ZStack (overlap) +
widgetSetHidden— same blank
result.
Where this bites
A Perry-native port of an Angular + Capacitor app (sharity-app, 17
screens, full bottom-nav + secondary routes). Every multi-screen flow
relies on a state-driven router primitive. Documented today in
sharity-perry-app/PLAN.md near the navigation section.
Related
- The
NavStack()no-arg legacy stub form works (just hosts a single
body). The state-drivenNavStack(state, routes)is what's broken. - Imperative
navstackPush/navstackPopAPI works on iOS per
the existingperry-ui-ios/src/widgets/navstack.rsimpl.
Activity
proggeramlug commentedon May 9, 2026
Update — narrowed the trigger:
So the corruption isn't NavStack-specific — passing an array of
{name: string, body: Widget}of size ≥ ~13 to a function and using one of the bodies as App.body breaks all rendering. Going to retest the threshold to find the cutoff.Probe builds:
src/probe6.ts— 1-route version: rendered the welcome screensrc/probe7.ts— 13-route version: blank screenI think this is a separate but related Perry codegen / lifetime bug — likely a widget handle is being GC'd or its retained-reference is dropping when the array goes out of scope at function-return.
Will follow up with bisection.
proggeramlug commentedon May 9, 2026
Update — found the deeper trigger and a complete workaround.
Trigger: Pre-building N widget trees (full screens with VStack + Text + Button + State bindings, etc.) and holding them in any
Widget[]array of size ≥ ~6 corrupts the entire render tree on iOS sim. Specifics:Widget[]of 13 simpleText(...)widgets — renders fine ✓Widget[]of 5 fullWelcomeScreen()-style screens — renders fine ✓Widget[]of 15 full screens — blank ✗So the threshold isn't pure array length; it's tied to the total widget-handle count or memory footprint of the constructed tree forest. Likely a Perry-runtime widget-handle pool overflow on iOS.
Workaround that works: build only the active screen at App-construction time. Branch on the route name in main, return the chosen
Screen()directly.This sidesteps the bug entirely — only one screen tree exists at any time. Trade-off: navigation between screens needs a different mechanism (App.body is fixed at construction time on iOS); for our app's first launch + login flow it's enough.
Full Wishare app now renders on iOS sim with this approach — Welcome screen with brand-red Anmelden button visible, German tagline, full visual fidelity.
Original NavStack blank-on-iOS bug is still real and tracked here. The pre-built array workaround attempt also fails for the same root cause (too many handles). The lazy-single-screen approach above is the only path that worked.