Skip to content

perry/ui iOS: NavStack(state, routes) state-driven shape renders nothing #612

Closed

Description

@proggeramlug

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.png
import {
    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-driven NavStack(state, routes) is what's broken.
  • Imperative navstackPush / navstackPop API works on iOS per
    the existing perry-ui-ios/src/widgets/navstack.rs impl.

Activity

  1. proggeramlug commented on May 9, 2026

    @proggeramlug
    ContributorAuthor

    Update — narrowed the trigger:

    // 13 simple Text bodies in an array, integer-indexed loop, single-child VStack as App.body.
    // All it does is pick element [active] and put it in a VStack. Same shape as a 1-route case.
    // 1-route version (probe6.ts): renders fine ✓
    // 13-route version (probe7.ts): blank ✗

    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 screen
    • src/probe7.ts — 13-route version: blank screen

    I 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.

  2. proggeramlug commented on May 9, 2026

    @proggeramlug
    ContributorAuthor

    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 simple Text(...) widgets — renders fine ✓
    • Widget[] of 5 full WelcomeScreen()-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.

    function pickActiveScreen(name: string, nav: (target: string) => void): Widget {
        if (name === ROUTE_LOGIN) return LoginScreen(nav);
        if (name === ROUTE_HOME)  return HomeScreen(nav);
        // ... 13 cases
        return WelcomeScreen(nav);
    }
    
    App({ ..., body: pickActiveScreen(route.value, navigate) });

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions