From 370716142834a1b08b18e6fa6ef6756180efd09a Mon Sep 17 00:00:00 2001 From: harshit-d3v Date: Wed, 29 Jul 2026 12:29:03 +0530 Subject: [PATCH 1/2] fix(vue-form): generate an SSR-safe default formId FormApi falls back to `uuid()` when no formId is supplied, so the Vue adapter produced a different id on the server than on the client and binding it (e.g. `
`) caused a hydration mismatch. Use Vue's `useId` for the default instead, mirroring what react-form and preact-form already do. `useId` only exists in vue@3.5 and this package supports vue@^3.4.0, so it is read off the namespace and falls back to `uuid()` when unavailable -- and likewise outside a component instance, where `useId` cannot produce a stable value. Fixes #2254. --- .changeset/lucky-donkeys-shout.md | 7 ++++ packages/vue-form/src/useForm.tsx | 5 ++- packages/vue-form/src/useFormId.ts | 24 +++++++++++ packages/vue-form/tests/useFormId.test.tsx | 49 ++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 .changeset/lucky-donkeys-shout.md create mode 100644 packages/vue-form/src/useFormId.ts create mode 100644 packages/vue-form/tests/useFormId.test.tsx diff --git a/.changeset/lucky-donkeys-shout.md b/.changeset/lucky-donkeys-shout.md new file mode 100644 index 000000000..8f655643e --- /dev/null +++ b/.changeset/lucky-donkeys-shout.md @@ -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 diff --git a/packages/vue-form/src/useForm.tsx b/packages/vue-form/src/useForm.tsx index 43b434779..fcd3fbd9d 100644 --- a/packages/vue-form/src/useForm.tsx +++ b/packages/vue-form/src/useForm.tsx @@ -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, @@ -268,6 +269,8 @@ export function useForm< TSubmitMeta >, ) { + const fallbackFormId = useFormId() + const formApi = (() => { const api = new FormApi< TParentData, @@ -282,7 +285,7 @@ export function useForm< TFormOnDynamicAsync, TFormOnServer, TSubmitMeta - >(opts) + >({ ...opts, formId: opts?.formId ?? fallbackFormId }) const extendedApi: typeof api & VueFormApi< diff --git a/packages/vue-form/src/useFormId.ts b/packages/vue-form/src/useFormId.ts new file mode 100644 index 000000000..d7dc75a53 --- /dev/null +++ b/packages/vue-form/src/useFormId.ts @@ -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() +} diff --git a/packages/vue-form/tests/useFormId.test.tsx b/packages/vue-form/tests/useFormId.test.tsx new file mode 100644 index 000000000..26327cb50 --- /dev/null +++ b/packages/vue-form/tests/useFormId.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { render } from '@testing-library/vue' +import { createSSRApp, defineComponent, h } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { useForm } from '../src' + +const Comp = defineComponent(() => { + const form = useForm({ defaultValues: { firstName: '' } }) + + return () => +}) + +describe('useFormId', () => { + it('uses the provided formId when one is given', () => { + const WithId = defineComponent(() => { + const form = useForm({ + defaultValues: { firstName: '' }, + formId: 'my-form', + }) + + return () => + }) + + 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) + }) +}) From f3474c79beb28839b7a83e858e56c13e977d8eaa Mon Sep 17 00:00:00 2001 From: harshit-d3v Date: Wed, 29 Jul 2026 12:42:54 +0530 Subject: [PATCH 2/2] test(vue-form): assert real hydration, not just matching ids The server/client case compared SSR html against a fresh client render, which never exercises hydration itself. Mount the server markup with `createSSRApp().mount()` and assert Vue raises no hydration mismatch. --- packages/vue-form/tests/useFormId.test.tsx | 26 ++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/vue-form/tests/useFormId.test.tsx b/packages/vue-form/tests/useFormId.test.tsx index 26327cb50..43e946078 100644 --- a/packages/vue-form/tests/useFormId.test.tsx +++ b/packages/vue-form/tests/useFormId.test.tsx @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { render } from '@testing-library/vue' -import { createSSRApp, defineComponent, h } from 'vue' +import { createSSRApp, defineComponent, h, nextTick } from 'vue' import { renderToString } from 'vue/server-renderer' import { useForm } from '../src' @@ -46,4 +46,26 @@ describe('useFormId', () => { 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 = [] + 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) + }) })