Skip to content

Tabs selects the last tab on initial mount instead of the first #819

Description

@Shinyaigeek

Summary

<Tabs> from @openuidev/react-ui selects the last tab on initial mount instead of the first, even for a fully static (non-streaming) response.

Reproduction

Standalone repro (published packages only, npm install && npm run dev):
https://github.com/Shinyaigeek/openui-tabs-repro

root = Tabs([t1, t2, t3])
t1 = TabItem("a", "Apple", [c1])
t2 = TabItem("b", "Banana", [c2])
t3 = TabItem("c", "Cherry", [c3])
c1 = TextContent("Apple tab content")
c2 = TextContent("Banana tab content")
c3 = TextContent("Cherry tab content")
  • Expected: the first tab (Apple) is active on mount.
  • Actual: the last tab (Cherry) is active on mount.

Cherry tab selected on mount

Versions: @openuidev/react-ui@0.13.0, @openuidev/react-lang@0.2.8, @openuidev/react-headless@0.9.2, React 19.

Root cause

In packages/react-ui/src/genui-lib/Tabs/index.tsx, two effects race on mount:

  1. An effect that defaults activeTab to the first tab.
  2. A dependency-less effect that auto-advances to any tab whose serialized content grew since the last render (streaming follow-along):
for (const item of items) {
  const size = JSON.stringify(item.props.content).length;
  const prevSize = prevContentSizes.current[item.props.value] ?? 0; // {} on first run
  nextSizes[item.props.value] = size;
  if (size > prevSize) {
    candidate = item.props.value; // every tab "grew" -> candidate ends as the LAST tab
  }
}

On the very first pass prevContentSizes.current is empty, so every tab satisfies size > prevSize, and candidate ends the loop pointing at the last item. Both effects run in the same commit; the auto-advance effect runs after the default-to-first effect, so the last tab wins.

(SectionBlock guards its equivalent auto-advance with an isStreaming check; Tabs has no such guard.)

Suggested fix

Treat the first pass as a baseline-recording pass only: record nextSizes but skip candidate selection when prevContentSizes.current is empty. Streaming follow-along still works — subsequent growth (or a newly appearing tab) still advances the active tab.

I have a PR ready for this.

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