Skip to content

Commit b2e781d

Browse files
authored
fix(useId): honor configured id source before vue useId (#2683)
* fix: honor configured id source before vue useId * chore(deps): pin @vue/server-renderer dependency * chore(deps): update lockfile
1 parent 034d20e commit b2e781d

8 files changed

Lines changed: 198 additions & 20 deletions

File tree

docs/content/docs/utilities/config-provider.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,11 @@ import { ConfigProvider } from 'reka-ui'
6464
</template>
6565
```
6666

67-
## Hydration issue (Vue < 3.5)
67+
## Hydration issue
6868

69-
We expose a temporary workaround to allow current Nuxt (with version >3.10) project fix the current hydration issue by using [`useId`](https://nuxt.com/docs/api/composables/use-id) provided by Nuxt.
69+
`ConfigProvider` can accept a custom `useId` function for frameworks that need to provide their own SSR-stable ID source. Reka UI uses this function before Vue's native `useId`, so every primitive that generates accessibility IDs follows the same app-provided source.
70+
71+
This is useful in Nuxt projects where prerendered HTML and client hydration can use different Vue app ID prefixes. Pass Nuxt's [`useId`](https://nuxt.com/docs/api/composables/use-id) through `ConfigProvider` so Reka-generated IDs stay stable across server and client rendering.
7072

7173
> Inspired by [Headless UI](https://github.com/tailwindlabs/headlessui/pull/2959)
7274

docs/content/docs/utilities/use-id.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ description: Generate random id
1515
Generate random id
1616
</Description>
1717

18+
## Source order
19+
20+
`useId` resolves IDs in this order:
21+
22+
1. Explicit ID passed to `useId(id)`.
23+
2. The `useId` function provided by `ConfigProvider`.
24+
3. Vue's native `useId` when available.
25+
4. Reka UI's fallback counter for older Vue versions.
26+
27+
Use `ConfigProvider` when your framework provides its own SSR-stable ID source, such as Nuxt's `useId`.
28+
1829
## Usage
1930

2031
```ts

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
"@types/node": "^24.0.13",
114114
"@vitejs/plugin-vue": "^6.0.7",
115115
"@vitest/coverage-istanbul": "^3.2.6",
116+
"@vue/server-renderer": "^3.5.17",
116117
"@vue/test-utils": "^2.4.11",
117118
"@vue/tsconfig": "^0.7.0",
118119
"jsdom": "^26.1.0",

packages/core/src/Accordion/Accordion.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,80 @@
11
import type { VueWrapper } from '@vue/test-utils'
22
import { findByText, fireEvent } from '@testing-library/vue'
3+
import { renderToString } from '@vue/server-renderer'
34
import { mount } from '@vue/test-utils'
4-
import { beforeEach, describe, expect, it } from 'vitest'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
56
import { axe } from 'vitest-axe'
7+
import { createSSRApp, defineComponent, h, nextTick } from 'vue'
8+
import { ConfigProvider } from '@/ConfigProvider'
9+
import {
10+
AccordionContent,
11+
AccordionHeader,
12+
AccordionItem,
13+
AccordionRoot,
14+
AccordionTrigger,
15+
} from '.'
616
import Accordion from './story/_Accordion.vue'
717

18+
const AccordionHydrationFixture = defineComponent({
19+
setup() {
20+
const items = ['One', 'Two']
21+
let count = 0
22+
const useId = () => `nuxt-${++count}`
23+
24+
return () =>
25+
h(ConfigProvider, { useId }, () =>
26+
h(
27+
AccordionRoot,
28+
{ type: 'single', collapsible: true },
29+
() => items.map(item =>
30+
h(AccordionItem, { value: item }, () => [
31+
h(AccordionHeader, () =>
32+
h(AccordionTrigger, () => `Trigger ${item}`)),
33+
h(AccordionContent, () => `Content ${item}`),
34+
]),
35+
),
36+
))
37+
},
38+
})
39+
40+
afterEach(() => {
41+
vi.restoreAllMocks()
42+
})
43+
44+
describe('ssr hydration', () => {
45+
it('uses ConfigProvider ids when Vue app id prefixes differ', async () => {
46+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
47+
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
48+
49+
// Nuxt prerender can produce a different Vue useId app prefix than the
50+
// hydrating client. ConfigProvider's useId must remain the stable source.
51+
const serverApp = createSSRApp(AccordionHydrationFixture)
52+
serverApp.config.idPrefix = 'v-1'
53+
54+
const container = document.createElement('div')
55+
container.innerHTML = await renderToString(serverApp)
56+
document.body.innerHTML = ''
57+
document.body.append(container)
58+
59+
expect(container.innerHTML).toContain('id="reka-accordion-trigger-nuxt-1"')
60+
expect(container.innerHTML).toContain('id="reka-collapsible-content-nuxt-2"')
61+
const triggerId = container.querySelector('button')?.id
62+
const contentId = container.querySelector('[role="region"]')?.id
63+
64+
const clientApp = createSSRApp(AccordionHydrationFixture)
65+
clientApp.config.idPrefix = 'v-0'
66+
clientApp.mount(container)
67+
await nextTick()
68+
69+
expect(container.querySelector('button')?.id).toBe(triggerId)
70+
expect(container.querySelector('[role="region"]')?.id).toBe(contentId)
71+
72+
const warnings = warn.mock.calls.flat().join('\n')
73+
expect(warnings).not.toContain('Hydration attribute mismatch')
74+
expect(error.mock.calls.flat().join('\n')).not.toContain('Hydration completed but contains mismatches')
75+
})
76+
})
77+
878
describe('given a single Accordion', () => {
979
let wrapper: VueWrapper<InstanceType<typeof Accordion>>
1080
beforeEach(() => {

packages/core/src/ConfigProvider/ConfigProvider.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import type { VueWrapper } from '@vue/test-utils'
22
import type vueuse from '@vueuse/core'
33
import { mount } from '@vue/test-utils'
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
5-
import { nextTick } from 'vue'
5+
import { defineComponent, h, nextTick } from 'vue'
6+
import { useId } from '@/shared'
67
import ConfigProviderTest from './_ConfigProvider.vue'
8+
import ConfigProvider from './ConfigProvider.vue'
79

810
vi.mock('@vueuse/core', async (importOriginal) => {
911
const mod: typeof vueuse = await importOriginal()
@@ -84,3 +86,25 @@ describe('given a scrollBody ConfigProvider', async () => {
8486
expect(document.body.style.marginRight).toBe('20px')
8587
})
8688
})
89+
90+
describe('given a useId ConfigProvider', () => {
91+
it('uses the provided id generator before Vue useId', () => {
92+
const IdConsumer = defineComponent({
93+
setup() {
94+
const id = useId(undefined, 'reka-test')
95+
return () => h('div', { id })
96+
},
97+
})
98+
99+
const wrapper = mount(ConfigProvider, {
100+
props: {
101+
useId: () => 'provided-id',
102+
},
103+
slots: {
104+
default: () => h(IdConsumer),
105+
},
106+
})
107+
108+
expect(wrapper.find('#reka-test-provided-id').exists()).toBe(true)
109+
})
110+
})

packages/core/src/Tabs/Tabs.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,69 @@
11
import type { VueWrapper } from '@vue/test-utils'
2+
import { renderToString } from '@vue/server-renderer'
23
import { flushPromises, mount } from '@vue/test-utils'
3-
import { beforeEach, describe, expect, it } from 'vitest'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
45
import { axe } from 'vitest-axe'
6+
import { createSSRApp, defineComponent, h, nextTick } from 'vue'
7+
import { ConfigProvider } from '@/ConfigProvider'
58
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '.'
69
import Tabs from './story/_Tabs.vue'
710

11+
const TabsHydrationFixture = defineComponent({
12+
setup() {
13+
let count = 0
14+
const useId = () => `nuxt-${++count}`
15+
16+
return () =>
17+
h(ConfigProvider, { useId }, () =>
18+
h(TabsRoot, { defaultValue: 'account' }, () => [
19+
h(TabsList, () => [
20+
h(TabsTrigger, { value: 'account' }, () => 'Account'),
21+
h(TabsTrigger, { value: 'password' }, () => 'Password'),
22+
]),
23+
h(TabsContent, { value: 'account' }, () => 'Account content'),
24+
h(TabsContent, { value: 'password' }, () => 'Password content'),
25+
]))
26+
},
27+
})
28+
29+
afterEach(() => {
30+
vi.restoreAllMocks()
31+
})
32+
33+
describe('ssr hydration', () => {
34+
it('uses ConfigProvider ids when Vue app id prefixes differ', async () => {
35+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
36+
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
37+
38+
// Tabs derives trigger/content IDs from one base ID, so this catches the
39+
// shared source-order bug for components that build related IDs.
40+
const serverApp = createSSRApp(TabsHydrationFixture)
41+
serverApp.config.idPrefix = 'v-1'
42+
43+
const container = document.createElement('div')
44+
container.innerHTML = await renderToString(serverApp)
45+
document.body.innerHTML = ''
46+
document.body.append(container)
47+
48+
expect(container.innerHTML).toContain('id="reka-tabs-nuxt-1-trigger-account"')
49+
expect(container.innerHTML).toContain('id="reka-tabs-nuxt-1-content-account"')
50+
const triggerId = container.querySelector('[role="tab"]')?.id
51+
const contentId = container.querySelector('[role="tabpanel"]')?.id
52+
53+
const clientApp = createSSRApp(TabsHydrationFixture)
54+
clientApp.config.idPrefix = 'v-0'
55+
clientApp.mount(container)
56+
await nextTick()
57+
58+
expect(container.querySelector('[role="tab"]')?.id).toBe(triggerId)
59+
expect(container.querySelector('[role="tabpanel"]')?.id).toBe(contentId)
60+
61+
const warnings = warn.mock.calls.flat().join('\n')
62+
expect(warnings).not.toContain('Hydration attribute mismatch')
63+
expect(error.mock.calls.flat().join('\n')).not.toContain('Hydration completed but contains mismatches')
64+
})
65+
})
66+
867
describe('given default Tabs', () => {
968
let wrapper: VueWrapper<InstanceType<typeof Tabs>>
1069

packages/core/src/shared/useId.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,32 @@ import { injectConfigProviderContext } from '@/ConfigProvider/ConfigProvider.vue
55

66
let count = 0
77
/**
8-
* The `useId` function generates a unique identifier using a provided deterministic ID or a default
9-
* one prefixed with "reka-", or the provided one via `useId` props from `<ConfigProvider>`.
8+
* The `useId` function generates a unique identifier using a provided deterministic ID,
9+
* a configured `<ConfigProvider>` ID source, Vue's native `useId`, or a fallback counter.
1010
* @param {string | null | undefined} [deterministicId] - The `useId` function you provided takes an
1111
* optional parameter `deterministicId`, which can be a string, null, or undefined. If
1212
* `deterministicId` is provided, the function will return it. Otherwise, it will generate an id using
13-
* the `useId` function obtained
13+
* the configured ID source.
1414
*/
1515
export function useId(deterministicId?: string | null | undefined, prefix = 'reka') {
1616
if (deterministicId)
1717
return deterministicId
1818

1919
let id: string
20-
if ('useId' in vue) {
20+
const configProviderContext = injectConfigProviderContext({ useId: undefined })
21+
22+
// Keep the app-provided ID source authoritative. Frameworks such as Nuxt can
23+
// prerender with a different Vue app ID prefix than the hydrating client, so
24+
// falling through to Vue's native useId would bypass the stable source that
25+
// ConfigProvider was explicitly given.
26+
if (configProviderContext.useId) {
27+
id = configProviderContext.useId()
28+
}
29+
else if ('useId' in vue) {
2130
id = vue.useId?.()
2231
}
2332
else {
24-
const configProviderContext = injectConfigProviderContext({ useId: undefined })
25-
id = configProviderContext.useId?.() ?? `${++count}`
33+
id = `${++count}`
2634
}
2735

2836
return prefix ? `${prefix}-${id}` : id

pnpm-lock.yaml

Lines changed: 12 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)