Skip to content

fix(ios): clear a secure field with deletes before the caret tap - #4350

Merged
thymikee merged 10 commits into
callstack:mainfrom
TanmayOG:fix/ios-secure-field-clear-deletes-first
Nov 11, 2026
Merged

thymikee merged 10 commits into
callstack:mainfrom
TanmayOG:fix/ios-secure-field-clear-deletes-first

Conversation

@TanmayOG

@TanmayOG TanmayOG commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On iOS, fill always cleared a secure field, whose value is unreadable even when empty. The clear starts with a caret tap 4 pt inside the right edge, where apps often draw a show/hide button. In e2e-inc/e2e#1631 that tap pressed the button, the app swapped the input, and the fill failed with TEXT_INPUT_NOT_FOCUSED.

Now a masked field with unconfirmed focus gets the readiness retap at its center, and isn't cleared when it shows nothing or a placeholder that isn't a mask. A bullet placeholder isn't taken as empty: text of its length reads the same. Otherwise the clear sends deletes and reads back, and the edge tap runs only if masked text remains. The rule stays out of the shared textValueRead, where it would make formatted fills into bullet-placeholder fields mismatch.

9 files: runner, fixture, tests, docs, CLI help.

Validation

  • Base 093341a60, head 569032096.
  • Fixture tests (toggle over the right edge) fail without the fix, pass at head. 129 text-entry XCTests pass on iOS 27.2 and 26.5. macOS, tvOS and visionOS runners build.
  • Mutation check (test-experiments.md): 14 mutants, all killed.
  • RN 0.86.3 app, the issue's row, iOS 26.5: 21 steps pass at head, button never pressed (log). Base: 12 of 21 failed. Android 16: unaffected.
  • check:affected --run --head 569032096: 14 of 14 pass.
  • Independent review: findings fixed.
  • Not covered: masked text the deletes can't reach (documented).
  • CI: waiting for workflow approval.

