Skip to content

fix(Tabs): preserve state when items change#6374

Closed
onmax wants to merge 2 commits intonuxt:v4from
onmax:fix/tabs-stable-identity-5841
Closed

fix(Tabs): preserve state when items change#6374
onmax wants to merge 2 commits intonuxt:v4from
onmax:fix/tabs-stable-identity-5841

Conversation

@onmax
Copy link
Copy Markdown
Contributor

@onmax onmax commented Apr 20, 2026

🔗 Linked issue

Resolves #5841

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

This PR keeps the UTabs fix focused on the reported issue and the hydration mismatch reproduced in the linked portal case.

It fixes the state-loss problem when tab items with stable values are inserted or removed before the active tab by switching trigger/content identity away from render indexes.

It also keeps the hydration-specific repair for the direct-load case where the server renders only Third, the client hydrates with First, Second, Third, and v-model stays third. In that situation, stable keys alone are not enough, so the component performs a one-time post-mount refresh only when the hydrated active tab DOM does not match modelValue.

Intentionally out of scope for this PR:

  • generalized duplicate-label / no-value identity guarantees
  • Accordion or any other component

The reduced test coverage now focuses on:

  • preserving mounted tab content when a leading tab is removed with unmountOnHide=false
  • keeping a single active tab in the SSR/client hydration mismatch case above

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

@onmax onmax requested a review from benjamincanac as a code owner April 20, 2026 12:42
@github-actions github-actions Bot added the v4 #4488 label Apr 20, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Introduces hydration-aware stable keys for Tabs by adding hydrationKeyPrefix and a resolvedItems computed that maps incoming props.items to objects with a stable key, item, index, and resolved value. Adds rootRef, synchronizes triggersRef length to resolvedItems, and on mount performs a hydration mismatch check that can flip hydrationKeyPrefix to force a rekey. TabsTrigger and TabsContent render from resolvedItems with stable :key and :value. Tests were added for instance preservation when items change and for SSR/hydration behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(Tabs): preserve state when items change' clearly summarizes the main change: fixing state preservation in the Tabs component when items list changes.
Description check ✅ Passed The description is related to the changeset, explaining the state-loss problem fix and hydration mismatch handling with clear scope boundaries.
Linked Issues check ✅ Passed The PR directly addresses #5841 by switching from index-based keys to stable per-item keys and includes hydration mismatch detection, meeting all linked issue requirements.
Out of Scope Changes check ✅ Passed All changes are in-scope: Tabs.vue improvements and Tabs.spec.ts tests directly address the linked issue's core problem and hydration handling.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
test/components/Tabs.spec.ts (1)

165-196: Add a true hydration regression for the new refresh path.

This test verifies a post-mount item update, but with plain mount() the hydration branch in Tabs.vue does not run. Please add a case that hydrates mismatched SSR/client items, or otherwise exercises hydrationKeyPrefix = 0 and shouldRefreshHydratedItems().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/components/Tabs.spec.ts` around lines 165 - 196, Add a new test in
Tabs.spec.ts that exercises the SSR/hydration branch: simulate a pre-rendered
DOM with a different items list (e.g., render markup or mount with hydrate:
true) so the Tabs component enters the hydration path (hydrationKeyPrefix = 0)
and then update props to trigger shouldRefreshHydratedItems(); specifically
target Tabs.vue's hydrationKeyPrefix and shouldRefreshHydratedItems logic so the
test hydrates mismatched SSR/client items and asserts a single active tab after
refresh.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/runtime/components/Tabs.vue`:
- Around line 137-142: The current mapper uses the render index in the Vue key
which causes remounts when items shift; change the key generation inside
props.items.map to use a stable identifier derived from the item's explicit
value or label (e.g., get(item, props.valueKey) ?? get(item, props.labelKey))
and only fall back to String(index) for the public tab value, but not for the
Vue key; keep the returned value field semantics (value: get(item,
props.valueKey) ?? String(index)) but set key to
`${hydrationKeyPrefix.value}-${stableId}` where stableId is the non-index
identifier (value or label).
- Around line 183-187: The hydration check is collecting every descendant
[role="tab"] (rootEl.querySelectorAll) so nested Tabs' tabs leak into
tabs/activeIndexes; limit the selector to only the tab elements that belong to
this Tabs instance (e.g. use the tab-list container or a scoped selector like
':scope > [role="tab"]' or select within the specific tabs-list element) so the
tabs variable and activeIndexes only reflect this component's tabs when
comparing against resolvedItems.value and props.modelValue.

---

Nitpick comments:
In `@test/components/Tabs.spec.ts`:
- Around line 165-196: Add a new test in Tabs.spec.ts that exercises the
SSR/hydration branch: simulate a pre-rendered DOM with a different items list
(e.g., render markup or mount with hydrate: true) so the Tabs component enters
the hydration path (hydrationKeyPrefix = 0) and then update props to trigger
shouldRefreshHydratedItems(); specifically target Tabs.vue's hydrationKeyPrefix
and shouldRefreshHydratedItems logic so the test hydrates mismatched SSR/client
items and asserts a single active tab after refresh.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 31f0e06e-0571-46f9-8b5b-21b6690d517c

