ReviewOS

craft-native/craft

A pressable with a link target never runs its own press handler, on both platforms

#442
Closed glennmichael123 opened this 6 hours ago ·
6 hours ago

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:

  1. 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.
  2. 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.
  3. 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.

2 hours ago

Fixed in 78eeae3, shipping in 0.0.426.

The host now takes option 1: an authored press handler is dispatched and owns navigation, and linkTargets is consulted only when there is no handler. Both platforms, and both iOS paths — viewPressed, which is the one stacksjs/hq.training#392 actually went through, as well as buttonPressed — plus dispatchNativePress on Android covering the click, touch-up and non-pressable branches that each pre-empted separately before. Tests on both sides and a note in docs/guides/native-stx.md.

Worth restating the behaviour change for anyone reading this later: a screen that authored both an href and an onPress and relied on the link winning will now run its handler instead. That is the correct default, but it is a change, not a no-op.

Two notes on the related items:

  • stacksjs/stx#2147 is closed, and the host moving this way is what makes the testing kit's press() correct rather than blind: dispatching the handler whatever link target the node carries is now exactly what the host does.
  • The downstream workaround in HQ — suppressing the link target on those rows so the handler survives — becomes redundant once HQ installs 0.0.426 and will be removed there. Nothing to do here for it.

Sign in to comment on this issue.