Skip to content

[tabs] Fire onValueChange for automatic tab selection#3758

Open
michaldudak wants to merge 35 commits intomui:masterfrom
michaldudak:tabs-initial-onvaluechange
Open

[tabs] Fire onValueChange for automatic tab selection#3758
michaldudak wants to merge 35 commits intomui:masterfrom
michaldudak:tabs-initial-onvaluechange

Conversation

@michaldudak
Copy link
Copy Markdown
Member

@michaldudak michaldudak commented Jan 15, 2026

Fixes a bug where the onValueChange callback was not invoked when the selected tab changed automatically due to external factors.

Previously, onValueChange only fired for user-initiated tab changes (clicks, keyboard navigation). It did not fire when:

  • A selected tab became disabled
  • A selected tab was removed from the DOM
  • All tabs became disabled

Now, onValueChange fires in all these scenarios with appropriate reason values.

It also fires during initial render when no value or defaultValue is provided, and the component automatically selects the first enabled tab.


New reason values in onValueChange:

  • 'initial': First automatic selection on mount (no value/defaultValue provided)
  • 'disabled': Selected tab became disabled after initial render
  • 'missing': Selected tab was removed from the DOM

The existing 'none' reason is used for user-initiated changes.

--

New experiment: https://deploy-preview-3758--base-ui.netlify.app/experiments/tabs/tabs-onValueChange

Fixes #2097

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Jan 15, 2026

commit: 353a806

@mui-bot
Copy link
Copy Markdown

mui-bot commented Jan 15, 2026

Bundle size report

Bundle Parsed size Gzip size
@base-ui/react 🔺+1.49KB(+0.32%) 🔺+497B(+0.34%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@netlify
Copy link
Copy Markdown

netlify bot commented Jan 15, 2026

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 353a806
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/69bbb13b459fab00080627d7
😎 Deploy Preview https://deploy-preview-3758--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@michaldudak michaldudak added the component: tabs Changes related to the tabs component. label Jan 16, 2026
@michaldudak michaldudak marked this pull request as ready for review January 19, 2026 09:21
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Jan 19, 2026

Greptile Summary

This PR fixes issue #2097 by ensuring onValueChange fires for all automatic tab selections, not just user-initiated changes. The implementation adds three new reason values ('initial', 'disabled', 'missing') to distinguish different automatic selection scenarios.

Key changes:

  • onValueChange now fires on initial render when no explicit value/defaultValue is provided and the first enabled tab is automatically selected
  • onValueChange fires when the selected tab becomes disabled or is removed from the DOM
  • Respects explicit defaultValue pointing to a disabled tab if it was disabled from the start
  • Supports cancellation through eventDetails.cancel() for automatic selections
  • Does not fire in controlled mode for automatic fallbacks (parent controls the value)

Issue found:

  • The reason assignment logic uses 'initial' for any first-run automatic selection, even when an explicit defaultValue is provided but the tab doesn't exist. The PR description states 'initial' should only be used when "no value/defaultValue provided", suggesting this edge case may need refinement for semantic correctness.

The test coverage is thorough and validates the main scenarios. The implementation properly uses useIsoLayoutEffect and follows the repository's code guidelines.

Confidence Score: 4/5

  • This PR is safe to merge with minor considerations
  • The implementation correctly handles the main use cases for firing onValueChange during automatic tab selection. Comprehensive test coverage validates the behavior. However, there's a minor logic inconsistency where the 'initial' reason can be used even when an explicit defaultValue is provided (if that tab doesn't exist), which doesn't match the documented behavior. This edge case is unlikely to occur in practice but should be addressed for semantic correctness.
  • packages/react/src/tabs/root/TabsRoot.tsx - review the reason assignment logic for edge case handling

Important Files Changed