📥 Commits

Reviewing files that changed from the base of the PR and between 7f5eb52 and 02af458.

📒 Files selected for processing (2)
  • src/runtime/components/Tabs.vue
  • test/components/Tabs.spec.ts

Comment thread src/runtime/components/Tabs.vue Outdated
Comment thread src/runtime/components/Tabs.vue Outdated
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 20, 2026

npm i https://pkg.pr.new/@nuxt/ui@6374

commit: 8b668a1

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/runtime/components/Tabs.vue (1)

183-187: ⚠️ Potential issue | 🟡 Minor

Limit the hydration check to this component’s trigger elements.

The selector is scoped to the list, but it still includes any descendant role="tab" rendered by list-leading, list-trailing, or nested markup. That can make activeIndexes.length !== 1 and force an unnecessary post-hydration remount even when this Tabs instance is already correct.

Suggested refinement
-  const tabs = Array.from(rootEl.querySelectorAll<HTMLElement>(':scope > [data-slot="list"] [role="tab"]'))
+  const tabs = Array.from(rootEl.querySelectorAll<HTMLElement>(':scope > [data-slot="list"] > [data-slot="trigger"][role="tab"]'))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Tabs.vue` around lines 183 - 187, The hydration check
currently queries all descendant role="tab" nodes under the list slot which can
capture tabs rendered by list-leading/list-trailing or nested markup; change the
selector used in tabs (rootEl.querySelectorAll) to only capture the list slot's
immediate tab triggers (e.g. ':scope > [data-slot="list"] > [role="tab"]') so
activeIndexes, expectedIndex (resolvedItems, props.modelValue) compare only this
component’s actual trigger elements and avoid spurious remounts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/runtime/components/Tabs.vue`:
- Around line 137-145: The current mapping in the render (props.items.map)
builds stableId using get(item, props.valueKey) ?? get(item, props.labelKey)
which can produce duplicate keys when labels repeat; change stableId generation
to prefer props.valueKey, then props.labelKey only if proven unique, and
otherwise assign a per-item stable identifier using a WeakMap (e.g., a
module-scoped WeakMap keyed by the item objects) to generate and persist a
unique id per item; update key construction (using hydrationKeyPrefix.value +
'-' + stableId) so Vue keys are always stable and unique even when labels
duplicate, referencing the existing variables stableId, value,
hydrationKeyPrefix, and props.items to locate where to implement the WeakMap
fallback.

---

Duplicate comments:
In `@src/runtime/components/Tabs.vue`:
- Around line 183-187: The hydration check currently queries all descendant
role="tab" nodes under the list slot which can capture tabs rendered by
list-leading/list-trailing or nested markup; change the selector used in tabs
(rootEl.querySelectorAll) to only capture the list slot's immediate tab triggers
(e.g. ':scope > [data-slot="list"] > [role="tab"]') so activeIndexes,
expectedIndex (resolvedItems, props.modelValue) compare only this component’s
actual trigger elements and avoid spurious remounts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bffcae21-46d6-462b-ba56-2348c7af337b

📥 Commits

Reviewing files that changed from the base of the PR and between 02af458 and e8d7b78.

📒 Files selected for processing (1)
  • src/runtime/components/Tabs.vue

