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..43e946078 --- /dev/null +++ b/packages/vue-form/tests/useFormId.test.tsx @@ -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 () =>
+}) + +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) + }) + + 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) + }) +})