Repository navigation
perry/ui: TextField in non-initially-active NavStack route doesn't accept geisterhand /type after screen.set #640
Description
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 && ./probeDrive 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 swapDiscovery context
Hit this building an "Add shop" screen for our admin app. Sequence:
- App boots, signin route is active. TextFields for email/password (handles 9, 11) accept
/typeand onChange propagates to JS — verified end-to-end by a successful POST/v1/auth/signinreaching the server with the right body. - After signin,
screen.set("orders")fires. NavStack visibly swaps to the orders route. - User clicks "Add shop" →
screen.set("addShop"). /typeagainst any of the 4 addShop TextFields (handles 124, 126, 128, 130) succeeds-with-{"ok":true}but/valuereturns{"value": null}for each.- 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/shopsreaches the server. - 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
perry 0.5.739(the version that closed post-#591 regression: exec(INSERT, [str, Buffer]) after SELECT segfaults the runtime #609 / perry/ui: ForEach(stateCount, render) doesn't expand on .set() — #599 follow-up #610)--target macos, arm64--enable-geisterhand
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
fix: closes #556 #640 #631 (part A) — refs #644 — 4 coordinated fixes…
proggeramlug commentedon May 10, 2026
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.
fix(stream): closes #631 — node:stream constructors return real calla…
proggeramlug commentedon May 10, 2026
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-TextFieldreproducer from the original filing still shows the same behavior:SecureFieldparity wasn't part of our reproducer — all 7 affected fields (3 signin + 4 addShop) are regularTextField. So while the SecureField path is now parity-correct, the regular-TextField behavior:/valuetonullon the previously-active route afterstate.set("orders")swaps NavStack;/valueasnull(not"") on the newly-active addShop route afterstate.set("addShop");/typefor 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.
fix(transform): #764 — module-init `State()` no longer breaks stateOn…
fix(transform): #764 — module-init `State()` no longer breaks stateOn…
fix(transform): #764 — module-init `State()` no longer breaks stateOn…