feat(nuxt): `useId` composable (#23368)

Co-authored-by: Daniel Roe <daniel@roe.dev>
Co-authored-by: Sébastien Chopin <seb@nuxt.com>
This commit is contained in:
Якин Никита 2024-01-30 14:10:13 +05:00 committed by GitHub
parent 46b5336718
commit 658a0ffed7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 187 additions and 6 deletions

View File

@ -0,0 +1,29 @@
---
title: "useId"
description: Generate an SSR-friendly unique identifier that can be passed to accessibility attributes.
---
`useId` generates an SSR-friendly unique identifier that can be passed to accessibility attributes.
Call `useId` at the top level of your component to generate a unique string identifier:
```vue [components/EmailField.vue]
<script setup lang="ts">
const id = useId()
</script>
<template>
<div>
<label :for="id">Email</label>
<input :id="id" name="email" type="email"/>
</div>
</template>
```
## Parameters
`useId` does not take any parameters.
## Returns
`useId` returns a unique string associated with this particular `useId` call in this particular component.

View File

@ -1,7 +1,9 @@
import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, ref } from 'vue'
import type { ComponentInternalInstance, ComponentOptions } from 'vue'
import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, provide, ref } from 'vue'
import type { ComponentInternalInstance, ComponentOptions, InjectionKey } from 'vue'
import { getFragmentHTML } from './utils'
export const clientOnlySymbol: InjectionKey<boolean> = Symbol.for('nuxt:client-only')
export default defineComponent({
name: 'ClientOnly',
inheritAttrs: false,
@ -10,6 +12,7 @@ export default defineComponent({
setup (_, { slots, attrs }) {
const mounted = ref(false)
onMounted(() => { mounted.value = true })
provide(clientOnlySymbol, true)
return (props: any) => {
if (mounted.value) { return slots.default?.() }
const slot = slots.fallback || slots.placeholder

View File

@ -0,0 +1,49 @@
import { getCurrentInstance, inject } from 'vue'
import { useNuxtApp } from '../nuxt'
import { clientOnlySymbol } from '#app/components/client-only'
const ATTR_KEY = 'data-n-ids'
/**
* Generate an SSR-friendly unique identifier that can be passed to accessibility attributes.
*/
export function useId (key?: string): string {
if (typeof key !== 'string') {
throw new TypeError('[nuxt] [useId] key must be a string.')
}
const nuxtApp = useNuxtApp()
const instance = getCurrentInstance()
if (!instance) {
// TODO: support auto-incrementing ID for plugins if there is need?
throw new TypeError('[nuxt] `useId` must be called within a component.')
}
nuxtApp._id ||= 0
instance._nuxtIdIndex ||= {}
instance._nuxtIdIndex[key] ||= 0
const instanceIndex = key + ':' + instance._nuxtIdIndex[key]++
if (import.meta.server) {
const ids = JSON.parse(instance.attrs[ATTR_KEY] as string | undefined || '{}')
ids[instanceIndex] = key + ':' + nuxtApp._id++
instance.attrs[ATTR_KEY] = JSON.stringify(ids)
return ids[instanceIndex]
}
if (nuxtApp.payload.serverRendered && nuxtApp.isHydrating && !inject(clientOnlySymbol, false)) {
// Access data attribute from sibling if root is a comment node and sibling is an element
const el = instance.vnode.el?.nodeType === 8 && instance.vnode.el?.nextElementSibling?.getAttribute
? instance.vnode.el?.nextElementSibling
: instance.vnode.el
const ids = JSON.parse(el?.getAttribute?.(ATTR_KEY) || '{}')
if (ids[instanceIndex]) {
return ids[instanceIndex]
}
}
// pure client-side ids, avoiding potential collision with server-side ids
return key + '_' + nuxtApp._id++
}

View File

@ -35,3 +35,4 @@ export type { NuxtAppManifest, NuxtAppManifestMeta } from './manifest'
export type { ReloadNuxtAppOptions } from './chunk'
export { reloadNuxtApp } from './chunk'
export { useRequestURL } from './url'
export { useId } from './id'

View File

@ -102,6 +102,8 @@ interface _NuxtApp {
[key: string]: unknown
/** @internal */
_id?: number
/** @internal */
_scope: EffectScope
/** @internal */
@ -438,7 +440,7 @@ export function callWithNuxt<T extends (...args: any[]) => any> (nuxt: NuxtApp |
/*@__NO_SIDE_EFFECTS__*/
/**
* Returns the current Nuxt instance.
*
*
* Returns `null` if Nuxt instance is unavailable.
*/
export function tryUseNuxtApp (): NuxtApp | null {
@ -455,7 +457,7 @@ export function tryUseNuxtApp (): NuxtApp | null {
/*@__NO_SIDE_EFFECTS__*/
/**
* Returns the current Nuxt instance.
*
*
* Throws an error if Nuxt instance is unavailable.
*/
export function useNuxtApp (): NuxtApp {

View File

@ -31,5 +31,6 @@ declare module 'vue' {
}
interface ComponentInternalInstance {
_nuxtOnBeforeMountCbs: Function[]
_nuxtIdIndex?: Record<string, number>
}
}

View File

@ -100,6 +100,10 @@ const granularAppPresets: InlinePreset[] = [
{
imports: ['useRequestURL'],
from: '#app/composables/url'
},
{
imports: ['useId'],
from: '#app/composables/id'
}
]

View File

@ -136,6 +136,7 @@ export default defineUntypedSchema({
*/
keyedComposables: {
$resolve: (val: Array<{ name: string, argumentLength: string }> | undefined) => [
{ name: 'useId', argumentLength: 1 },
{ name: 'callOnce', argumentLength: 2 },
{ name: 'defineNuxtComponent', argumentLength: 2 },
{ name: 'useState', argumentLength: 2 },

View File

@ -872,7 +872,7 @@ describe('navigate external', () => {
})
describe('composables', () => {
it('should run code once', async () => {
it('`callOnce` should run code once', async () => {
const html = await $fetch('/once')
expect(html).toContain('once.vue')
@ -881,6 +881,31 @@ describe('composables', () => {
const { page } = await renderPage('/once')
expect(await page.getByText('once:').textContent()).toContain('once: 2')
})
it('`useId` should generate unique ids', async () => {
// TODO: work around interesting Vue bug where async components are loaded in a different order on first import
await $fetch('/use-id')
const sanitiseHTML = (html: string) => html.replace(/ data-[^= ]+="[^"]+"/g, '').replace(/<!--[[\]]-->/, '')
const serverHTML = await $fetch('/use-id').then(html => sanitiseHTML(html.match(/<form.*<\/form>/)![0]))
const ids = serverHTML.match(/id="[^"]*"/g)?.map(id => id.replace(/id="([^"]*)"/, '$1')) as string[]
const renderedForm = [
`<h2 id="${ids[0]}"> id: ${ids[0]}</h2><div><label for="${ids[1]}">Email</label><input id="${ids[1]}" name="email" type="email"><label for="${ids[2]}">Password</label><input id="${ids[2]}" name="password" type="password"></div>`,
`<div><label for="${ids[3]}">Email</label><input id="${ids[3]}" name="email" type="email"><label for="${ids[4]}">Password</label><input id="${ids[4]}" name="password" type="password"></div>`
]
const clientOnlyServer = '<span></span>'
expect(serverHTML).toEqual(`<form>${renderedForm.join(clientOnlyServer)}</form>`)
const { page, pageErrors } = await renderPage('/use-id')
const clientHTML = await page.innerHTML('form')
const clientIds = clientHTML
.match(/id="[^"]*"/g)?.map(id => id.replace(/id="([^"]*)"/, '$1'))
.filter(i => !ids.includes(i)) as string[]
const clientOnlyClient = `<div><label for="${clientIds[0]}">Email</label><input id="${clientIds[0]}" name="email" type="email"><label for="${clientIds[1]}">Password</label><input id="${clientIds[1]}" name="password" type="password"></div>`
expect(sanitiseHTML(clientHTML)).toEqual(`${renderedForm.join(clientOnlyClient)}`)
expect(pageErrors).toEqual([])
await page.close()
})
})
describe('middlewares', () => {
@ -1420,7 +1445,7 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => {
expect(files.map(m => m.replace(/\.\w+(\.\w+)$/, '$1'))).toContain('css-only-asset.svg')
})
it('should not include inlined CSS in generated CSS file', async () => {
it('should not include inlined CSS in generated CSS file', async () => {
const html: string = await $fetch('/styles')
const cssFiles = new Set([...html.matchAll(/<link [^>]*href="([^"]*\.css)">/g)].map(m => m[1]))
let css = ''

View File

@ -0,0 +1,21 @@
<script setup>
const emailId = useId()
const passwordId = useId()
</script>
<template>
<div>
<label :for="emailId">Email</label>
<input
:id="emailId"
name="email"
type="email"
>
<label :for="passwordId">Password</label>
<input
:id="passwordId"
name="password"
type="password"
>
</div>
</template>

15
test/fixtures/basic/pages/use-id.vue vendored Normal file
View File

@ -0,0 +1,15 @@
<script setup>
const id = useId()
</script>
<template>
<form>
<h2 :id="id">
id: {{ id }}
</h2>
<LazyComponentWithIds />
<ClientOnly><ComponentWithIds /></ClientOnly>
<ComponentWithIds />
</form>
</template>

View File

@ -3,6 +3,7 @@
import { describe, expect, it, vi } from 'vitest'
import { defineEventHandler } from 'h3'
import { mount } from '@vue/test-utils'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import * as composables from '#app/composables'
@ -14,6 +15,7 @@ import { setResponseStatus, useRequestEvent, useRequestFetch, useRequestHeaders
import { clearNuxtState, useState } from '#app/composables/state'
import { useRequestURL } from '#app/composables/url'
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
import { useId } from '#app/composables/id'
import { callOnce } from '#app/composables/once'
import { useLoadingIndicator } from '#app/composables/loading-indicator'
@ -98,6 +100,7 @@ describe('composables', () => {
'clearNuxtState',
'useState',
'useRequestURL',
'useId',
'useRoute',
'navigateTo',
'abortNavigation',
@ -431,6 +434,33 @@ describe('clearNuxtState', () => {
})
})
describe('useId', () => {
it('default', () => {
const vals = new Set<string>()
for (let index = 0; index < 100; index++) {
mount(defineComponent({
setup () {
const id = useId()
vals.add(id)
return () => h('div', id)
}
}))
}
expect(vals.size).toBe(100)
})
it('generates unique ids per-component', () => {
const component = defineComponent({
setup () {
const id = useId()
return () => h('div', id)
}
})
expect(mount(component).html()).not.toBe(mount(component).html())
})
})
describe('url', () => {
it('useRequestURL', () => {
const url = useRequestURL()