Skip to content

tabs: programmatic value change dispatches a non-cancelable synthetic click on anchor triggers (hard navigation) #3354

Description

@AlexRixten

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

  1. Render tabs whose triggers are anchors with href.
  2. Change the value programmatically (api.setValue(...) or update a controlled value prop from outside user interaction).
  3. 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

  1. 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"]);
      });
      // ...
    }
  2. navigateIfNeeded invokes the navigate prop, whose default is clickIfLink(details.node).

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions