Impact
Three visible form fields have no accessible name in production. Screen reader users get bare textboxes on two of the app's main write paths.
| Page |
Field |
/notes/new |
Title |
/notes/new |
Body |
/admin |
invite email |
Root cause
Vue's useId() diverges between server and client. UFormField does const id = ref(useId()) and binds <Label :for="id">, passing the same id to the child input. After hydration the input re-generates its id while the label keeps the server value:
|
label for |
input id |
| SSR |
v-0-12-0 |
v-0-12-0 ✓ |
| after hydration |
v-0-12-0 |
v-0-0-0 ✗ |
document.getElementById(label.htmlFor) returns null for all three.
/account is unaffected. Its ids are flat (v-0-7, v-0-8, …) while the broken ones are nested (v-0-12-0), which means useId() ran inside an async boundary on the broken pages and not on /account.
Only in production
Dev builds are fine. This is why pnpm test:e2e passes locally (dev server) while CI fails (production build). It also went unnoticed for weeks because the scheduled e2e workflow was blocked by an Actions budget.
Reproduce locally against the production build:
pnpm build
SCREENSHOTS=1 pnpm exec playwright test --project=app
Tried and rejected
Adding { lazy: true } to useNotes()'s useAsyncData on the theory that it created the async boundary. Rebuilt and retested: ids were still nested and still broken. Not the cause.
Options
- Interim: explicit
aria-label on the three fields. Restores the accessible name and unblocks CI. A workaround, not a cure, and it will not help the next form someone adds.
- Real fix: find why
useId() is unstable on these pages. UFormField exposes no id prop, so it cannot be overridden from the call site. Likely worth an upstream issue against Nuxt UI or Vue.
Any page combining a setup-time fetch with UFormField is exposed, so this is not limited to the three fields above.
Impact
Three visible form fields have no accessible name in production. Screen reader users get bare textboxes on two of the app's main write paths.
/notes/new/notes/new/adminRoot cause
Vue's
useId()diverges between server and client.UFormFielddoesconst id = ref(useId())and binds<Label :for="id">, passing the same id to the child input. After hydration the input re-generates its id while the label keeps the server value:foridv-0-12-0v-0-12-0✓v-0-12-0v-0-0-0✗document.getElementById(label.htmlFor)returnsnullfor all three./accountis unaffected. Its ids are flat (v-0-7,v-0-8, …) while the broken ones are nested (v-0-12-0), which meansuseId()ran inside an async boundary on the broken pages and not on/account.Only in production
Dev builds are fine. This is why
pnpm test:e2epasses locally (dev server) while CI fails (production build). It also went unnoticed for weeks because the scheduled e2e workflow was blocked by an Actions budget.Reproduce locally against the production build:
Tried and rejected
Adding
{ lazy: true }touseNotes()'suseAsyncDataon the theory that it created the async boundary. Rebuilt and retested: ids were still nested and still broken. Not the cause.Options
aria-labelon the three fields. Restores the accessible name and unblocks CI. A workaround, not a cure, and it will not help the next form someone adds.useId()is unstable on these pages.UFormFieldexposes noidprop, so it cannot be overridden from the call site. Likely worth an upstream issue against Nuxt UI or Vue.Any page combining a
setup-time fetch withUFormFieldis exposed, so this is not limited to the three fields above.