Skip to content

perry/ui: TextField in non-initially-active NavStack route doesn't accept geisterhand /type after screen.set #640

Closed

Description

@proggeramlug

Summary

After a NavStack(state, routes[]) route swap on macOS, TextField widgets in routes that were not the initially active route refuse to accept programmatic text via geisterhand /type/:handle, and geisterhand /value/:handle returns {"value": null} for those handles. Worse: /value/:handle for textfields in the previously active route also returns null after the swap, even though they held a valid value at the moment they fired their click handler.

Functional impact: I can drive a multi-screen app's first screen (signin) end-to-end via geisterhand — /type updates the field, /value reads it back, the click handler reads the JS-side variable that onChange wrote — but as soon as screen.set(...) swaps to a different NavStack route, the textfields on that new route can't be exercised programmatically.

Reproducer

Two-route NavStack with a TextField on each route:

import { App, VStack, HStack, Text, Button, TextField, NavStack, state } from "perry/ui";

const route = state("a");
let aValue = "";
let bValue = "";

App({
  title: "TextField nav probe",
  width: 400, height: 300,
  body: NavStack(route, [
    {
      name: "a",
      body: VStack(8, [
        Text("route A"),
        TextField("type here on A", (s: string) => { aValue = s; console.log("A:", s); }),
        Button("go to B", () => route.set("b")),
        Button("log A value", () => console.log("aValue=" + aValue)),
      ]),
    },
    {
      name: "b",
      body: VStack(8, [
        Text("route B"),
        TextField("type here on B", (s: string) => { bValue = s; console.log("B:", s); }),
        Button("go to A", () => route.set("a")),
        Button("log B value", () => console.log("bValue=" + bValue)),
      ]),
    },
  ]),
});
$ perry compile probe.ts -o probe --enable-geisterhand && ./probe

Drive via geisterhand:

# Find the two TextField handles via /widgets — say A's TF is handle 5, B's TF is handle 9.
$ curl -X POST -H 'content-type: application/json' \
       -d '{"text":"hello A"}' http://127.0.0.1:7676/type/5
{"ok":true}
$ curl http://127.0.0.1:7676/value/5
{"handle":5,"value":"hello A"}                       # ✓ A is on the active route, works

# Click "log A value" — console prints `aValue=hello A`. onChange fired correctly.

# Now navigate to route B (click the "go to B" button on route A).
$ curl -X POST http://127.0.0.1:7676/click/<go-to-b-handle>

# Wait a moment for the route swap. Visually, route B is now showing.
# Try to type into route B's TextField (handle 9):
$ curl -X POST -H 'content-type: application/json' \
       -d '{"text":"hello B"}' http://127.0.0.1:7676/type/9
{"ok":true}
$ curl http://127.0.0.1:7676/value/9
{"handle":9,"value":null}                            # ✗ /type didn't store

# Click "log B value" — console prints `bValue=` (empty). onChange never fired.

# And meanwhile, route A's previously-set value is gone:
$ curl http://127.0.0.1:7676/value/5
{"handle":5,"value":null}                            # ✗ value cleared after swap

Discovery context

Hit this building an "Add shop" screen for our admin app. Sequence:

  1. App boots, signin route is active. TextFields for email/password (handles 9, 11) accept /type and onChange propagates to JS — verified end-to-end by a successful POST /v1/auth/signin reaching the server with the right body.
  2. After signin, screen.set("orders") fires. NavStack visibly swaps to the orders route.
  3. User clicks "Add shop" → screen.set("addShop").
  4. /type against any of the 4 addShop TextFields (handles 124, 126, 128, 130) succeeds-with-{"ok":true} but /value returns {"value": null} for each.
  5. Clicking the addShop "Connect shop" button fires doAddShop, which reads the JS-side variables (shopName, shopBaseUrl, ...) that onChange would have written. They're all empty strings, so the handler's required-fields guard fires and returns. No POST /v1/shops reaches the server.
  6. Probing /value/9 (the signin email field, which earlier round-tripped fine) also now returns {"value": null}.

So both directions break: the formerly-active route's textfields lose their value, and newly-revealed routes' textfields refuse /type.

Hypothesis

The macOS NavStack post-#535 wires js_register_widget_hidden_handler correctly for visibility swap, but the underlying TextField widget instances may be torn down (or their input pipelines unhooked) when their route is hidden. New widgets get created when the route becomes visible again, but the original widget handles point to a dead instance — and the new widgets aren't getting the same handle, so geisterhand still routes by the old handle.

