Skip to content

Note fields corrupt typed text and force the cursor to the end (TextFieldWithToolBarString) #1416

Description

@BirdBearBeast

Note fields corrupt typed text and force the cursor to the end (TextFieldWithToolBarString)

Describe the bug

Two problems in the free-text Note fields, both in TextFieldWithToolBarString.

1. Typed characters get duplicated or dropped. Typing at a normal pace into a meal Note produces wrong text. Typing sausage has produced sausagege. Characters vanish and come back, and the field feels like it is autocompleting, even though autocorrect is explicitly disabled on it. Intermittent, and only when the screen is busy. See To Reproduce, because it will not reproduce on an idle screen.

2. The cursor is forced to the end of the text. Tapping into the middle of an existing note puts the caret where you tapped for an instant, then moves it to the end. There is no way to position the caret anywhere but the end, so a typo in the middle of a note cannot be fixed without deleting everything after it.

These are related. See Technical Details: the mechanism behind problem 1 also moves the caret, so it is a second and independent cause of problem 2.

Both affect every field that uses TextFieldWithToolBarString, which is all four of these:

  • Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift:280 (meal Note)
  • Trio/Sources/Modules/AddCarbs/View/AddCarbsRootView.swift:60 (note)
  • Trio/Sources/Modules/History/View/CarbEntryEditorView.swift:172 (note, editing a past carb entry)
  • Trio/Sources/Modules/Treatments/View/MealPreset/AddMealPresetView.swift:34 (Name Of Dish, for a meal preset)

Attach a Log

Will attach if useful, though this is a pure UI issue and I would not expect the log to show anything.

To Reproduce

For the text corruption. The main thread has to be busy, or this will not reproduce. The write to the binding is deferred to the main queue, so on an idle screen it drains between keystrokes and nothing goes wrong. Typing sausage into a quiet meal screen ten times in a row will look perfectly fine.

  1. Go to the meal entry screen.
  2. Enter or change a carb value, which triggers an insulin recalculation and a forecast chart redraw.
  3. Immediately tap the Note field and type a word of seven or more characters at a normal pace, for example sausage.
  4. Characters briefly disappear and reappear. Retyping the missing ones while the restore is still pending produces duplicates, observed as sausagege.

Catching a loop cycle mid-typing works too and is probably the harsher test, but it is awkward to time deliberately. See #1114 for a report of the treatments window going laggy exactly when a loop initiates.

Reported with tapped keys, not swipe typing.

To be clear about what is observed and what is derived: the corruption itself is observed. The requirement for a busy main thread is inferred from the deferred write in the code, and I've has not tested loop timing against it. If it turns out to reproduce on a completely idle screen, that is worth knowing, and would suggest the window is wider than the code alone implies.

For the cursor:

  1. Tap the Note field and type two words, for example chicken burrito.
  2. Dismiss the keyboard.
  3. Tap the field again, aiming at the middle of the first word.
  4. The caret appears where you tapped, then jumps to the end of the text after first character is entered.

Expected behavior

Typed characters appear exactly once, in order. Tapping positions the caret where you tapped, as in every other text field on iOS.

Screenshots

Including screen recording to show cursor placement issue.

ScreenRecording_08-16-2026.10-52-29_1.MP4

Setup Information (please complete the following information):

Smartphone:

  • Hardware: iPhone 16 Pro
  • OS Version: iOS 27.0 beta

Pump:

  • Manufacturer: Insulet
  • Model: Omnipod 5

CGM:

  • Device: Dexcom G7
  • Manager app: Dexcom App

Trio Version:

  • Version Number: 0.8.4.54 (build 1)
  • Repo: nightscout/trio
  • Git Reference: dev 29a5417 (2026-08-09)

Technical Details

Problem 1: the binding is written asynchronously from a stale snapshot

Trio/Sources/Views/TextFieldWithToolBar.swift, in the UITextFieldDelegate extension:

let newText = currentText.replacingCharacters(in: range, with: string)

// Update the binding text state
DispatchQueue.main.async {
    self.parent.text = newText
}

return true

newText is computed from currentText, the field's value before this keystroke is applied, and the write to the binding is deferred. Meanwhile return true lets UIKit apply the keystroke immediately.

updateUIView at :381 then reconciles:

