Package: @zag-js/tabs 1.43.3 (also reproduced through @ark-ui/react and chakra-ui v3)
Description
When a tab trigger is an anchor (<a href="...">), every value change — including programmatic api.setValue() and controlled value prop sync — causes the machine to "click" the newly selected trigger via a synthetic MouseEvent. The event is dispatched without bubbles/cancelable, so framework-delegated listeners (e.g. React root delegation) never see it and preventDefault() is impossible: the browser performs a hard navigation to the trigger's href.
Reproduction
- Render tabs whose triggers are anchors with
href.
- Change the value programmatically (
api.setValue(...) or update a controlled value prop from outside user interaction).
- The browser navigates to the anchor's
href.
Downstream report with a runnable repro: chakra-ui/chakra-ui#11003.
Expected behavior
Only user-initiated activation (pointer/keyboard on the trigger) should follow the trigger link. Programmatic/controlled value sync should select the tab without navigation side effects. At minimum, the synthetic click should be cancelable and bubbling so applications can intercept it.
Root cause
-
The tabs machine watches context.value and runs navigateIfNeeded on every change — there is no distinction between user-initiated transitions and programmatic/controlled sync (tabs.machine.ts):
watch({ context, prop, track, action }) {
track([() => context.get("value")], () => {
action(["syncIndicatorAnimation", "syncIndicatorRect", "syncTabIndex", "navigateIfNeeded"]);
});
// ...
}
-
navigateIfNeeded invokes the navigate prop, whose default is clickIfLink(details.node).
-
clickIfLink (@zag-js/dom-query, navigate.ts) dispatches an uninitialized event:
el.dispatchEvent(new win.MouseEvent("click"));
No { bubbles: true, cancelable: true } → non-bubbling and non-cancelable (queued via queueMicrotask).
Possible directions
- Guard
navigateIfNeeded so it only runs for user-initiated changes (e.g. track the transition origin — pointer/keyboard events vs SET_VALUE/controlled sync), or
- make the synthetic click
{ bubbles: true, cancelable: true } so delegated handlers can preventDefault() it.
Happy to send a PR if there's a preferred direction.
Package: @zag-js/tabs 1.43.3 (also reproduced through @ark-ui/react and chakra-ui v3)
Description
When a tab trigger is an anchor (
<a href="...">), everyvaluechange — including programmaticapi.setValue()and controlledvalueprop sync — causes the machine to "click" the newly selected trigger via a syntheticMouseEvent. The event is dispatched withoutbubbles/cancelable, so framework-delegated listeners (e.g. React root delegation) never see it andpreventDefault()is impossible: the browser performs a hard navigation to the trigger'shref.Reproduction
href.api.setValue(...)or update a controlledvalueprop from outside user interaction).href.Downstream report with a runnable repro: chakra-ui/chakra-ui#11003.
Expected behavior
Only user-initiated activation (pointer/keyboard on the trigger) should follow the trigger link. Programmatic/controlled value sync should select the tab without navigation side effects. At minimum, the synthetic click should be cancelable and bubbling so applications can intercept it.
Root cause
The tabs machine watches
context.valueand runsnavigateIfNeededon every change — there is no distinction between user-initiated transitions and programmatic/controlled sync (tabs.machine.ts):navigateIfNeededinvokes thenavigateprop, whose default isclickIfLink(details.node).clickIfLink(@zag-js/dom-query,navigate.ts) dispatches an uninitialized event:No
{ bubbles: true, cancelable: true }→ non-bubbling and non-cancelable (queued viaqueueMicrotask).Possible directions
navigateIfNeededso it only runs for user-initiated changes (e.g. track the transition origin — pointer/keyboard events vsSET_VALUE/controlled sync), or{ bubbles: true, cancelable: true }so delegated handlers canpreventDefault()it.Happy to send a PR if there's a preferred direction.