Filename Overview
packages/react/src/tabs/root/TabsRoot.tsx Added logic to fire onValueChange for automatic tab selections with new reason values (initial, disabled, missing). Implementation handles most scenarios correctly but has a potential logic issue with reason assignment when a non-existent tab is specified as defaultValue.
packages/react/src/tabs/root/TabsRoot.test.tsx Comprehensive test coverage added for all automatic selection scenarios including initial selection, disabled tabs, removed tabs, cancellation, and controlled mode behavior.
packages/react/src/utils/reason-parts.ts Added two new reason constants (missing and initial) to support the new automatic selection events in Tabs component.
docs/reference/generated/tabs-root.json Updated type signature for onValueChange callback to reflect simplified ChangeEventDetails type (generic rather than hardcoded to 'none' reason).

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +261 to +268
let reason: TabsRoot.ChangeEventReason;
if (isInitialRun) {
reason = REASONS.initial;
} else if (selectionIsDisabled) {
reason = REASONS.disabled;
} else {
reason = REASONS.missing;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: The reason assignment logic always uses 'initial' for the first run, even when an explicit defaultValue is provided but the corresponding tab doesn't exist. According to the PR description, 'initial' should only be used when "no value/defaultValue provided", but this code will fire with 'initial' reason even if defaultValue={1} is provided but tab 1 is missing.

Consider refining the logic:

Suggested change
let reason: TabsRoot.ChangeEventReason;
if (isInitialRun) {
reason = REASONS.initial;
} else if (selectionIsDisabled) {
reason = REASONS.disabled;
} else {
reason = REASONS.missing;
}
// Determine the appropriate reason based on context:
// - 'initial': First automatic selection (no value/defaultValue provided)
// - 'disabled': Tab became disabled after initial render
// - 'missing': Tab was removed from DOM or never existed
let reason: TabsRoot.ChangeEventReason;
if (isInitialRun && isAutomaticDefault) {
reason = REASONS.initial;
} else if (selectionIsDisabled) {
reason = REASONS.disabled;
} else {
reason = REASONS.missing;
}

This ensures 'initial' is only used when no explicit value was provided, making the behavior match the documentation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react/src/tabs/root/TabsRoot.tsx
Line: 261:268

Comment:
**logic:** The reason assignment logic always uses `'initial'` for the first run, even when an explicit `defaultValue` is provided but the corresponding tab doesn't exist. According to the PR description, `'initial'` should only be used when "no value/defaultValue provided", but this code will fire with `'initial'` reason even if `defaultValue={1}` is provided but tab 1 is missing.

Consider refining the logic:
```suggestion
    // Determine the appropriate reason based on context:
    // - 'initial': First automatic selection (no value/defaultValue provided)
    // - 'disabled': Tab became disabled after initial render
    // - 'missing': Tab was removed from DOM or never existed
    let reason: TabsRoot.ChangeEventReason;
    if (isInitialRun && isAutomaticDefault) {
      reason = REASONS.initial;
    } else if (selectionIsDisabled) {
      reason = REASONS.disabled;
    } else {
      reason = REASONS.missing;
    }
```

This ensures `'initial'` is only used when no explicit value was provided, making the behavior match the documentation.

How can I resolve this? If you propose a fix, please make it concise.


const selectionIsDisabled = selectedTabMetadata?.disabled;
const selectionIsMissing = selectedTabMetadata == null && value !== null;
const hasNoSelection = value == null; // Catches both null and undefined
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems uncontrolled defaultValue={null} can no longer mean "no active tab" as hasNoSelection treats null as a signal to auto-select, so the effect will always pick the first enabled tab (and fire onValueChange) even when null is explicitly provided

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@atomiks
Copy link
Copy Markdown
Contributor

atomiks commented Mar 10, 2026

Codex Review

Overview

This patch makes uncontrolled Tabs.Root report onValueChange when it changes selection automatically, adds explicit reason strings for those fallback cases, and updates the docs and experiments around that behavior. The fallback state machine itself looks much stronger now, but the new Tabs.Panel visibility guard regresses the server-rendered output.

Findings

1. 🔴 [Blocking] Selected panel is hidden until the client builds the tab map

Impact: Server-rendered tabs no longer include the active panel in the initial HTML, so a normal Tabs.Root starts with every tabpanel hidden until hydration runs. That is a user-visible regression for SSR consumers and it weakens the accessibility story because the selected panel content is missing from the prerendered markup.

Evidence: packages/react/src/tabs/panel/TabsPanel.tsx:59-60 now requires getTabIdByPanelValue(value) before a panel can be considered open. That tab id is only registered from packages/react/src/composite/list/useCompositeListItem.ts:73-86, which runs in useIsoLayoutEffect, so the map is empty during server rendering. I verified this by running renderToStaticMarkup on the PR head for a basic <Tabs.Root defaultValue={0}>…</Tabs.Root> example: both rendered tabpanel elements came out with hidden="", so no panel content was present in the server HTML.

Recommendation: Decouple panel openness from the runtime tab-id lookup. The selected panel should stay open whenever value === selectedValue, even if aria-labelledby is still unavailable until after mount. Then add an SSR-focused regression test that renders tabs to static markup and asserts the selected panel is visible in the initial HTML.

Confidence: 4/5

High confidence based on a full-pass review of the current master...head diff, targeted TabsRoot test runs in both JSDOM and Chromium, and a direct server-render repro of the panel visibility change.

Notes

  • pnpm test:jsdom TabsRoot --no-watch and pnpm test:chromium TabsRoot --no-watch both pass on this branch.
  • The latest commits appear to fix the earlier null-fallback regression from the previous review; the remaining issue is on the SSR/initial-markup path instead.

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 16, 2026
@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 17, 2026
@michaldudak
Copy link
Copy Markdown
Member Author

Codex Review

Overview

This patch makes uncontrolled Tabs.Root report onValueChange for automatic selection changes, adds explicit reason values for those fallback cases, and documents the updated callback contract. The latest revisions also tighten the uncontrolled fallback state machine around null and initially disabled defaults.

Findings (None)

No blocking issues found in this patch.

Confidence: 4/5

High confidence based on a full-pass review of the current master...head diff, the added edge-case coverage in packages/react/src/tabs/root/TabsRoot.test.tsx, and the green CI signal on the PR.

Notes

  • The latest changes address the earlier null fallback regression and add coverage for the disabled-default transition.

@atomiks
Copy link
Copy Markdown
Contributor

atomiks commented Mar 18, 2026

Codex Review

Overview

This patch makes uncontrolled Tabs.Root report onValueChange when it changes selection automatically, adds explicit reason strings for those fallback cases, and updates the docs and experiments around that behavior. The fallback state machine itself looks much stronger now, but the new Tabs.Panel visibility guard regresses the server-rendered output.

Findings

1. 🔴 [Blocking] Selected panel is hidden until the client builds the tab map

Impact: Server-rendered tabs no longer include the active panel in the initial HTML, so a normal Tabs.Root starts with every tabpanel hidden until hydration runs. That is a user-visible regression for SSR consumers and it weakens the accessibility story because the selected panel content is missing from the prerendered markup.

Evidence: packages/react/src/tabs/panel/TabsPanel.tsx:59-60 now requires getTabIdByPanelValue(value) before a panel can be considered open. That tab id is only registered from packages/react/src/composite/list/useCompositeListItem.ts:73-86, which runs in useIsoLayoutEffect, so the map is empty during server rendering. I verified this by running renderToStaticMarkup on the PR head for a basic <Tabs.Root defaultValue={0}>…</Tabs.Root> example: both rendered tabpanel elements came out with hidden="", so no panel content was present in the server HTML.

Recommendation: Decouple panel openness from the runtime tab-id lookup. The selected panel should stay open whenever value === selectedValue, even if aria-labelledby is still unavailable until after mount. Then add an SSR-focused regression test that renders tabs to static markup and asserts the selected panel is visible in the initial HTML.

Confidence: 4/5

High confidence based on a full-pass review of the current master...head diff, targeted TabsRoot test runs in both JSDOM and Chromium, and a direct server-render repro of the panel visibility change.

Notes

  • pnpm test:jsdom TabsRoot --no-watch and pnpm test:chromium TabsRoot --no-watch both pass on this branch.
  • The latest commits appear to fix the earlier null-fallback regression from the previous review; the remaining issue is on the SSR/initial-markup path instead.

@michaldudak michaldudak changed the title [tabs] Fire onValueChange for automatic tab selection [tabs] Fire onValueChange for automatic tab selection Mar 18, 2026
@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: tabs Changes related to the tabs component. PR: out-of-date The pull request has merge conflicts and can't be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[tabs] onValueChange does not fire when the initially selected tab is picked by the Tabs component

3 participants