public func updateUIView(_ textField: UITextField, context: Context) {
    if textField.text != text {
        textField.text = text
    }

Typing sausage, with the last two keystrokes landing faster than the main queue drains:

Time Event textField.text parent.text
t0 types g, schedules A = sausag sausag sausa
t1 types e, schedules B = sausage sausage sausa
t2 A runs, updateUIView clobbers sausag sausag
t3 B runs, updateUIView restores sausage sausage

At t2 the character is visibly lost. At t3 the pending write restores the full string wholesale. The duplication is the user losing that race. Seeing the tail of the word vanish at t2, they retype it, and the t3 restore lands first, so the retyped characters append to an already-correct string. sausag, restored to sausage, plus a retyped ge, gives sausagege. The reporter described it as the field appearing to autocomplete while their own typing still went in, which is precisely this: the restore is the phantom autocomplete.

This only bites when the main queue is slow enough that a keystroke lands before the previous one's write has drained. updateUIView fires on every SwiftUI re-render of the parent, and the treatments view re-renders while recalculating insulin and redrawing the forecast chart, which is also when the main thread is most likely to be stalled. That combination is why this is intermittent rather than constant, and why it needs a busy screen to reproduce.

Scope: the numeric variant is not affected

Worth stating plainly, since #1114 mentions digits doubling in the bolus field and the two could easily be triaged together. They are not the same defect.

The only two DispatchQueue.main.async calls in TextFieldWithToolBar.swift are at :420 and :449, both inside TextFieldWithToolBarString. The numeric TextFieldWithToolBar at :4-312 binds a plain SwiftUI TextField to a @State string and updates through synchronous onChange handlers, with no deferred write anywhere in its typing path. It cannot exhibit the race described here.

The doubled digits in #1114 are more likely the input lag in that report causing a tap to register twice, which would be a symptom of the freeze rather than of this.

This matches use in practice. I've only ever seen the corruption in the Note field, never in carbs, fat, protein, or bolus, which is what the code above predicts.

The same mechanism moves the caret

Assigning .text on a UITextField resets selectedTextRange to the end of the document. So every stale rewrite at t2 also sends the caret to the end, mid-typing. This is independent of the editingDidBegin handler below.

Problem 2: the caret is also forced to the end on focus

Trio/Sources/Views/TextFieldWithToolBar.swift:342

textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)

Trio/Sources/Views/TextFieldWithToolBar.swift:419-421

@objc fileprivate func editingDidBegin(_ textField: UITextField) {
    DispatchQueue.main.async {
        textField.moveCursorToEnd()
    }
}

The DispatchQueue.main.async is why it is visible rather than silent. UIKit sets the caret from the tap during the touch, and the reset runs on the next runloop turn, so the caret is seen to land and then move. moveCursorToEnd() is a general UITextField extension at :313-319.

Why this looks inherited rather than intended

TextFieldWithToolBarString at :327 is the string counterpart to the numeric TextFieldWithToolBar at :4, which is pure SwiftUI and uses @FocusState, and has neither of these problems.

Forcing the caret to the end is reasonable for append-only numeric entry. But TextFieldWithToolBarString is only ever used for free text: all four call sites above are notes or names, and none is numeric, so no current caller benefits from it.

Suggested fix

For problem 1, update the binding synchronously rather than through DispatchQueue.main.async, or preserve and restore selectedTextRange around the assignment in updateUIView and guard against applying a stale value.

For problem 2, remove the editingDidBegin target from TextFieldWithToolBarString, or gate it behind an opt-in parameter defaulting to off. Since no current call site is numeric, removing it outright looks safe.

Additional context

Same struct, same defaults, all inherited from the numeric variant and arguably wrong for free text. Happy to split these into a separate issue, but they are one file and likely one fix:

Line Setting Effect on a note field
:333 autocorrectionType = .no no autocorrect, no spellcheck, and no predictive text bar
:332 autocapitalizationType = .none never capitalizes, including at the start of a sentence
:330 textAlignment = .right notes are right-aligned
:350 adjustsFontSizeToFitWidth = true text shrinks as it is typed rather than scrolling

Separately, all four call sites pass maxLength: 25. Typing simply stops at the limit with no indication why. Twenty five characters is tight for a meal note, and tighter still for Name Of Dish in AddMealPresetView.swift:34.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions