#406

@native-sdk/core/text fails the subset checker's integer proofs once TextEditState is a Model field

Openstewartjarodopened 2mo ago

Summary

applyTextInputEvent is the documented way to drive a markup text control from a TypeScript core: hold a TextEditState in the Model, feed it TextInputEvents, store the result. Doing exactly that fails native build with 19 integer-proof errors across 10 lines, all inside sdk/text.ts itself. The app's own code is never named.

native check passes. Only native build fails.

Reproduced against native 0.9.3 (b25cefe), macOS 26.5.2, Zig 0.16.0, from a clean native init.

Repro

// src/core.ts
import { Cmd, asciiBytes } from "@native-sdk/core";
import { type TextEditState, type TextInputEvent, applyTextInputEvent } from "@native-sdk/core/text";

export interface Model {
  readonly name: TextEditState;   // storing it in the Model is what triggers this
}

export type Msg =
  | { readonly kind: "name_edit"; readonly edit: TextInputEvent }
  | { readonly kind: "noop" };

export function initialModel(): Model {
  return { name: { text: asciiBytes(""), selection: { anchor: 0, focus: 0 }, composition: null } };
}

export function nameText(model: Model): Uint8Array {
  return model.name.text;
}

export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
  switch (msg.kind) {
    case "name_edit": {
      const next = applyTextInputEvent(model.name, msg.edit, 120);
      if (!next) return model;
      return { ...model, name: next };
    }
    case "noop":
      return model;
  }
}
<!-- src/app.native -->
<column gap="12" padding="16">
  <text-field text="{nameText}" placeholder="Type here" on-input="name_edit" />
</column>

Actual

sdk/text.ts:79  SC4023  'TextRange.start' / 'TextRange.end' - range failed:
                        proven range [-Infinity, 9007199254740991]
sdk/text.ts:243,244,325,355,375,394,414,439,459
                SC4022  'TextRange.start' / 'TextRange.end' - wholeness failed:
                        the value may be NaN

Line 79 is rangeNormalized, where Math.min is float-classed:

const start = Math.min(r.start, textLen);
const end = Math.min(r.end, textLen);

The SC4022 sites are other TextRange constructions - snapTextOffset(...), previousTextCaretOffset(...), { start: result.insertedStart, end: result.insertedEnd }.

Scope

Two things I checked before filing:

  • It does not reproduce unless TextEditState is a Model field. Calling applyTextInputEvent and storing only next.text builds fine - the integer slots are only instantiated once the record becomes model state.
  • Fixing rangeNormalized alone is not sufficient. Replacing that Math.min pair with | 0-pinned ternaries clears all four SC4023 errors at line 79, but the nine SC4022 sites remain. This looks systemic across TextRange construction rather than a single bad line.

Workaround

| 0 restores integer classification (the remedy in #251), applied both where a derived index is produced and at the slot it lands in. I hand-rolled an equivalent caret/selection editor that compiles under those rules, so the constraint is satisfiable in app code - it just isn't satisfied in the shipped module.

Impact

Without this, a TypeScript core cannot use the SDK's own text engine, so text fields are limited to whatever the app hand-rolls. In my case that meant append/backspace-only editing until I worked out the | 0 pattern - a user could not delete a word from the start of a field.

Activity

2
stewartjarod2mo ago

Update: also reproduces on 0.10.1 (064ca98, Node 24.19.0) — same 19 errors, same lines. Not fixed since 0.9.3, so the version in the original report is stale rather than the bug being.

Some findings from attempting a fix, in case they narrow it down:

  • Math.min is not the cause. Replacing the rangeNormalized pair with plain ternaries changes nothing; the inputs are already float-classed by the time they get there.
  • Pinning the byte-offset producers' returns with | 0 — snapTextOffset, previousTextOffset, nextTextOffset, snapTextCaretOffset, previousTextCaretOffset, nextTextCaretOffset, previousTextWordOffset, nextTextWordOffset, textLineStartOffset, rangeByteLen — clears all four SC4023 range errors at line 79.
  • The SC4022 wholeness errors need pins at the TextRange construction sites as well. The proof appears to be required where the value lands, not only where it is produced.
  • Together those two converge (10 failing lines down to 6, and further with more sites), but a complete patch takes roughly 26 | 0 sites across the module.

That last point is why I have not opened a PR. Twenty-six coercions in a shipped module reads like a workaround rather than a fix, and it suggests the gap is in how the checker infers integrality for values derived from text.length and byte offsets rather than anything specific to text.ts. Happy to send a patch in that shape if it is useful to you, but it seemed more likely you would want to address it a layer down.

For reference, the same | 0 discipline — applied both at the producer and at the slot — is what let me hand-roll an equivalent caret/selection editor in app code that compiles cleanly, so the constraint is satisfiable; it just is not satisfied in the shipped module.

groundfast1mo ago

One more data point, reproduced on 0.10.1 (064ca98, macOS 27.0, arm64):

The route of "carry the vocabulary by copying the declarations into the core" is closed too, which pins the workaround to a type-only import:

  • Copying TextRange / TextSelection / TextEditState verbatim into src/core.ts while still importing applyTextInputEvent from @native-sdk/core/text fails with 3× NS1038 — "module-scope names are unique across a core's files" — reported at sdk/text.ts:26, :33 and :70, each naming src/core.ts as the other declaration site.
  • The compiler's sidecar layer documents the same shape as SC4010 ("a tabled type's members gather from more than one declaration site"), so a renamed copy would hit the merged-declaration refusal instead.

Same 19 errors as the original report otherwise; unchanged between 0.9.3 and 0.10.1. The only shape that builds is import type for the state/event types plus an app-owned reducer.