Comment thread src/runtime/components/Tabs.vue
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/runtime/components/Tabs.vue`:
- Around line 162-171: The current stableId can collide across sources
(explicitValue, label-derived, generated getTabsItemId/String(index)); change
the key generation in the props.items.map block so the returned key namespaces
the id source as well as the value—e.g., prefix explicit values with an
"explicit:" tag, label-derived ids with "label:", and generated ids with "gen:"
(apply this to the stableId used with hydrationKeyPrefix.value when building
key), referencing the existing symbols props.items.map, valueKey, labelKey,
getTabsItemId, stableId and hydrationKeyPrefix to locate and update the logic.

In `@test/components/Tabs.spec.ts`:
- Around line 261-294: The console.warn spy (created as warn = vi.spyOn(console,
'warn').mockImplementation(() => {})) must be restored in a finally block so it
always runs even if wrapper.setProps or the assertion throws; wrap the await
wrapper.setProps(...) and the expectations in try { ... } finally {
warn.mockRestore() } (keeping creation of the spy before and restoration in
finally) to guarantee cleanup for subsequent tests and reference the existing
warn, TestTabs, and wrapper.setProps symbols when making the change.
- Around line 216-247: Wrap the mounting, tick waits and assertions in a
try/finally so cleanup always runs: after creating and app.mount(container) (the
createSSRApp instance referenced as app) and awaiting nextTick(), perform the
DOM queries and expect checks inside the try block, and call app.unmount() and
container.remove() in the finally block to ensure the mounted app and container
are removed even if an assertion throws; ensure you still append the container
to document.body before mounting and preserve the existing nextTick() waits
before querying.
- Line 11: The hydration test gate is inverted: the const hydrationIt currently
sets hydrationIt to it.skip when __NUXT_VITEST_ENVIRONMENT__ is truthy but it
should run in that environment; update the ternary for the declaration of
hydrationIt so that when (globalThis as { __NUXT_VITEST_ENVIRONMENT__?: boolean
}).__NUXT_VITEST_ENVIRONMENT__ is true you assign it and otherwise assign
it.skip (i.e., swap the two branches for the hydrationIt constant).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ab468be0-2044-4b78-a7b8-a009d2caae65

📥 Commits

Reviewing files that changed from the base of the PR and between e8d7b78 and 6dcc151.

📒 Files selected for processing (2)
  • src/runtime/components/Tabs.vue
  • test/components/Tabs.spec.ts

Comment thread src/runtime/components/Tabs.vue
Comment thread test/components/Tabs.spec.ts
Comment thread test/components/Tabs.spec.ts Outdated
Comment thread test/components/Tabs.spec.ts Outdated
@onmax onmax force-pushed the fix/tabs-stable-identity-5841 branch from 6dcc151 to 6714654 Compare April 21, 2026 07:42
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
test/components/Tabs.spec.ts (1)

138-166: Consider unmounting the wrapper at end of test.

wrapper is never explicitly unmounted, so the onUnmounted recording in StatefulContent only captures removals caused by the setProps reconciliation (which is what this test asserts — good). However, leaving the wrapper mounted can leak reactive effects across tests. Adding wrapper.unmount() after the assertions is a cheap safeguard.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/components/Tabs.spec.ts` around lines 138 - 166, Test leaves the Vue
test wrapper mounted which can leak reactive effects; unmount the wrapper at the
end of the test. After the existing assertions that check getInstances(),
unmounted, and activeTabs, call wrapper.unmount() to teardown the mounted
TestTabs instance (the component using StatefulContent that populates the
unmounted array) so reactive side effects are cleaned up between tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/components/Tabs.spec.ts`:
- Around line 138-166: Test leaves the Vue test wrapper mounted which can leak
reactive effects; unmount the wrapper at the end of the test. After the existing
assertions that check getInstances(), unmounted, and activeTabs, call
wrapper.unmount() to teardown the mounted TestTabs instance (the component using
StatefulContent that populates the unmounted array) so reactive side effects are
cleaned up between tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c66c281f-820f-4f7a-bd19-3107319f3019

📥 Commits

Reviewing files that changed from the base of the PR and between 6dcc151 and 6714654.

📒 Files selected for processing (2)
  • src/runtime/components/Tabs.vue
  • test/components/Tabs.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/runtime/components/Tabs.vue

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
test/components/Tabs.spec.ts (1)

11-11: ⚠️ Potential issue | 🟠 Major

Run the hydration test in the Nuxt environment.

hydrationIt currently skips when __NUXT_VITEST_ENVIRONMENT__ is truthy, which leaves the SSR hydration regression untested in the target environment. Swap the ternary branches.

Fix
-const hydrationIt = (globalThis as { __NUXT_VITEST_ENVIRONMENT__?: boolean }).__NUXT_VITEST_ENVIRONMENT__ ? it.skip : it
+const hydrationIt = (globalThis as { __NUXT_VITEST_ENVIRONMENT__?: boolean }).__NUXT_VITEST_ENVIRONMENT__ ? it : it.skip

Run this read-only check to verify the flag’s usage in this repo:

#!/bin/bash
# Description: Inspect how the Nuxt Vitest environment flag is used and confirm
# the hydration test gate maps the Nuxt environment to `it`, not `it.skip`.

sed -n '1,20p' test/components/Tabs.spec.ts

rg -n -C3 '__NUXT_VITEST_ENVIRONMENT__|environment:\s*["'\''"]nuxt|@nuxt/test-utils' \
  --glob '*.ts' \
  --glob '*.vue'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/components/Tabs.spec.ts` at line 11, The hydrationIt definition
currently skips tests when the Nuxt env flag is truthy; change the ternary so
that hydrationIt resolves to it (not it.skip) when (globalThis as {
__NUXT_VITEST_ENVIRONMENT__?: boolean }).__NUXT_VITEST_ENVIRONMENT__ is truthy;
locate the hydrationIt declaration in test/components/Tabs.spec.ts and swap the
it.skip and it branches so SSR hydration tests run in the Nuxt Vitest
environment.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@test/components/Tabs.spec.ts`:
- Line 11: The hydrationIt definition currently skips tests when the Nuxt env
flag is truthy; change the ternary so that hydrationIt resolves to it (not
it.skip) when (globalThis as { __NUXT_VITEST_ENVIRONMENT__?: boolean
}).__NUXT_VITEST_ENVIRONMENT__ is truthy; locate the hydrationIt declaration in
test/components/Tabs.spec.ts and swap the it.skip and it branches so SSR
hydration tests run in the Nuxt Vitest environment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6db9dc94-899b-46d5-8723-1fad0583fd8b

📥 Commits

Reviewing files that changed from the base of the PR and between 6714654 and 8b668a1.

📒 Files selected for processing (1)
  • test/components/Tabs.spec.ts

@benjamincanac
Copy link
Copy Markdown
Member

Closing in favor of #6380.

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

Labels

v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UTabs remounts all following tabs when adding/removing a tab due to index-based keys

2 participants