Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/content/docs/2.components/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@ Learn how to use the `locale` prop to change the locale of your app. This also c
:::
::

### Stable IDs

Reka UI primitives rely on Vue's [useId](https://vuejs.org/api/composition-api-helpers.html#useid) to generate the `id` attributes used for accessibility. A known upstream issue ([vuejs/core#12591](https://github.com/vuejs/core/issues/12591)) can make these ids differ between the server and the client in some Nuxt apps, causing hydration mismatches on components like Accordion or Tabs.

You can use the `useId` prop to provide your own generator as a workaround:

```vue [app.vue]
<script setup lang="ts">
let count = 0
const generateId = () => `app-${++count}`
</script>

<template>
<UApp :use-id="generateId">
<NuxtPage />
</UApp>
</template>
```

::warning
A counter like this is only hydration safe when Reka UI primitives mount in the same order on the server and the client, which is not guaranteed when several lazy or data fetching components render them. Keep the default unless you actually face these mismatches.
::

## API

### Props
Expand Down
9 changes: 7 additions & 2 deletions src/runtime/components/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export interface AppProps<T extends Messages = Messages> extends Omit<ConfigProv
toaster?: ToasterProps | null
locale?: Locale<T>
portal?: boolean | string | HTMLElement
/**
* Provide a custom `id` generator for Reka UI primitives as a workaround when facing hydration mismatches.
* @defaultValue Vue's `useId`
*/
useId?: () => string
}

export interface AppSlots {
Expand All @@ -21,7 +26,7 @@ export default {
</script>

<script setup lang="ts" generic="T extends Messages">
import { toRef, useId, provide } from 'vue'
import { toRef, useId as vueUseId, provide } from 'vue'
import { ConfigProvider, TooltipProvider, useForwardProps } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import { localeContextInjectionKey } from '../composables/useLocale'
Expand All @@ -46,7 +51,7 @@ provide(portalTargetInjectionKey, portal)
</script>

<template>
<ConfigProvider :use-id="() => (useId() as string)" :dir="props.dir || locale?.dir" :locale="locale?.code" v-bind="configProviderProps">
<ConfigProvider :use-id="() => props.useId?.() ?? (vueUseId() as string)" :dir="props.dir || locale?.dir" :locale="locale?.code" v-bind="configProviderProps">
<TooltipProvider v-bind="tooltipProps">
<UToaster v-if="toaster !== null" v-bind="toasterProps">
<slot />
Expand Down
32 changes: 32 additions & 0 deletions test/components/App.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { defineComponent, h } from 'vue'
import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { useId } from 'reka-ui'
import App from '../../src/runtime/components/App.vue'

const IdConsumer = defineComponent({
setup() {
const id = useId()
return () => h('div', { id, 'data-testid': 'consumer' })
}
})

describe('App', () => {
it('defaults to Vue useId for Reka UI primitives', async () => {
const wrapper = await mountSuspended(App, {
slots: { default: () => h(IdConsumer) }
})

expect(wrapper.find('[data-testid="consumer"]').attributes('id')).toMatch(/^reka-v-?\d/)
})

it('forwards the useId prop to Reka UI primitives', async () => {
let count = 0
const wrapper = await mountSuspended(App, {
props: { useId: () => `custom-${++count}` },
slots: { default: () => h(IdConsumer) }
})

expect(wrapper.find('[data-testid="consumer"]').attributes('id')).toBe('reka-custom-1')
})
})