A pressable that carries a link target never runs its own press handler. The host opens the target and returns, so an authored onPress on the same node is dropped with no diagnostic.
Where
packages/ios/templates/CraftNativeScreen.swift:11406
@objc private func viewPressed(_ sender: UITapGestureRecognizer) {
guard let view = sender.view else { return }
let id = ObjectIdentifier(view)
if let target = linkTargets[id] {
hybridEvents?.nativeScreen(self, open: target, props: [:])
return
}
guard let handler = handlers[id] else { return }Android does the same thing, so this is not platform-specific:
setOnClickListener {
screen.linkTargets[it]?.let { target -> navigateOpen(target); return@setOnClickListener }
val current = screen.handlers[this] ?: return@setOnClickListener
send(screen, "EVENT", ...)
}packages/android/templates/MainActivityNative.kt.template:6104
A link target is registered for any Link, TouchableOpacity, TouchableHighlight or Pressable with a non-empty href or to, so the two are easy to author together and the handler simply stops existing.
Why this is wrong
On the web the same markup runs the handler first and lets it decide, which is what preventDefault is for. A link with a click handler is an ordinary pattern, not a mistake, and the handler usually exists precisely because plain navigation is not what the author wants. Dropping it silently means the authored behaviour is unreachable and nothing in the screen, the logs or the tests says so.
What it cost downstream
stacksjs/hq.training#392. The consent gate lists the terms of service, privacy policy and health and safety information as rows carrying both an href and an onPress. The handler was the only thing that opened those documents, in the in-app browser, so it never ran; the host resolved /terms through the routes and landed on the app's [username] profile screen instead. People were asked to agree to three documents the app would not show them, and it shipped that way.
The downstream fix was to stop authoring a link target on those rows, which works but is a workaround for this behaviour rather than a fix for it.
Reproducing
A pressable with both, on either platform:
<a href="/anything" @click="mine($event)">Tap</a>mine never runs; the host opens /anything.
What good would look like
An authored handler should not disappear. True preventDefault semantics are not available, since the handler runs over the bridge and the host cannot wait on it, so there is a real design choice here:
- When a node has both an authored press handler and a link target, dispatch the handler and let it own navigation. The author asked for a handler, and a handler that wants plain navigation can call the router itself. This is the behaviour the downstream workaround is emulating by hand.
- Dispatch the handler and navigate as well. Closest to the web's default, but it makes every such node do two things, which is worse than today for anything side-effecting.
- Keep today's behaviour and make it explicit: a diagnostic when a node carries both, so it fails at compile or mount rather than silently at runtime.
Option 1 looks right to me, with 3 as a cheap companion either way. Whichever it is, it is a behaviour change worth a release note, since screens may already rely on the link winning.
Related: the testing kit cannot currently observe any of this. press() dispatches the handler whatever link target the node carries, so a test over the broken screen above passes. Filed as stacksjs/stx#2147.
Found on Craft 0.0.420 (identical in 0.0.395), iOS 26.5 simulator, Xcode 26.6.