fix(vue-form): generate an SSR-safe default formId - #2259
Conversation
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. `<form :id="form.formId">`) 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 TanStack#2254.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughVue form defaults now use a component-aware, SSR-safe identifier composable with UUID fallback. ChangesVue form identifier flow
Estimated code review effort: 2 (Simple) | ~10 minutes Sequence Diagram(s)sequenceDiagram
participant VueComponent
participant useForm
participant useFormId
participant FormApi
VueComponent->>useForm: create form
useForm->>useFormId: request default formId
useFormId->>VueComponent: use Vue useId or UUID fallback
useForm->>FormApi: initialize with formId
FormApi-->>VueComponent: expose form.formId
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/vue-form/tests/useFormId.test.tsx (1)
39-47: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExercise actual hydration, not only independent renders.
This compares SSR HTML with a new client render, but never mounts a client app onto the server markup. Hydrate the server-rendered container and assert the ID remains unchanged without hydration warnings.
🤖 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/tests/useFormId.test.tsx` around lines 39 - 47, Update the test around the SSR render and client ID assertion to hydrate a Vue app onto the server-rendered markup instead of independently calling render(Comp). Mount the client app with hydration enabled using the existing server HTML container, capture or assert that no hydration warnings occur, and verify the hydrated form element retains the server-generated ID.
🤖 Prompt for all review comments with 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.
Inline comments:
In `@packages/vue-form/src/useFormId.ts`:
- Around line 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.
---
Nitpick comments:
In `@packages/vue-form/tests/useFormId.test.tsx`:
- Around line 39-47: Update the test around the SSR render and client ID
assertion to hydrate a Vue app onto the server-rendered markup instead of
independently calling render(Comp). Mount the client app with hydration enabled
using the existing server HTML container, capture or assert that no hydration
warnings occur, and verify the hydrated form element retains the
server-generated ID.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 9f6168d0-4522-4573-8a99-8b17fdc2589f
📒 Files selected for processing (4)
.changeset/lucky-donkeys-shout.mdpackages/vue-form/src/useForm.tsxpackages/vue-form/src/useFormId.tspackages/vue-form/tests/useFormId.test.tsx
| export function useFormId(): string { | ||
| const useId = vueUseId.useId | ||
| if (useId && Vue.getCurrentInstance()) { | ||
| return useId() | ||
| } | ||
| return uuid() |
There was a problem hiding this comment.
🎯 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:
- 1: https://github.com/vuejs/blog/blob/78b4193b/posts/vue-3-5.md
- 2: https://vue-land.github.io/faq/unique-element-ids
- 3: https://blog.vuejs.org/posts/vue-3-5
- 4: export 'useId' (imported as 'e') was not found in 'vue' tailwindlabs/headlessui#3474
🌐 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:
- 1: https://blog.vuejs.org/posts/vue-3-5
- 2: https://vue-land.github.io/faq/unique-element-ids
- 3: https://blog.ninja-squad.com/2024/09/05/what-is-new-vue-3.5
- 4: feat(runtime-core): useId() vuejs/core#11404
- 5:
onServerPrefetchcausing unstable ids fromuseId, leading to hydration mismatches vuejs/core#12102 - 6: onServerPrefetch with
useIdcause hydration issue if treeshaken client-side vuejs/core#12591
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.
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.
|
Thanks — both points addressed. On the hydration test (nitpick): fair, the test compared SSR html against a fresh client render and never actually hydrated. On vue@3.4 still producing mismatched ids: that's accurate, and it is a deliberate limitation rather than something the patch can solve. 3.4 has no The alternative is bumping |
🎯 Changes
Fixes #2254.
FormApifalls back touuid()when noformIdis passed, and the Vue adapter never passes one — so the id differs between the server render and the client render, and binding it (<form :id="form.formId">) produces a hydration mismatch.This uses Vue's
useIdfor the default instead, which is exactly what the other adapters already do:react-form/src/useFormId.ts—React.useId, falling back touseUUIDon React 17preact-form/src/useFormId.ts—useIdfrompreact/hooksVue had no equivalent, so
packages/vue-form/src/useFormId.tsadds one anduseFormnow passesformId: opts?.formId ?? fallbackFormId, mirroringreact-form/src/useForm.tsx.The version constraint
useIdwas added in vue@3.5, but@tanstack/vue-formdeclarespeerDependencies: { "vue": "^3.4.0" }. So a straightimport { useId } from 'vue'would break 3.4 consumers — and a named import of a missing export is a bundler resolution error, not a runtimeundefined. It is therefore read off the namespace and typed as optional, with the same reasoning as the existing React comment (webpack/webpack#14814):The
getCurrentInstance()guard matters too: outside a component instanceuseIdcannot produce a stable value and warns in dev, souseFormcalled outsidesetupkeeps the old uuid behaviour rather than emitting a warning.If you would rather bump the peer range to
^3.5.0and calluseIdunconditionally, happy to switch — this way keeps 3.4 working.✅ Checklist
pnpm test:pr.🚀 Release Impact
Verification
packages/vue-form/tests/useFormId.test.tsxcovers this. Run againstmainwith only the test applied (i.e. revertinguseForm.tsx), it fails on the two default-id cases while the explicit-id control still passes:formIdwhen one is givenformIdacross identical renders2902fb86-…vs752902fb-…formIdmatches between server and clientfb8639db-…vs02fb8639-…The server/client case renders through
renderToStringfromvue/server-renderer(reached via thevuesubpath export, so it adds no new dependency) and compares against a client render.Also run locally:
pnpm test:pr→ passes (test:sherif,test:knip,test:docs,test:eslint,test:lib,test:types,test:build,build)@tanstack/vue-formsuite → 31 passed, no type errorsDisclosure: I used an AI assistant while investigating and preparing this change, including running the verification above.
Summary by CodeRabbit
useFormIdcomposable for generating SSR-safe, stable form identifiers.useFormnow defaults to an auto-generatedformIdwhen none is provided.useIdisn’t available (including non-component usage).