If that's right, the fix is for NavStack to keep the original widget instances alive (and registered with the same handle) across visibility toggles — currently it apparently does for widgets with state-bound text content (#599 / #610 fixes work fine across routes), but not for the input-pipeline registration on TextField/etc.

Environment

Why we filed

The Add Shop flow is the next high-value step in our admin client UI. It requires typing into 4 TextFields on a route that's hidden at boot — exactly the case that breaks. Real users with keyboard input may not be affected (NSTextField receives keystrokes through AppKit's responder chain, which may not be the same pipeline geisterhand drives), but the geisterhand-driven smoke harness can't verify the flow. And there's a real possibility that the underlying widget tear-down causes other latent issues that will affect users.

Activity

  1. added a commit that references this issue on May 10, 2026
  2. proggeramlug commented on May 10, 2026

    @proggeramlug
    ContributorAuthor

    Partial fix in 5746eac (v0.5.765). The narrow reported symptom — value cleared after swap — didn't reproduce in a minimal NavStack repro: in a 2-route App with one TextField per route, typing into TF A, swapping to B via `state.set("b")`, then reading `/value/A` still returns the typed text correctly. The underlying NSTextField persists across `setHidden` (v0.5.752 made macOS NavStack a plain NSView; `stringValue` works regardless of visibility), so handles stay valid for both `/type` and `/value` after the swap.

    What did get fixed: the misleading `value: null` vs empty-string conflation that was masking the real triage signal. Pre-fix, `/value/:handle` returned `null` for both "widget not found / not readable" and "widget found with empty stringValue". Pump-side `ReadValue` now flags "not found" via a `\u0\u0NF` sentinel so the HTTP handler can return JSON `value: ""` for empty-but-found distinct from `value: null` for actually-not-found. `alloc_string_result` in `crates/perry-ui-macos/src/widgets/mod.rs` now allocates ≥ 1 byte so empty values pass through as a non-null pointer.

    Why this matters for your full app: with the new behavior, `/value/124` after `/type/124 ok:true` returns `""` if the textfield genuinely has no text vs `null` if the handle isn't a textfield (or the widget vanished). That distinction wasn't observable before. If you re-run the addShop flow, please share the actual `/value` responses — `null` everywhere → handles are dead (something we'd need to debug); `""` everywhere → `/type`'s SetText pump action isn't actually firing on those textfields (different bug, possibly in the queue ordering).

    Leaving open until we can confirm whether the reported symptom is reproducible after this change.

  3. added a commit that references this issue on May 10, 2026
  4. proggeramlug commented on May 10, 2026

    @proggeramlug
    ContributorAuthor

    Re-tested against perry 0.5.789 (geisterhand libs rebuilt from 73d8392 — v0.5.788's "ui-macos: geisterhand label decoding + SecureField /type onChange parity"). The label-decoding side is great, but the regular-TextField reproducer from the original filing still shows the same behavior:

    === signin /value BEFORE click ===
      /value/9:  {"handle":9,"value":"demo@skelpo.com"}     ← works
    === signin /value AFTER route swap to orders ===
      /value/9:  {"handle":9,"value":null}                  ← cleared
    === addShop /value (untouched, post-navigation) ===
      /value/124: {"handle":124,"value":null}               ← still null, not ""
      /value/126: {"handle":126,"value":null}
      /value/128: {"handle":128,"value":null}
      /value/130: {"handle":130,"value":null}
    === /type WITHOUT prior /value warm-up ===
      (4× POST /type/{124,126,128,130} returning ok:true)
      /value/124: {"handle":124,"value":null}               ← /type didn't store
      ...
    === /v1/shops POST + DB ===
    (no POST landed; no shops in DB)
    

    SecureField parity wasn't part of our reproducer — all 7 affected fields (3 signin + 4 addShop) are regular TextField. So while the SecureField path is now parity-correct, the regular-TextField behavior:

    • (a) clears /value to null on the previously-active route after state.set("orders") swaps NavStack;
    • (b) keeps /value as null (not "") on the newly-active addShop route after state.set("addShop");
    • (c) silently no-ops on /type for those addShop fields.

    …is unchanged from the pre-fix behavior I originally filed. The only window where this works is "type into the route that was active when App first rendered, then click without navigating away first" — which only covers initial-route flows like signin.

    The earlier comment hypothesis ("widget instances persist across setHidden; handles stay valid") doesn't seem to be holding for this multi-route + post-navigation case. Worth re-opening, or possibly a separate ticket scoped to "regular TextField on non-initially-active NavStack route".

    Happy to refile fresh as a focused issue if you'd prefer that over reopening — let me know which path you want.

  5. added 3 commits that reference this issue on May 14, 2026
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