ReviewOS

craft-native/craft

fix(ios): a native <Switch> is inert — the thumb never moves and onValueChange never fires

#371
Closed glennmichael123 opened this 8 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
8 days ago

What happens

A native <Switch> renders as a real UISwitch but is completely inert. Tapping it does not move the thumb, and @change / onValueChange never reaches the script.

The thumb not moving is the part I'd look at first: a UISwitch toggles itself on touch regardless of what targets are attached, so this looks less like the event failing to cross the bridge and more like the switch never receiving the touch at all.

Repro

A native screen (renderer: 'native', the path in nativeScreens) with four controls side by side. Tap each and watch the counter:

<script client lang="ts">
const probe = state<number>(0)
function hit(n: number): void { probe.set(n) }
</script>

<template>
  <View class="flex-1 bg-[#f2f2f7]">
    <Text :text="'probe=' + probe()" />

    <Switch   id="p-switch"   @change="hit(1)" />
    <Checkbox id="p-checkbox" @change="hit(2)" label="checkbox" />
    <Slider   id="p-slider"   @change="hit(3)" :minimumValue="0" :maximumValue="5" />
    <TextInput id="p-input"   @input="hit(4)" placeholder="input" />
  </View>
</template>
controleventfires?
Switch@change → onValueChangeno
Checkbox@change → onValueChangeyes
Slider@change → onValueChangeyes
TextInput@input → onChangeTextyes
Pressable@press → onPressyes

Same screen, same build, same tap harness. So this is specific to Switch, not to onValueChange and not to event dispatch generally.

What I ruled out

  • Event naming. The compiled bundle emits type:"Switch" with events:{onValueChange:"hit(1)"} — exactly the key CraftNativeScreen.swift reads (updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: toggle)), and makeView does toggle.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged). Both sides look right.
  • Zero-size hit area. An explicit class="h-[31px] w-[51px]" (the intrinsic size) behaves identically to no sizing at all.
  • Controlled-prop snapback. I first suspected :value was being re-applied over the user's change. It is not the cause: a Switch with no :value and no handler-side state is equally inert, and the thumb does not move even momentarily.
  • The handler itself. Pointing @change at a function that demonstrably fires from a Pressable on the same screen still did nothing.

Why it matters

There is no way to draw a working toggle on a native screen. A UISwitch that animates nothing and changes nothing reads to a user as a control that silently forgets what they set — worse than one that is visibly disabled. The workaround is a Pressable row with a checkmark and accessibilityRole="switch", which works but is not the control the platform expects for a binary setting.

Environment

  • craft-native 0.0.187
  • stacks 0.75.187, @stacksjs/stx 0.2.176
  • iPhone 17 Pro simulator, iOS 26.5
  • Xcode 26.6 (17F113), built with CODE_SIGN_IDENTITY=-
8 days ago

A datapoint from another app on the same version, because I nearly filed this myself yesterday and then could not reproduce it by hand.

<Switch> works here, but only if the tap has a duration.

Wildloop, craft-native 0.0.237 / @stacksjs/stx 0.2.563, iOS 26.5 simulator, five <Switch> instances across four native screens, all written the same way:

<Switch :value="accessible" @change="accessible.set($event.value);load()" />
inputthumb moves?@change fires?
synthetic tap, no durationnono
synthetic tap, duration: 0.15yesyes
single-finger drag across the trackyesyes

Confirmed by a side effect rather than by watching the thumb: toggling "Accessible" on our trail filters re-ran the query and the list went from 21 trails to 1 — the only accessible trail in the dev database. So the event crossed the bridge and the handler ran.

I would guess UISwitch's own tap recogniser has a minimum touch duration that an instantaneous synthetic touch-down/touch-up pair falls below, while UIButton, UISlider and UITextField respond to touchDown / touchUpInside and do not care. That would explain why the other four controls in your probe pass with the same harness — the asymmetry is the part that makes this look like a Switch bug rather than a harness artifact, and it was what convinced me too until I tried a slower tap.

Worth re-running your probe with a non-instant tap before anyone digs into hit-testing. If it fires for you as well, this is a testing-tools issue rather than an iOS one — and arguably still worth fixing there, since every XCUITest-style harness will hit it.

(If your harness already presses with a duration, ignore all of this — then the asymmetry is real and I have nothing useful to add.)

8 days ago

The current main fixture covers a real XCTest XCUIElement.tap() against a native UISwitch, verifies the UIKit value changes, and verifies onValueChange reaches the screen (b83e451e). That matches the device datapoint that an instantaneous synthetic touch is the failing case, not the native control. The remaining gap is in the external tap harness: it should use a non-zero touch duration before classifying this as a Craft/iOS regression.

8 days ago

Resolved on main by 994c4d7, 5467d07, and b83e451. Native Switch state is preserved, interactive changes dispatch onValueChange, and iOS regression coverage is present. On main, unreleased.

Sign in to comment on this issue.