A fill on an empty secure field still ran the clear, whose caret tap
lands at the field's right edge. A show/hide control drawn there took
the tap, the app swapped the input, and the fill failed with
TEXT_INPUT_NOT_FOCUSED (e2e-inc/e2e#1631).

A masked value that shows nothing, or a placeholder that is not a mask,
is no longer cleared. Any other masked value gets deletes first, after
the readiness repeat tap at the field's center when its focus is
unconfirmed, since deletes into a field without focus record an XCTest
failure. iOS clears a secure field on its first delete after it takes
focus, and deletes from a caret at the end reach the rest, so the caret
tap runs only when the read-back does not show the field empty.

A placeholder that is itself a mask reads the same as text of its
length, so it is not taken as empty: the deletes run and read back.

Fixture tests put a toggle over the field's right edge. They cover a
fill over prefilled text in the masked field and in a native secure
field, a secure field that refused the focus tap, a refill of a focused
field whose text reads like its bullet placeholder (no extra tap), and
the first fill of an empty secure field, both bare (no deletes sent)
and showing a bullet placeholder as its value, as React Native does.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 8 files

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread website/docs/docs/commands.md Resolved
With both the masked-value and secure-entry fixture flags, the masked
field wins, and the count label setup sent its setter to a field that
has none. The setup now uses the secure-entry field it built.
The masked-value clear applies to any input whose value is a mask, not
only secure fields. A masked input showing its placeholder is not
cleared, except a secure field whose placeholder is bullets.
A replacement skipped the clear whenever the value read as the
placeholder, so a plain masked field holding text as long as its
bullet placeholder kept the stale text and the fill appended to it.

On iOS a bullet-only value is no longer proof of an empty field, even
when it equals the placeholder: such a field goes to the masked clear,
which deletes and reads it back. Readability and verification reads are
unchanged.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread website/docs/docs/commands.md Resolved
An empty field with a bullet placeholder shows the same bullets as the
first fill's text, so waiting for them proved nothing. The masked
fixtures now show the field's text length, and the refill tests wait
for it before refilling.
After the deletes, the edge caret tap is also skipped when the field
reads back as a masked placeholder, not only when it reads empty.
@thymikee

Copy link
Copy Markdown
Member

Thanks for the careful fix. I found no code problems at fa2b299, but two things need settling before this is ready: the live evidence and one question on size. There are no conflicts. The one reported check does not run the runner XCUITests that cover this Swift change.

The React Native run (21 steps, iOS 27.2/26.5) is described in the PR body but has no log. Please attach the fill command, device and OS, head SHA, and the runner lines AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty|maskPlaceholder for a prefilled secure field and for the empty bullet-placeholder field, plus proof that the show/hide toggle stays unpressed.

Could the change be smaller? Of the 352 lines, about 99 are fixture ObjC and 162 are XCUITests, so the runner logic is roughly 85 lines. Would moving the mask-before-placeholder check into textValueRead work? That would delete textValueForClear and the TextTyping edit, and MaskedTextShown could drop to three cases. The one thing to check first is whether fill verification can read a mask placeholder as unreadable (unconfirmed rather than mismatch), since textValueRead is shared with verification.

These comments no longer apply and can be resolved: placeholder skipping the clear, nil secure field, docs wording, earlier fill wait, caret tap docs.

Not blocking:

  • The masked-deletes read-back treats .maskPlaceholder as cleared, so if the deletes removed nothing and the stale text has the same bullet count as the placeholder, the fill appends. Documenting that limit, or accepting it only when the pre-burst value was a different mask, would cover it.
  • A secure field that reports "" while holding text would now skip the clear, where it was always cleared before; I could not confirm every real secure input reports a non-empty value.
  • The #if os(iOS) guard at line 300 implies a platform scope, but the same bullet skip exists on macOS and tvOS readable fields.

The masked clear returned for an empty field before its focus check, so an
empty secure field whose focus tap was refused was typed into without focus,
which records an XCTest failure. The old edge tap had focused it.

From review: the clear's mask-placeholder rule now applies on every platform,
the read-back has three cases, an input that stops answering after the deletes
gets no caret tap (its one burst, as for callstack#3060), and a test covers the caret-tap
fallback for masked text the deletes can't reach.

The rule stays out of textValueRead. There it turns a formatted read-back in a
field that reports a bullet placeholder, such as a PIN field, from unconfirmed
into TEXT_ENTRY_MISMATCH.
Masked text the deletes cannot reach that shows as many bullets as the
placeholder reads as cleared, so fill types the new text next to it.
The masked clear logged its route only after the deletes, so a skipped
empty field left no line in runner.log.
… no input

939214c ended the clear there instead. No fixture stops answering in the
middle of a clear, so that change had no test; this restores the behavior at
fa2b299. Also drops a test comment that named the wrong limit.
@TanmayOG

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Pushed four commits; the head is 569032096.

Live run. iPhone 17 Pro simulator, iOS 26.5, agent-device 569032096, and a React Native 0.86.3 Release app with the issue's password row: a secure TextInput with a show/hide button at its right edge (hitSlop={8}), and the placeholder ••••••••, Password or none. The app prints its own status line: toggle presses, whether the input is still secure, and the length. Each step below shows the command, the CLI result, the runner lines it wrote (clear route, focus, and every tap with its point) and that status line.

  • Empty •••••••• field (S1): after the focus tap the runner reads it as showing nothing and skips the clear: route=masked-skip shown=empty, no deletes, and only the center tap at (183, 286). The edge tap would be at x 356. That skip didn't log anything before, so it now has its own line.
  • Prefilled secure field (S2, S3, S4, S5c in each row): route=masked-deletes shown=empty.
  • S1 to S5c in all three rows: toggle presses: 0 · secure: true, with the length of the text filled.
  • shown=maskPlaceholder comes up in R3: 8 characters under the 8-bullet placeholder, with the caret moved to the start. That's the documented limit. The deletes reach nothing and the text ends as qqabcdefgh, with the toggle still unpressed.
Steps and runner lines
date: 2026-11-10T21:10:49Z
agent-device head: 569032096af8317a7b4d5a0b7e339ca957f7d5e2
checkout: clean
device: iPhone 17 Pro simulator
runtime: com.apple.CoreSimulator.SimRuntime.iOS-26-5
app: dev.repro.securefill (Expo 57, React Native 0.86.3, Release build)

[password] S1 fill the empty field
  $ agent-device fill id=password-input hunter22
  exit=0
  cli: Filled 8 chars
  runner: Tap Application 'dev.repro.securefill'[0.00, 0.00] -> (183.0, 286.3)
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-skip shown=empty
  app: password: toggle presses: 0 · secure: true · length: 8 · text: "hunter22"

[password] S2 refill while it keeps focus
  $ agent-device fill id=password-input abc
  exit=0
  cli: Filled 3 chars
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=current-input-reports-keyboard-focus polls=0
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: password: toggle presses: 0 · secure: true · length: 3 · text: "abc"

[password] S3 refill after focus moved to the email field
  $ agent-device fill id=password-input xyz12
  exit=0
  cli: Filled 5 chars
  runner: Tap Application 'dev.repro.securefill'[0.00, 0.00] -> (183.0, 286.3)
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: password: toggle presses: 0 · secure: true · length: 5 · text: "xyz12"

[password] S4 fill an empty string
  $ agent-device fill id=password-input ''
  exit=0
  cli: Filled 0 chars
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=current-input-reports-keyboard-focus polls=0
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: password: toggle presses: 0 · secure: true · length: 0 · text: ""

[password] S5a fill 36 characters
  $ agent-device fill id=password-input abcdefghijklmnopqrstuvwxyz0123456789
  exit=0
  cli: Filled 36 chars
  runner: Tap Application 'dev.repro.securefill'[0.00, 0.00] -> (183.0, 286.3)
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=input-reports-keyboard-focus polls=1
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-skip shown=empty
  app: password: toggle presses: 0 · secure: true · length: 36 · text: "abcdefghijklmnopqrstuvwxyz0123456789"

[password] S5b press the field's centre
  $ agent-device press id=password-input
  exit=0
  cli: Tapped id=password-input (183, 286)
  app: password: toggle presses: 0 · secure: true · length: 36 · text: "abcdefghijklmnopqrstuvwxyz0123456789"

[password] S5c refill
  $ agent-device fill id=password-input qq
  exit=0
  cli: Filled 2 chars
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=current-input-reports-keyboard-focus polls=0
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: password: toggle presses: 0 · secure: true · length: 2 · text: "qq"

[worded] S1 fill the empty field
  $ agent-device fill id=worded-input hunter22
  exit=0
  cli: Filled 8 chars
  runner: Tap Application 'dev.repro.securefill'[0.00, 0.00] -> (183.0, 286.3)
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-skip shown=empty
  app: worded: toggle presses: 0 · secure: true · length: 8 · text: "hunter22"

[worded] S2 refill while it keeps focus
  $ agent-device fill id=worded-input abc
  exit=0
  cli: Filled 3 chars
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=current-input-reports-keyboard-focus polls=0
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: worded: toggle presses: 0 · secure: true · length: 3 · text: "abc"

[worded] S3 refill after focus moved to the email field
  $ agent-device fill id=worded-input xyz12
  exit=0
  cli: Filled 5 chars
  runner: Tap Application 'dev.repro.securefill'[0.00, 0.00] -> (183.0, 286.3)
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: worded: toggle presses: 0 · secure: true · length: 5 · text: "xyz12"

[worded] S4 fill an empty string
  $ agent-device fill id=worded-input ''
  exit=0
  cli: Filled 0 chars
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=current-input-reports-keyboard-focus polls=0
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: worded: toggle presses: 0 · secure: true · length: 0 · text: ""

[worded] S5a fill 36 characters
  $ agent-device fill id=worded-input abcdefghijklmnopqrstuvwxyz0123456789
  exit=0
  cli: Filled 36 chars
  runner: Tap Application 'dev.repro.securefill'[0.00, 0.00] -> (183.0, 286.3)
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=input-reports-keyboard-focus polls=1
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-skip shown=empty
  app: worded: toggle presses: 0 · secure: true · length: 36 · text: "abcdefghijklmnopqrstuvwxyz0123456789"

[worded] S5b press the field's centre
  $ agent-device press id=worded-input
  exit=0
  cli: Tapped id=worded-input (183, 286)
  app: worded: toggle presses: 0 · secure: true · length: 36 · text: "abcdefghijklmnopqrstuvwxyz0123456789"

[worded] S5c refill
  $ agent-device fill id=worded-input qq
  exit=0
  cli: Filled 2 chars
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=current-input-reports-keyboard-focus polls=0
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: worded: toggle presses: 0 · secure: true · length: 2 · text: "qq"

[bare] S1 fill the empty field
  $ agent-device fill id=bare-input hunter22
  exit=0
  cli: Filled 8 chars
  runner: Tap Application 'dev.repro.securefill'[0.00, 0.00] -> (183.0, 286.3)
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-skip shown=empty
  app: bare: toggle presses: 0 · secure: true · length: 8 · text: "hunter22"

[bare] S2 refill while it keeps focus
  $ agent-device fill id=bare-input abc
  exit=0
  cli: Filled 3 chars
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=current-input-reports-keyboard-focus polls=0
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: bare: toggle presses: 0 · secure: true · length: 3 · text: "abc"

[bare] S3 refill after focus moved to the email field
  $ agent-device fill id=bare-input xyz12
  exit=0
  cli: Filled 5 chars
  runner: Tap Application 'dev.repro.securefill'[0.00, 0.00] -> (183.0, 286.3)
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: bare: toggle presses: 0 · secure: true · length: 5 · text: "xyz12"

[bare] S4 fill an empty string
  $ agent-device fill id=bare-input ''
  exit=0
  cli: Filled 0 chars
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=current-input-reports-keyboard-focus polls=0
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: bare: toggle presses: 0 · secure: true · length: 0 · text: ""

[bare] S5a fill 36 characters
  $ agent-device fill id=bare-input abcdefghijklmnopqrstuvwxyz0123456789
  exit=0
  cli: Filled 36 chars
  runner: Tap Application 'dev.repro.securefill'[0.00, 0.00] -> (183.0, 286.3)
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=input-reports-keyboard-focus polls=1
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-skip shown=empty
  app: bare: toggle presses: 0 · secure: true · length: 36 · text: "abcdefghijklmnopqrstuvwxyz0123456789"

[bare] S5b press the field's centre
  $ agent-device press id=bare-input
  exit=0
  cli: Tapped id=bare-input (183, 286)
  app: bare: toggle presses: 0 · secure: true · length: 36 · text: "abcdefghijklmnopqrstuvwxyz0123456789"

[bare] S5c refill
  $ agent-device fill id=bare-input qq
  exit=0
  cli: Filled 2 chars
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=current-input-reports-keyboard-focus polls=0
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=empty
  app: bare: toggle presses: 0 · secure: true · length: 2 · text: "qq"

[password] R3 (limit) refill with the caret moved to the start
  (setup, not shown: fill id=password-input abcdefgh, then press 20 286 to put the caret at the start)
  $ agent-device fill id=password-input qq --json
  exit=0
  cli: "Filled 2 chars"
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=current-input-reports-keyboard-focus polls=0
  runner: AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR route=masked-deletes shown=maskPlaceholder
  app: password: toggle presses: 0 · secure: true · length: 10 · text: "qqabcdefgh"

Moving the check into textValueRead. I tried it. Fill verification does read a mask placeholder as unreadable: the refill test's first fill, 16 characters that show as the 8-bullet placeholder, goes from TEXT_ENTRY_MISMATCH to unverified.

But the shared read also covers readable fields that report a bullet placeholder as their value while empty, such as a code field with a •••••• placeholder. There the confirmation baseline becomes nil, and replacementTextEntryResult returns a mismatch at guard !request.isEmpty, let baseline (RunnerTests+TextEntryConfirmation.swift:109). I set up the digit-grouping fixture that way, an 8-bullet placeholder reported while empty. fill 000629177 then fails with TEXT_ENTRY_MISMATCH after the move, and at fa2b299a8 it's unconfirmed (before "", after "00 062 91 77").

So I kept the rule in textValueForClear, the clear's own read, and took the rest: MaskedTextShown is down to three cases, and the #if os(iOS) guard is gone. If you'd still rather have the shared read, it's one condition in textValueRead plus deleting textValueForClear.

Not blocking:

  • maskPlaceholder after the deletes: the limit is now documented in commands.md and the CLI help. The stricter rule would send a field that shows its bullet placeholder both before and after the deletes to the edge tap. The fixture's empty field does that, because it reports its placeholder as its value, and there it presses the toggle on the first fill; I checked that as a mutant. In my RN runs the read after the deletes came back empty, so for RN the rule would change R3 instead: the silent qqabcdefgh would become the edge tap. Happy to switch if you'd rather it fail loudly there.
  • Secure field that reads "" while holding text: the skip follows okwasniewski's review on #1631 ("treat an empty secure field (placeholder or nil value) as empty"), and main already skips a readable field that reads "". UIKit (fixture) and RN (repro) secure fields report one bullet per character when they hold text. I haven't checked WebKit or Flutter.
  • Platform scope: with the guard gone, macOS, visionOS and tvOS clear a mask-placeholder field instead of skipping it. They use each platform's usual clear: caret tap and bursts on macOS and visionOS, bursts on tvOS. An empty field still skips. All three runners build at 569032096, but the fixture tests are iOS-only, so this isn't run there.

Also fixed:

  • The masked clear returned for an empty field before its focus check. So an empty secure field whose focus tap was refused got its text typed without focus, an XCTest failure ("Neither element nor any descendant has keyboard focus"). It now gets the center retap first. testFirstFillIntoAnEmptySecureFieldThatRefusedTheFocusTap fails without the reorder.
  • testMaskedTextTheDeletesCannotReachFallsBackToTheCaretTap covers the fallback to the caret tap, which had no test.

Checks:

  • 129 text-entry XCUITests pass at 569032096 on iOS 26.5 and on iOS 27.2.
  • Mutation check at 939214c25, each mutant killed:
    • NA: the old order, the empty return before the focus check.
    • NF and NF2: the read-back return made unconditional, and removed.
    • NT: the mask-placeholder rule dropped from textValueForClear.
  • No test covers the branch where the read after the deletes finds no input. The iOS: tapping a Flutter password field records an XCTest failure and restarts the runner, although the tap lands #3060 fixture stops answering at the focus tap, so the fill ends before the clear, and no fixture stops answering mid-clear. That branch is as it was at fa2b299a8.
  • check:affected --run --head 569032096: 14 of 14 checks pass, exactCommit=true.
  • CI here is waiting for workflow approval (action_required).

The five threads you listed were already resolved.

@thymikee

Copy link
Copy Markdown
Member

I found no code problems at 5690320. The earlier evidence request is now satisfied, and the earlier review's questions are resolved.

The one CI check passes. It does not run the runner XCUITests that cover the changed Swift path, so the author's local XCUITest runs and the live React Native log cover that path. I did not run the runner XCUITests, the mutation set or check:affected. I confirmed by reading the code that the new tests would fail on the pre-change order at fa2b299.

A few gaps remain, and none of them needs a change here. Removing the iOS guard changes macOS, visionOS and tvOS: they now clear an empty mask-placeholder field instead of skipping it. No fixture or live run covers that, and I did not verify the build claim. No test covers the case where the read after the deletes finds no input (#3060, mid-clear). The author says that branch is unchanged since fa2b299, and the diff agrees. The live run covers React Native only, so WebKit and Flutter secure fields that read "" while holding text are unchecked and would skip the clear.

The review threads on the mask-placeholder read (#4350 (comment)), the counts label (#4350 (comment)), the commands.md wording (#4350 (comment)), the prefill wait (#4350 (comment)) and the documented read-back limit (#4350 (comment)) are all fixed at this commit. They no longer apply, so please resolve them.

I know of no conflicts. Before merge, a maintainer needs to approve the CI run for this commit, which is waiting on that approval.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Nov 10, 2026
@thymikee
thymikee merged commit 0abf88e into callstack:main Nov 11, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants