Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nuxt): useId composable #23368

Merged
merged 36 commits into from
Jan 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c8e26c9
feat(nuxt): add `useId`
TakNePoidet Sep 22, 2023
46742c5
refactor(nuxt): simplification checking the key type and return type …
TakNePoidet Sep 23, 2023
22ef4ce
test(nuxt): add testing `useId`
TakNePoidet Sep 23, 2023
50c2303
Merge branch 'main' into main
TakNePoidet Sep 25, 2023
bcd0ddc
refactor(nuxt): renaming `useId` to `getUniqueID`
TakNePoidet Sep 25, 2023
10140e9
fix(vite): remove key `getUniqueID`
TakNePoidet Sep 26, 2023
b3585be
docs(nuxt): remove generic `getUniqueID`
TakNePoidet Sep 26, 2023
8242f31
Merge branch 'main' into main
TakNePoidet Sep 26, 2023
3113f3e
Merge branch 'main' into main
TakNePoidet Sep 29, 2023
23c9e15
feat(nuxt): add localId in `getUniqueID`
TakNePoidet Oct 5, 2023
62c94a5
chore(nuxt): remove prefix in `getUniqueID`
TakNePoidet Oct 5, 2023
944caac
Merge branch 'main' into main
TakNePoidet Nov 26, 2023
0f04401
Merge branch 'nuxt:main' into main
TakNePoidet Dec 14, 2023
ce39c42
chore: fix merge conflict
danielroe Dec 20, 2023
70f14a2
test: add test to ensure ids differ between component instances
danielroe Dec 20, 2023
98b42e1
Merge remote-tracking branch 'origin/main' into TakNePoidet/main
danielroe Dec 20, 2023
9368930
refactor: rename to `._ids`
danielroe Dec 20, 2023
f54793d
test: add failing test for id stability on hydration
danielroe Dec 20, 2023
063b6ac
Merge branch 'main' into main
Atinux Dec 20, 2023
5ab3221
chore: rename to useId and make it work properly
Atinux Dec 20, 2023
28ee91c
chore: fix types
Atinux Dec 20, 2023
32eb513
test: update test for useId
Atinux Dec 20, 2023
e3d5902
small fixes
Atinux Dec 20, 2023
145b8a0
docs: typo fixes
danielroe Dec 20, 2023
296ecb5
test: close page
danielroe Dec 20, 2023
2dd33b8
test: add back runtime `useId` tests
danielroe Dec 20, 2023
ece6d0b
test: remove alternative `local-ids` e2e test
danielroe Dec 20, 2023
f21dd99
Merge branch 'nuxt:main' into main
TakNePoidet Dec 20, 2023
f70a684
Merge remote-tracking branch 'origin/main' into TakNePoidet/main
danielroe Dec 23, 2023
92e1f0c
test: assert id stability on client
danielroe Dec 25, 2023
049d48f
Merge remote-tracking branch 'origin/main' into TakNePoidet/main
danielroe Dec 25, 2023
d38530e
Merge branch 'main' into main
Atinux Jan 8, 2024
e460413
chore: handle when node is a comment
Atinux Jan 8, 2024
ea8c2ee
Merge branch 'main' into main
Atinux Jan 9, 2024
66217ff
Merge remote-tracking branch 'origin/main' into TakNePoidet/main
danielroe Jan 29, 2024
0232863
fix: use key + object syntax within data attribute
danielroe Jan 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/3.api/2.composables/use-id.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/nuxt/src/app/components/client-only.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions packages/nuxt/src/app/composables/id.ts
Original file line number Diff line number Diff line change
@@ -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++
}
1 change: 1 addition & 0 deletions packages/nuxt/src/app/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
6 changes: 4 additions & 2 deletions packages/nuxt/src/app/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ interface _NuxtApp {

[key: string]: unknown

/** @internal */
_id?: number
/** @internal */
_scope: EffectScope
/** @internal */
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt/src/app/types/augments.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ declare module 'vue' {
}
interface ComponentInternalInstance {
_nuxtOnBeforeMountCbs: Function[]
_nuxtIdIndex?: Record<string, number>
}
}
4 changes: 4 additions & 0 deletions packages/nuxt/src/imports/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ const granularAppPresets: InlinePreset[] = [
{
imports: ['useRequestURL'],
from: '#app/composables/url'
},
{
imports: ['useId'],
from: '#app/composables/id'
}
]

Expand Down
1 change: 1 addition & 0 deletions packages/schema/src/config/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
29 changes: 27 additions & 2 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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(/<!--[[\]]-->/, '')
Dismissed Show dismissed Hide dismissed

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', () => {
Expand Down Expand Up @@ -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 = ''
Expand Down
21 changes: 21 additions & 0 deletions test/fixtures/basic/components/ComponentWithIds.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions test/fixtures/basic/pages/use-id.vue
Original file line number Diff line number Diff line change
@@ -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>

30 changes: 30 additions & 0 deletions test/nuxt/composables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -98,6 +100,7 @@ describe('composables', () => {
'clearNuxtState',
'useState',
'useRequestURL',
'useId',
'useRoute',
'navigateTo',
'abortNavigation',
Expand Down Expand Up @@ -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()
Expand Down