Skip to content

state.rs format_value doesn't handle NaN-boxed strings on Windows / Android / GTK4 — string-typed states render as 'NaN' through stateBindTextNumeric / TEXT_BINDINGS #443

Closed

Description

@proggeramlug

Symptom

On Windows / Android / GTK4, binding a Text widget to a string-typed State via stateBindTextNumeric (or anything that flows through TEXT_BINDINGS / MULTI_TEXT_BINDINGS) renders the literal text "NaN" (or some bit-pattern double) rather than the actual string content. Apple platforms (macOS / iOS / tvOS / visionOS / watchOS) work correctly.

Found while investigating discussion #428 — would have been the recommended workaround for that user's reactive-Text question, but it silently breaks on Windows.

Repro

import { App, State, Text, TextField, VStack, HStack,
         stateBindTextfield, stateBindTextNumeric } from "perry/ui";

const text = State("");
const echo = Text("You typed: ");
const field = TextField("Placeholder...", (v: string) => text.set(v));

stateBindTextfield(text, field);                   // works on every platform (separate path)
stateBindTextNumeric(text, echo, "You typed: ", "");

App({ title: "demo", width: 400, height: 200,
      body: VStack(8, [HStack(8, [field, echo])]) });

Type "hello" into the field. On macOS/iOS/etc the echo shows "You typed: hello". On Windows / Android / GTK4 the echo shows "You typed: NaN".

Root cause

format_value(value: f64) in each backend's state.rs. Apple platforms have a NaN-boxed-string check; the others don't:

Apple (correct) — crates/perry-ui-{macos,ios,tvos,visionos,watchos}/src/state.rs:

fn format_value(value: f64) -> String {
    if is_nanboxed_string(value) {
        extract_nanboxed_string(value)
    } else if value.fract() == 0.0 && value.abs() < 1e15 {
        format!("{}", value as i64)
    } else {
        format!("{}", value)
    }
}

Windows (broken) — crates/perry-ui-windows/src/state.rs:97:

fn format_value(value: f64) -> String {
    if value.fract() == 0.0 && value.abs() < 1e15 {
        format!("{}", value as i64)
    } else {
        format!("{}", value)
    }
}

Same shape in crates/perry-ui-android/src/state.rs and crates/perry-ui-gtk4/src/state.rs.

A NaN-boxed string handle is an f64 whose top 16 bits are 0x7FFF (STRING_TAG, see crates/perry-runtime/src/value.rs). For such a value, value.fract() is NaN — neither branch matches cleanly — so format!("{}", value) runs and Rust formats it as "NaN".

Affected paths

In Windows state_set (and the equivalents in android / gtk4), formatted = format_value(value) flows into:

  • TEXT_BINDINGS (single-state Text — stateBindTextNumeric)
  • MULTI_TEXT_BINDINGS / MULTI_TEXT_INDEX (multi-state Text)
  • rebuild_multi_text recursive use of format_value for each StateRef

The TEXTFIELD_BINDINGS path on Windows happens to work because it uses js_get_string_pointer_unified(value) separately (crates/perry-ui-windows/src/state.rs:244) — so stateBindTextfield is fine for string states even on the broken platforms. That's why two-way input binding looks correct in screenshots while bound-Text binding silently produces "NaN".

Fix

Port the macOS is_nanboxed_string / extract_nanboxed_string helpers + the extra branch in format_value to:

  • crates/perry-ui-windows/src/state.rs
  • crates/perry-ui-android/src/state.rs
  • crates/perry-ui-gtk4/src/state.rs

The helpers themselves are tiny (~10 LOC each) and depend only on js_nanbox_get_pointer and the StringHeader layout — both already available on every platform.

A regression test that verifies a string-typed state bound via stateBindTextNumeric renders the actual string (not "NaN") would catch any future per-platform divergence.

Workaround for users

Drop State for the simple "echo what was typed" pattern and call textSetString(echoTextWidget, "..." + value) directly from the TextField onChange. textSetString's FFI takes the string pointer raw (no NaN-box round-trip), so it works identically on every backend.

Activity

  1. added a commit that references this issue on May 4, 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