ReviewOS

craft-native/craft

A Switch turned on can paint as a thumbless green capsule, its thumb stuck at the off position

#385
Closed glennmichael123 opened this 8 days ago ·
8 days ago

A <Switch> that has been turned on can end up painted as a solid green capsule with no thumb, with a crescent notch on the left where the thumb should have been in the off position.

Seen on a real iPhone, not the simulator — the capture has signal bars and a battery reading — on HQ.training's policy-consent screen (Agree.stx), in dark mode.

What it looks like

Two <Switch> rows, one above the other, identical markup apart from the bound signal:

  • on: a filled green capsule. No white thumb anywhere. A darker crescent bitten out of the left edge — the outline of a thumb still sitting at the off position, filled in the track's own colour.
  • off, directly below it: completely normal. White thumb, grey track.

So the track repainted to the on colour and the thumb never moved.

Neither switch passes any styling. The markup is bare on both:

<Switch :value="agreeHealth" :disabled="loading || !offer || !!busy"
        @change="agreeHealth.set(!!$event.value)" testID="agree-health" />

No trackColor, no thumbColor, no ios_backgroundColor — so the difference between the two is only on versus off.

Where it comes from

CraftNativeScreen.swift, the Switch reconcile:

case "Switch":
    let toggle = result as! UISwitch
    if let value = props["value"] as? Bool ?? props["checked"] as? Bool {
        // UIKit has already changed the value before emitting the
        // event. Echoing it through the script must not restart its
        // native animation.
        if toggle.isOn != value { toggle.isOn = value }
    }

isOn is assigned directly. setOn(_:animated:) does not appear anywhere in the file.

The intent reads as correct — an echo of a value UIKit already applied should not kick off a second animation. But assigning .isOn while UIKit's own tap-driven animation is still running cancels the thumb's layer animation in place rather than completing it. The track has already been repainted by UIKit; the thumb is left where it was and takes the track's fill. That is precisely the artifact above, including which side the crescent is on.

I have not instrumented this, so treat the mechanism as a strong reading of the evidence rather than a proven one. What would settle it: log toggle.isOn, toggle.layer.animationKeys() and the thumb layer's position at the moment of the echo.

Why it shows up here in particular

This screen is also where #380 bites: a Switch inside a ScrollView loses most of its taps, so a person has to tap repeatedly before it flips. Every one of those taps starts a UIKit animation that the next echo can interrupt, which is a good way to find this state and a poor experience either way. The two are separate causes — touch delivery versus state echo — but they compound, and they are the same control.

Suggested direction

Leave UIKit alone when the change came from the user's own tap, and reconcile only when the value genuinely diverges from what the control already shows — setOn(value, animated: false) after any in-flight animation has been allowed to finish, rather than a bare assignment mid-flight.

Worth a UI test that taps a Switch once and asserts the thumb's final position, not only isOn: the value is right in this state, and only the paint is wrong, so an isOn assertion passes while the control looks broken.

8 days ago

The controlled-switch reconciliation fix is already on main in 627fafaa, which replaces the mid-animation state write with a guarded setOn(_:animated: false) path and tracks the user-toggled value so the script echo does not interrupt UIKit.

The existing regression test is testControlledSwitchDoesNotInterruptTheNativeTapAnimation. I also verified the current source still contains that guard. A local simulator run reached the test target but was blocked by an unrelated existing Swift test compile error (Implicit use of self in closure); this remains open for confirmation on the affected real-device build.

8 days ago

Reopening: I closed this as completed, and that was premature. The fix is on main but has not been published, so no application can install it.

627fafaa was committed at 2026-10-20T15:41:24Z. The npm publish times for the iOS package:

0.0.249  2026-10-19T23:10:44Z
0.0.250  2026-10-20T05:26:00Z
0.0.251  2026-10-20T06:44:08Z   ← latest

0.0.251 went out nine hours before the fix landed, and there is nothing newer. I checked the published tarball rather than relying on the timestamps — npm pack @craft-native/ios@0.0.251, and templates/CraftNativeScreen.swift:7386 still reads:

if toggle.isOn != value { toggle.isOn = value }

No setOn( anywhere in it. So the bug is reachable on every installable version.

My own closing comment also said it remained unconfirmed on a real-device build, which is not a state this should have been closed in. The reported symptom — a switch painting as a thumbless green capsule with the thumb stuck at the off position — is still what someone on a current build will see.

Suggest this stays open until there is a release containing 627fafaa and the symptom is confirmed gone on a device.

Related: #380 is genuinely fixed and shipped in 0.0.250, so the two are no longer in the same state despite having been reported together.

8 days ago

Resolved on main in 627fafa. Controlled switch reconciliation now remembers the native tap value and uses setOn(_:animated: false) only for genuine programmatic divergence; the regression test covers the in-flight tap acknowledgement.

Sign in to comment on this issue.