Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/lucky-donkeys-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/vue-form': patch
---

Generate the default `formId` with Vue's `useId` so it is SSR-safe, falling back to a random uuid on `vue@3.4` (where `useId` does not exist) and outside of a component instance.

Fixes #2254
5 changes: 4 additions & 1 deletion packages/vue-form/src/useForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useSelector } from '@tanstack/vue-store'
import { defineComponent, h, onMounted } from 'vue'
import { Field } from './useField'
import { FormGroup } from './useFormGroup'
import { useFormId } from './useFormId'
import type {
FormAsyncValidateOrFn,
FormOptions,
Expand Down Expand Up @@ -268,6 +269,8 @@ export function useForm<
TSubmitMeta
>,
) {
const fallbackFormId = useFormId()

const formApi = (() => {
const api = new FormApi<
TParentData,
Expand All @@ -282,7 +285,7 @@ export function useForm<
TFormOnDynamicAsync,
TFormOnServer,
TSubmitMeta
>(opts)
>({ ...opts, formId: opts?.formId ?? fallbackFormId })

const extendedApi: typeof api &
VueFormApi<
Expand Down
24 changes: 24 additions & 0 deletions packages/vue-form/src/useFormId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as Vue from 'vue'
import { uuid } from '@tanstack/form-core'

/**
* `useId` was added in vue@3.5, but this package supports `vue@^3.4.0`. It is
* read off the namespace, typed as optional, rather than imported by name: a
* named import would make bundlers fail to resolve it on 3.4.
* Read more: https://github.com/webpack/webpack/issues/14814
*/
const vueUseId: { useId?: () => string } = Vue

/**
* Returns an SSR-safe id for the form, so the server-rendered markup and the
* client hydration agree on it. Falls back to a random uuid when `useId` is
* unavailable, or when called outside of a component instance, where `useId`
* cannot produce a stable value.
*/
export function useFormId(): string {
const useId = vueUseId.useId
if (useId && Vue.getCurrentInstance()) {
return useId()
}
return uuid()
Comment on lines +18 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | πŸ—οΈ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)useFormId\.ts$|package\.json$|packages/vue-form|vue-useId|useId|useUUID' || true

echo "== useFormId =="
if [ -f packages/vue-form/src/useFormId.ts ]; then
  cat -n packages/vue-form/src/useFormId.ts
fi

echo "== package manifests relevant =="
for f in package.json packages/vue-form/package.json; do
  if [ -f "$f" ]; then
    echo "-- $f"
    jq '{name,version,type,scripts,peerDependencies,dependencies,devDependencies,engines}' "$f"
  fi
done

echo "== references to useFormId =="
rg -n "useFormId|useid|useId|uuid\\(|vueUseId|Vue\\.use" -S .

Repository: TanStack/form

Length of output: 11353


🌐 Web query:

Vue 3.4 useId not exported composition api helper useId

πŸ’‘ Result:

The useId composable was not exported or available in Vue 3.4 [1][2][3]. It was introduced as a new API in Vue 3.5 [2][3]. If you are using Vue 3.4 and attempt to import useId from 'vue', it will result in an error indicating that the export cannot be found [4]. To use this functionality, you must upgrade your project to Vue 3.5 or later [2][3]. In versions prior to 3.5, developers typically used custom implementations or relied on utilities provided by third-party libraries (such as Nuxt or Quasar) to generate unique IDs [2].

Citations:


🌐 Web query:

Vue 3.5 useId SSR stable server client ID documentation

πŸ’‘ Result:

Vue 3.5 introduced the useId composable to generate unique, stable IDs that remain consistent between server-side rendering (SSR) and client-side hydration [1][2]. This feature addresses long-standing challenges in SSR, such as hydration mismatches caused by inconsistent ID generation [1][3]. Key features and behavior: - Stability: useId produces IDs that are guaranteed to match between server and client renders [1][4]. It achieves this by tracking the component tree structure and the positions of asynchronous boundaries (e.g., async components, async setup, and serverPrefetch) to ensure counters are synchronized [4][2]. - Usage: The API is straightforward to implement, requiring no arguments to return a unique string ID [1][3]: <script setup> import { useId } from 'vue' const id = useId </script> label:for="id"Name: <input:id="id" type="text" /> - Customization: IDs are unique per application instance [4]. By default, they use a prefix of v- (e.g., v:1-2-3), which can be customized at the application level using app.config.idPrefix [4][3]. Important considerations: - Async Boundaries: While useId is designed to be robust, edge cases involving asynchronous logicβ€”such as the use of onServerPrefetch or treeshaking patterns in frameworks like Nuxtβ€”have historically caused hydration issues when the server and client treat async boundaries differently [5][6]. These issues are tracked in the Vue core repository and are typically addressed by ensuring the component tree structure remains identical across environments [5][6]. - Legacy Alternatives: Before Vue 3.5, libraries like Nuxt and Quasar implemented their own ID generation strategies to handle SSR [2]. If you are using Vue 3.5 or later, the built-in useId is the recommended approach to replace those custom solutions [2].

Citations:


Vue 3.4 supports still generate SSR/client mismatched form IDs.

useId() is unavailable in Vue 3.4, so package authors using @tanstack/vue-form with the supported range fall back to uuid() and can get different server/client formIds. Either raise the Vue 3.4 support edge to Vue 3.5+ for the safe default, or replace the random UUID fallback with a deterministic SSR-aware implementation.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vue-form/src/useFormId.ts` around lines 18 - 23, Update useFormId so
supported Vue 3.4 environments cannot produce different server/client IDs:
either raise the package’s Vue peer/support requirement to Vue 3.5+ and rely on
vueUseId.useId, or replace the uuid() fallback with a deterministic SSR-aware ID
generator while preserving the existing useId path.

}
71 changes: 71 additions & 0 deletions packages/vue-form/tests/useFormId.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/vue'
import { createSSRApp, defineComponent, h, nextTick } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { useForm } from '../src'

const Comp = defineComponent(() => {
const form = useForm({ defaultValues: { firstName: '' } })

return () => <form id={form.formId} data-testid="form" />
})

describe('useFormId', () => {
it('uses the provided formId when one is given', () => {
const WithId = defineComponent(() => {
const form = useForm({
defaultValues: { firstName: '' },
formId: 'my-form',
})

return () => <form id={form.formId} data-testid="form" />
})

const { getByTestId } = render(WithId)

expect(getByTestId('form').getAttribute('id')).toBe('my-form')
})

it('generates the same default formId across identical renders', () => {
// Scoped to each render's own container: both mount into `document.body`,
// so a document-wide query would find each other's form too.
const first = render(Comp).container.querySelector('form')?.id
const second = render(Comp).container.querySelector('form')?.id

expect(first).toBeTruthy()
expect(second).toBe(first)
})

it('generates a default formId that matches between server and client', async () => {
const html = await renderToString(createSSRApp(Comp))
// `\s` so this doesn't match the `id="` inside `data-testid="..."`
const serverId = html.match(/\sid="([^"]*)"/)?.[1]

const clientId = render(Comp).getByTestId('form').getAttribute('id')

expect(serverId).toBeTruthy()
expect(clientId).toBe(serverId)
})

it('hydrates server-rendered markup without a mismatch', async () => {
const container = document.createElement('div')
container.innerHTML = await renderToString(createSSRApp(Comp))
document.body.appendChild(container)

const serverId = container.querySelector('form')?.id

// Vue reports hydration mismatches through `console.error`, so actually
// hydrate the server markup and assert none were raised.
const errors: Array<string> = []
const spy = vi
.spyOn(console, 'error')
.mockImplementation((...args) => void errors.push(args.join(' ')))

createSSRApp(Comp).mount(container)
await nextTick()
spy.mockRestore()

expect(errors.filter((e) => /hydration/i.test(e))).toEqual([])
expect(container.querySelector('form')?.id).toBe(serverId)
})
})