Conversation
commit: |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
✅ Files skipped from review due to trivial changes (1)
📝 WalkthroughWalkthroughAdds a Field and Form subsystem (components, contexts, composables, validation, tests, and exports), labelable-provider utilities, docs/demos (CSS & Tailwind), docs site updates and dependency, a Vue peer-dep bump, and a small SSR guard. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant Form as Form Component
participant FormCtx as FormContext
participant Field as FieldRoot
participant Validation as useFieldValidation
User->>Form: submit()
Form->>Form: handleSubmit()
Form->>FormCtx: iterate registered fields
FormCtx->>Field: call validate()
Field->>Validation: commit(value, revalidate)
Validation-->>Field: validity data (state, errors)
Field->>FormCtx: update field validity & errors
alt no errors
Form->>User: emit formSubmit with values
else errors present
Form->>Form: focus first invalid field and render errors
end
sequenceDiagram
participant App
participant LabelProv as LabelableProvider
participant FieldRoot as FieldRoot
participant Control as FieldControl
participant Label as FieldLabel
participant Error as FieldError
App->>LabelProv: mount provider
LabelProv->>FieldRoot: provide labelable context
FieldRoot->>Control: mount & set controlId
Control->>LabelProv: set controlId
Label->>LabelProv: set labelId
LabelProv-->>Control: computed aria-labelledby
Error->>LabelProv: register messageId
LabelProv-->>Control: aria-describedby (messageIds)
Control->>FieldRoot: input/blur -> trigger validation commit
FieldRoot->>Error: provide error messages when invalid
Error-->>App: display error
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (9)
packages/core/src/field/validity/FieldValidity.vue (1)
27-31: Consider excludingstatefrom the spread to match the declared type.The
FieldValidityStateinterface omitsstatefromFieldValidityData, but the spread ofcombinedFieldValidityDataincludes it at runtime. While TypeScript hides thestateproperty from consumers, the runtime object will have bothstateandvalidity(with identical values).♻️ Proposed fix to ensure runtime matches type
const fieldValidityState = computed<FieldValidityState>(() => ({ - ...combinedFieldValidityData.value, + error: combinedFieldValidityData.value.error, + errors: combinedFieldValidityData.value.errors, + value: combinedFieldValidityData.value.value, + initialValue: combinedFieldValidityData.value.initialValue, validity: combinedFieldValidityData.value.state, transitionStatus: transitionStatus.value, }))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/core/src/field/validity/FieldValidity.vue` around lines 27 - 31, fieldValidityState is currently created by spreading combinedFieldValidityData.value which at runtime still includes the `state` property even though FieldValidityState expects `validity` and not `state`; update the construction in the computed to omit `state` from the spread (e.g., destructure combinedFieldValidityData.value to remove `state` and spread the rest), then set `validity` explicitly from the removed `state` so the runtime object matches the FieldValidityState shape (refer to fieldValidityState, combinedFieldValidityData, FieldValidityState, and FieldValidityData).packages/core/src/field/root/FieldRoot.test.ts (1)
663-695: Missing blank line beforedescribeblock.Minor formatting nit: Line 663 should have a blank line before it to separate from the previous
describeblock, consistent with the rest of the file.✨ Proposed fix
}) }) + describe('component ref', () => {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/core/src/field/root/FieldRoot.test.ts` around lines 663 - 695, Add a blank line before the describe('component ref', ...) block to match the file's formatting convention; locate the describe block in FieldRoot.test.ts (the block that contains the "validates the field when the `validate` action is called" it test and references createApp, fieldRef, handleValidate) and insert one empty line above it so it is separated from the previous describe block.packages/core/src/labelable-provider/useAriaLabelledBy.ts (2)
62-76: Side effect in getter function may cause unexpected behavior.
getAriaLabelledBymutates the DOM by assigninglabel.id(line 72), which is unexpected for a function prefixed with "get". This side effect could cause issues if called multiple times or in SSR contexts.Consider extracting the ID assignment into the calling
watchEffector renaming the function to reflect its mutating behavior (e.g.,resolveOrAssignLabelId).♻️ Proposed refactor to separate concerns
-function getAriaLabelledBy( +function resolveAriaLabelledBy( labelSource?: LabelSource | null, generatedLabelId?: string, ): string | undefined { const label = findAssociatedLabel(labelSource) if (!label) { return undefined } if (!label.id && generatedLabelId) { label.id = generatedLabelId } return label.id || undefined }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/core/src/labelable-provider/useAriaLabelledBy.ts` around lines 62 - 76, The function getAriaLabelledBy mutates DOM state (assigns label.id) which is unexpected for a getter; remove the side effect from getAriaLabelledBy (keep it pure: return label.id || generatedLabelId || undefined) and move the assignment of generatedLabelId to the caller (the watchEffect that calls getAriaLabelledBy) where you can set label.id = generatedLabelId if needed; alternatively, if you prefer keeping current behavior, rename getAriaLabelledBy to a mutating name like resolveOrAssignLabelId to signal the side effect; use the existing helper findAssociatedLabel and the generatedLabelId parameter to locate the label and perform the assignment in the watchEffect or rename the function accordingly.
88-93: Next-sibling label lookup only checks immediate sibling.The fallback at lines 90-93 only checks
nextElementSibling. If the<label>is not immediately adjacent (e.g., separated by a wrapper or other elements), it won't be found. This may be intentional for simplicity, but consider documenting this limitation.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/core/src/labelable-provider/useAriaLabelledBy.ts` around lines 88 - 93, The current fallback in useAriaLabelledBy only checks the immediate nextElementSibling (via labelSource and controlId) so a nearby <label> separated by wrappers is missed; update the logic in useAriaLabelledBy to walk subsequent siblings (looping over nextElementSibling) and return the first element that is an HTMLLabelElement whose htmlFor equals controlId (or alternatively use parentElement.querySelector(`label[for="${controlId}"]`) if broader search is desired); update the references to labelSource, controlId and nextSibling accordingly and add a brief comment documenting the behavior.packages/core/src/field/error/FieldError.vue (1)
73-90: Potential redundant cleanup inonUnmounted.The
watchat lines 73-84 removes the ID whenrenderedbecomesfalse, andonUnmountedat lines 86-90 also removes the ID. This means if the component is unmounted whilerenderedisfalse, the ID is filtered twice.While harmless (filtering a non-existent ID is a no-op), the
onUnmountedcleanup is only necessary if the component unmounts whilerenderedistrue. Consider adding a guard:♻️ Proposed guard in onUnmounted
onUnmounted(() => { - if (id) { + if (id && rendered.value) { setMessageIds(ids => ids.filter(item => item !== id)) } })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/core/src/field/error/FieldError.vue` around lines 73 - 90, The onUnmounted cleanup is redundant when rendered is already false; change the onUnmounted handler to only remove the id if both id and rendered.value are truthy. Update the onUnmounted callback (which currently calls setMessageIds(ids => ids.filter(...))) to guard with rendered.value (and id) before calling setMessageIds so it only runs when the message was still considered rendered; keep the existing watch and setMessageIds logic unchanged.packages/core/src/form/FormContext.ts (1)
49-55: Default context uses mock refs - ensure read-only usage.The
defaultContextuses plain objects cast asReftypes (lines 50-54). This works for read-only fallback scenarios but will silently fail if any consuming code attempts to mutate these values (e.g.,submitAttempted.value = true).This is a common pattern for default injection values, but consider adding a comment to clarify that these are intentionally non-reactive fallbacks for when no
FormContextis provided.📝 Proposed documentation
+/** + * Default context used when no Form provider is present. + * These are non-reactive stubs; writes will have no effect. + */ const defaultContext: FormContext = { errors: { value: EMPTY_OBJECT as FormErrors } as Ref<FormErrors>,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/core/src/form/FormContext.ts` around lines 49 - 55, The defaultContext object uses plain objects cast to Ref/ShallowRef types (defaultContext with fields errors, clearErrors, formRef, validationMode, submitAttempted) which are non-reactive and will break if mutated; add a clear inline comment above defaultContext stating these are intentional read-only, non-reactive fallbacks for injection consumers and warn that consumers must not assign to .value (or alternatively replace these with proper readonly refs if mutation is required); update the comment to mention the specific symbols (defaultContext, errors, clearErrors, formRef, validationMode, submitAttempted) so future maintainers know the intended read-only usage.packages/core/src/field/control/FieldControl.vue (1)
105-113:labelSourceIdis not reactive.Passing
controlId.valueaslabelSourceIdcaptures the value at call time. IfcontrolIdcould change after mount (e.g., ifprops.idchanges),useAriaLabelledBywon't receive the updated value.If
controlIdis guaranteed to be stable after mount, this is acceptable. Otherwise, consider passing the computed ref or makinglabelSourceIdreactive in the hook options.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/core/src/field/control/FieldControl.vue` around lines 105 - 113, The current call to useAriaLabelledBy captures a non-reactive snapshot by passing controlId.value into labelSourceId; change it to pass a reactive ref (e.g., controlId or a computed wrapper) so useAriaLabelledBy receives updates when controlId changes. Update the call site where labelSourceId is set (the useAriaLabelledBy invocation referencing controlId.value) to provide a ref/computed instead, keeping the other args (ariaLabelledByAttr, labelId, inputElementRef, enableFallback) unchanged.packages/core/src/form/Form.vue (1)
12-12: Empty interface serves no purpose.As flagged by static analysis,
FormStateis equivalent to{}. If this is a placeholder for future state properties, consider adding a TODO comment or defining at least one property. Otherwise, remove it.♻️ Option 1: Add TODO comment
-export interface FormState {} +// TODO: Add form-level state properties (e.g., submitting, validating) +export interface FormState {}♻️ Option 2: Remove if unused
If
FormStateis not consumed anywhere, consider removing it entirely and usingRecord<string, never>orobjectwhere needed.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/core/src/form/Form.vue` at line 12, Remove or clarify the empty FormState interface: either delete the export interface FormState {} and replace its usages with Record<string, never> or object where appropriate, or keep it but add a TODO comment and at least one placeholder property (e.g., // TODO: add form state fields) so it's not equivalent to {}. Update any references to FormState in the codebase (components, props, or types) to the chosen alternative (Record<string, never>/object or the new placeholder property) and ensure type imports/exports that referenced FormState are adjusted accordingly.docs/content/docs/components/form.md (1)
31-31: Minor: Consider using "third-party" with hyphen.For grammatical correctness when used as a compound adjective, "third-party APIs" is preferred over "3rd party APIs".
📝 Suggested fix
-You can use the `@form-submit` event instead of the native `submit` event to access form values as a JavaScript object. This is useful when you need to transform the values before submission, or integrate with 3rd party APIs. When used, `preventDefault` is called on the native submit event. +You can use the `@form-submit` event instead of the native `submit` event to access form values as a JavaScript object. This is useful when you need to transform the values before submission, or integrate with third-party APIs. When used, `preventDefault` is called on the native submit event.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/content/docs/components/form.md` at line 31, Update the documentation text that references "3rd party APIs" to read "third-party APIs" (hyphenated) for grammatical correctness; the reference occurs in the sentence describing the `@form-submit` event so edit that sentence to replace "3rd party APIs" with "third-party APIs".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/components/demo/FormZod/css/index.vue`:
- Around line 14-17: The schema currently lets whitespace-only names and empty
age strings slip through; update the schema to preprocess inputs: wrap the name
field with z.preprocess to trim whitespace before validating with
z.string().min(1, ...), and wrap the age field with z.preprocess that trims the
input and returns undefined for empty/whitespace-only strings before passing to
z.coerce.number().positive(...); this ensures blank/whitespace values are
normalized to missing and trigger the correct validation errors for the existing
symbols schema, name, and age.
In `@docs/components/demo/FormZod/tailwind/index.vue`:
- Line 15: The z.coerce.number() call used for the age schema is using the old
API by passing a string; update the call to use the Zod v4 options object form
so the error message is provided as { error: 'Age must be a number' } (e.g.,
change the age schema where z.coerce.number('Age must be a number') is used to
z.coerce.number({ error: 'Age must be a number' }) and keep the .positive('Age
must be a positive number') as-is).
In `@packages/core/package.json`:
- Line 51: You changed the peerDependency in package.json from "vue": ">= 3.2.0"
to "vue": ">= 3.5.0" because new components use Vue's useId() via useLabelableId
and useAriaLabelledBy; verify and document this breaking requirement: if we
intend to require Vue 3.5+ for SSR-safe ID generation, keep the peerDependency
at ">= 3.5.0" and add a clear note to the PR description and changelog stating
SSR support now requires Vue 3.5+ (mention
useId/useLabelableId/useAriaLabelledBy); otherwise, revert package.json back to
">= 3.2.0" and modify useLabelableId/useAriaLabelledBy to detect availability of
useId at runtime and use an SSR-safe fallback for server renders to preserve
compatibility with Vue 3.2–3.4.
In `@packages/core/src/field/control/FieldControl.vue`:
- Around line 130-141: The watchEffect for inputElementRef currently only clears
filled for controlled inputs; update the logic inside the watchEffect (the block
referencing inputElementRef, el.value, isControlled, props.value, and setFilled)
so that when el.value is an empty string it calls setFilled(false) regardless of
isControlled, e.g. add an explicit else branch that sets setFilled(false) when
!el.value; keep existing handling for controlled inputs (props.value === '')
intact.
In `@packages/core/src/field/error/FieldError.vue`:
- Line 50: The current id is a static string from useBaseUiId(props.id) and
won't update when props.id changes; wrap it in a computed (i.e. make id a
reactive computed that calls useBaseUiId(props.id)) and update any direct usages
to reference the reactive value (e.g., use id.value where necessary) so the
watch/registration logic (the code that registers/unregisters message IDs) sees
the updated ID; also add an import for computed from 'vue' if it's not already
imported.
In `@packages/core/src/field/root/useFieldValidation.ts`:
- Around line 11-27: isOnlyValueMissing incorrectly resets onlyValueMissing when
it processes the 'valueMissing' key because the subsequent generic check
re-evaluates the same key; update the loop in isOnlyValueMissing to handle
'valueMissing' separately and skip the generic truth check for that key (e.g.
when key === 'valueMissing' set onlyValueMissing = state[key] and continue),
keep skipping 'valid', and for other keys if state[key] is true set
onlyValueMissing = false and break early; reference isOnlyValueMissing,
validityKeys, ValidityState, and the state parameter.
In `@packages/core/src/field/useField.ts`:
- Around line 30-37: The code treats validityData.value.initialValue === null as
"not initialized", which conflates a real null initial value with an
uninitialized state; change the validity tracking to use an explicit
initialization flag (e.g., validityData.value.initialized or
initialValueInitialized) rather than relying on null. Update the logic around
initialValue, getValue(), and setValidityData so you always record the true
initialValue (including null) on first initialization and set the new flag to
true, and replace any checks of validityData.value.initialValue === null with
checks of the new initialized flag wherever initialization state is needed.
---
Nitpick comments:
In `@docs/content/docs/components/form.md`:
- Line 31: Update the documentation text that references "3rd party APIs" to
read "third-party APIs" (hyphenated) for grammatical correctness; the reference
occurs in the sentence describing the `@form-submit` event so edit that sentence
to replace "3rd party APIs" with "third-party APIs".
In `@packages/core/src/field/control/FieldControl.vue`:
- Around line 105-113: The current call to useAriaLabelledBy captures a
non-reactive snapshot by passing controlId.value into labelSourceId; change it
to pass a reactive ref (e.g., controlId or a computed wrapper) so
useAriaLabelledBy receives updates when controlId changes. Update the call site
where labelSourceId is set (the useAriaLabelledBy invocation referencing
controlId.value) to provide a ref/computed instead, keeping the other args
(ariaLabelledByAttr, labelId, inputElementRef, enableFallback) unchanged.
In `@packages/core/src/field/error/FieldError.vue`:
- Around line 73-90: The onUnmounted cleanup is redundant when rendered is
already false; change the onUnmounted handler to only remove the id if both id
and rendered.value are truthy. Update the onUnmounted callback (which currently
calls setMessageIds(ids => ids.filter(...))) to guard with rendered.value (and
id) before calling setMessageIds so it only runs when the message was still
considered rendered; keep the existing watch and setMessageIds logic unchanged.
In `@packages/core/src/field/root/FieldRoot.test.ts`:
- Around line 663-695: Add a blank line before the describe('component ref',
...) block to match the file's formatting convention; locate the describe block
in FieldRoot.test.ts (the block that contains the "validates the field when the
`validate` action is called" it test and references createApp, fieldRef,
handleValidate) and insert one empty line above it so it is separated from the
previous describe block.
In `@packages/core/src/field/validity/FieldValidity.vue`:
- Around line 27-31: fieldValidityState is currently created by spreading
combinedFieldValidityData.value which at runtime still includes the `state`
property even though FieldValidityState expects `validity` and not `state`;
update the construction in the computed to omit `state` from the spread (e.g.,
destructure combinedFieldValidityData.value to remove `state` and spread the
rest), then set `validity` explicitly from the removed `state` so the runtime
object matches the FieldValidityState shape (refer to fieldValidityState,
combinedFieldValidityData, FieldValidityState, and FieldValidityData).
In `@packages/core/src/form/Form.vue`:
- Line 12: Remove or clarify the empty FormState interface: either delete the
export interface FormState {} and replace its usages with Record<string, never>
or object where appropriate, or keep it but add a TODO comment and at least one
placeholder property (e.g., // TODO: add form state fields) so it's not
equivalent to {}. Update any references to FormState in the codebase
(components, props, or types) to the chosen alternative (Record<string,
never>/object or the new placeholder property) and ensure type imports/exports
that referenced FormState are adjusted accordingly.
In `@packages/core/src/form/FormContext.ts`:
- Around line 49-55: The defaultContext object uses plain objects cast to
Ref/ShallowRef types (defaultContext with fields errors, clearErrors, formRef,
validationMode, submitAttempted) which are non-reactive and will break if
mutated; add a clear inline comment above defaultContext stating these are
intentional read-only, non-reactive fallbacks for injection consumers and warn
that consumers must not assign to .value (or alternatively replace these with
proper readonly refs if mutation is required); update the comment to mention the
specific symbols (defaultContext, errors, clearErrors, formRef, validationMode,
submitAttempted) so future maintainers know the intended read-only usage.
In `@packages/core/src/labelable-provider/useAriaLabelledBy.ts`:
- Around line 62-76: The function getAriaLabelledBy mutates DOM state (assigns
label.id) which is unexpected for a getter; remove the side effect from
getAriaLabelledBy (keep it pure: return label.id || generatedLabelId ||
undefined) and move the assignment of generatedLabelId to the caller (the
watchEffect that calls getAriaLabelledBy) where you can set label.id =
generatedLabelId if needed; alternatively, if you prefer keeping current
behavior, rename getAriaLabelledBy to a mutating name like
resolveOrAssignLabelId to signal the side effect; use the existing helper
findAssociatedLabel and the generatedLabelId parameter to locate the label and
perform the assignment in the watchEffect or rename the function accordingly.
- Around line 88-93: The current fallback in useAriaLabelledBy only checks the
immediate nextElementSibling (via labelSource and controlId) so a nearby <label>
separated by wrappers is missed; update the logic in useAriaLabelledBy to walk
subsequent siblings (looping over nextElementSibling) and return the first
element that is an HTMLLabelElement whose htmlFor equals controlId (or
alternatively use parentElement.querySelector(`label[for="${controlId}"]`) if
broader search is desired); update the references to labelSource, controlId and
nextSibling accordingly and add a brief comment documenting the behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 799dafc8-b4f7-446e-8caa-32ea14232700
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (55)
AGENTS.mddocs/.vitepress/config.tsdocs/.vitepress/theme/style.cssdocs/components/demo/Field/css/index.vuedocs/components/demo/Field/css/styles.cssdocs/components/demo/Field/tailwind/index.vuedocs/components/demo/Form/css/index.vuedocs/components/demo/Form/css/styles.cssdocs/components/demo/Form/tailwind/index.vuedocs/components/demo/FormZod/css/index.vuedocs/components/demo/FormZod/css/styles.cssdocs/components/demo/FormZod/tailwind/index.vuedocs/content/docs/components/field.mddocs/content/docs/components/form.mddocs/package.jsonpackages/core/package.jsonpackages/core/src/avatar/image/useImageLoadingStatus.tspackages/core/src/field/control/FieldControl.test.tspackages/core/src/field/control/FieldControl.vuepackages/core/src/field/control/FieldControlDataAttributes.tspackages/core/src/field/description/FieldDescription.test.tspackages/core/src/field/description/FieldDescription.vuepackages/core/src/field/description/FieldDescriptionDataAttributes.tspackages/core/src/field/error/FieldError.test.tspackages/core/src/field/error/FieldError.vuepackages/core/src/field/error/FieldErrorDataAttributes.tspackages/core/src/field/index.tspackages/core/src/field/item/FieldItem.test.tspackages/core/src/field/item/FieldItem.vuepackages/core/src/field/item/FieldItemContext.tspackages/core/src/field/label/FieldLabel.test.tspackages/core/src/field/label/FieldLabel.vuepackages/core/src/field/label/FieldLabelDataAttributes.tspackages/core/src/field/root/FieldRoot.test.tspackages/core/src/field/root/FieldRoot.vuepackages/core/src/field/root/FieldRootContext.tspackages/core/src/field/root/FieldRootDataAttributes.tspackages/core/src/field/root/useFieldRootState.tspackages/core/src/field/root/useFieldValidation.tspackages/core/src/field/root/useFieldValidity.tspackages/core/src/field/useField.tspackages/core/src/field/utils/constants.tspackages/core/src/field/utils/getCombinedFieldValidityData.tspackages/core/src/field/validity/FieldValidity.test.tspackages/core/src/field/validity/FieldValidity.vuepackages/core/src/form/Form.test.tspackages/core/src/form/Form.vuepackages/core/src/form/FormContext.tspackages/core/src/form/index.tspackages/core/src/index.tspackages/core/src/labelable-provider/LabelableContext.tspackages/core/src/labelable-provider/LabelableProvider.vuepackages/core/src/labelable-provider/index.tspackages/core/src/labelable-provider/useAriaLabelledBy.tspackages/core/src/labelable-provider/useLabelableId.ts
| const schema = z.object({ | ||
| name: z.string().min(1, 'Name is required'), | ||
| age: z.coerce.number('Age must be a number').positive('Age must be a positive number'), | ||
| }) |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Zod 4, what is the recommended pattern for rejecting empty strings before z.coerce.number()so blank form fields do not coerce to0?
💡 Result:
Use a z.preprocess step that turns a blank form value ("") into undefined before coercion, then coerce/validate the number:
import { z } from "zod";
const formNumber = z.preprocess(
(v) => (v === "" ? undefined : v),
z.coerce.number({
required_error: "Required",
invalid_type_error: "Must be a number",
}),
);
// optional field version:
// const formNumberOptional = z.preprocess((v) => (v === "" ? undefined : v), z.coerce.number()).optional();This prevents Number("") → 0 and lets “blank” behave like “missing”. [1][2]
Normalize form field values before schema validation.
z.coerce.number() converts empty strings to 0 via Number(input), causing blank age fields to incorrectly pass validation with a positive-number error instead of a missing-value error. Similarly, whitespace-only names still satisfy min(1) without trimming. Use z.preprocess() to strip and normalize incoming strings before coercion:
💡 Example fix
const schema = z.object({
- name: z.string().min(1, 'Name is required'),
- age: z.coerce.number('Age must be a number').positive('Age must be a positive number'),
+ name: z.string().trim().min(1, 'Name is required'),
+ age: z.preprocess(
+ (value) => typeof value === 'string' && value.trim() === '' ? undefined : value,
+ z.coerce
+ .number({
+ error: (issue) =>
+ issue.input === undefined ? 'Age is required' : 'Age must be a number',
+ })
+ .positive('Age must be a positive number'),
+ ),
})🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/components/demo/FormZod/css/index.vue` around lines 14 - 17, The schema
currently lets whitespace-only names and empty age strings slip through; update
the schema to preprocess inputs: wrap the name field with z.preprocess to trim
whitespace before validating with z.string().min(1, ...), and wrap the age field
with z.preprocess that trims the input and returns undefined for
empty/whitespace-only strings before passing to z.coerce.number().positive(...);
this ensures blank/whitespace values are normalized to missing and trigger the
correct validation errors for the existing symbols schema, name, and age.
|
|
||
| const schema = z.object({ | ||
| name: z.string().min(1, 'Name is required'), | ||
| age: z.coerce.number('Age must be a number').positive('Age must be a positive number'), |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Zod 4 z.coerce.number error message customization
💡 Result:
In Zod v4, invalid_type_error / required_error were removed; you customize messages with the unified error parameter (string or function) on the schema (or per-parse). [1], [2]
Custom message for z.coerce.number()
import { z } from "zod";
const num = z.coerce.number({ error: "Must be a number" });Zod v4: all schema constructors accept custom errors like this. [1]
Different messages for “missing” vs “not a number”
const num = z.coerce.number({
error: (iss) =>
iss.input === undefined ? "This field is required" : "Must be a number",
});Zod’s error function receives an issue/context object (including iss.code, iss.input, etc.). [1], [2]
Per-parse override (optional)
num.parse(value, { error: () => "Invalid number" });Per-parse error maps exist and have lower precedence than schema-level ones. [1]
🏁 Script executed:
# Check the actual file content
cat docs/components/demo/FormZod/tailwind/index.vueRepository: vuepont/base-ui-vue
Length of output: 2658
🏁 Script executed:
# Check Zod version in dependencies
find . -name "package.json" -o -name "package-lock.json" | head -5 | xargs grep -l "zod" 2>/dev/nullRepository: vuepont/base-ui-vue
Length of output: 82
🏁 Script executed:
# Read package.json to find Zod version
cat package.json | grep -A 5 -B 5 "zod"Repository: vuepont/base-ui-vue
Length of output: 45
🏁 Script executed:
# Check docs package.json for Zod version
cat docs/package.json | head -50Repository: vuepont/base-ui-vue
Length of output: 542
🏁 Script executed:
# Also check root package.json for Zod version
cat package.json | grep -A 2 '"zod"'Repository: vuepont/base-ui-vue
Length of output: 45
🏁 Script executed:
# Check if z.flattenError is a valid Zod v4 API
rg "flattenError" docs/components/demo/FormZod/tailwind/index.vueRepository: vuepont/base-ui-vue
Length of output: 118
🏁 Script executed:
# Search for Zod usage patterns in the codebase to see how z.flattenError is used
rg "flattenError|\.flatten\(\)" --type ts --type vueRepository: vuepont/base-ui-vue
Length of output: 90
🌐 Web query:
Zod v4 z.flattenError API documentation
💡 Result:
In Zod v4, z.flattenError() is the documented replacement for the deprecated ZodError.prototype.flatten().
- Docs (error formatting):
z.flattenError()returns a shallow object withformErrors(top-level issues wherepathis[]) andfieldErrors(arrays of messages keyed by field). Example output shape:{ formErrors: string[]; fieldErrors: Record<string, string[]> }(conceptually) [1]
- Migration note:
ZodError#flatten()is deprecated in v4; use the top-levelz.flattenError()/ related helpers instead [2]
Sources: [1] https://zod.dev/error-formatting, [2] https://zod.dev/v4/changelog
🏁 Script executed:
# Search for flattenError usage in TypeScript/JavaScript files
rg "flattenError" --type ts --type jsRepository: vuepont/base-ui-vue
Length of output: 265
Incorrect Zod v4 API syntax for z.coerce.number().
z.coerce.number() does not accept a string argument. In Zod v4, use the { error: 'message' } parameter to customize error messages:
Proposed fix
const schema = z.object({
name: z.string().min(1, 'Name is required'),
- age: z.coerce.number('Age must be a number').positive('Age must be a positive number'),
+ age: z.coerce.number({ error: 'Age must be a number' }).positive('Age must be a positive number'),
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| age: z.coerce.number('Age must be a number').positive('Age must be a positive number'), | |
| const schema = z.object({ | |
| name: z.string().min(1, 'Name is required'), | |
| age: z.coerce.number({ error: 'Age must be a number' }).positive('Age must be a positive number'), | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/components/demo/FormZod/tailwind/index.vue` at line 15, The
z.coerce.number() call used for the age schema is using the old API by passing a
string; update the call to use the Zod v4 options object form so the error
message is provided as { error: 'Age must be a number' } (e.g., change the age
schema where z.coerce.number('Age must be a number') is used to
z.coerce.number({ error: 'Age must be a number' }) and keep the .positive('Age
must be a positive number') as-is).
| }, | ||
| "peerDependencies": { | ||
| "vue": ">= 3.2.0" | ||
| "vue": ">= 3.5.0" |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Search for Vue 3.5+ specific APIs in the codebase to verify the peer dependency bump is warranted.
# Expected: Find usages of Vue 3.5+ features like useId, useTemplateRef, or other 3.5+ APIs.
echo "=== Searching for useId (Vue 3.5+) ==="
rg -n 'useId' --type vue --type ts -g '!node_modules'
echo ""
echo "=== Searching for useTemplateRef (Vue 3.5+) ==="
rg -n 'useTemplateRef' --type vue --type ts -g '!node_modules'
echo ""
echo "=== Searching for defineModel with get/set (Vue 3.4+ enhanced) ==="
rg -n 'defineModel' --type vue --type ts -g '!node_modules' -A 2
echo ""
echo "=== Checking imports from vue in new field/form components ==="
rg -n "from ['\"]vue['\"]" packages/core/src --type ts -g '*field*' -g '*form*' 2>/dev/null || \
rg -n "from ['\"]vue['\"]" packages/core/src -g '*.ts' -g '*.vue' | head -30Repository: vuepont/base-ui-vue
Length of output: 3151
🏁 Script executed:
#!/bin/bash
# Check the useId.ts file to see if there's anything specific about Vue 3.5
echo "=== Content of useId.ts ==="
cat packages/core/src/utils/useId.ts
echo ""
echo "=== Check package.json for more version context ==="
cat packages/core/package.json | grep -A 5 -B 5 '"vue"'
echo ""
echo "=== Check if useId was mentioned in any comments or docs ==="
rg -n 'useId\|Vue 3\.[45]' packages/core/src -A 1 | head -40Repository: vuepont/base-ui-vue
Length of output: 1581
🏁 Script executed:
#!/bin/bash
# Check for field/form components and how they use useId
echo "=== Search for field-related components ==="
find packages/core/src -type f \( -name '*field*' -o -name '*form*' \) ! -path '*/node_modules/*'
echo ""
echo "=== Check if useId is imported in form/field components ==="
rg -n 'useId\|from.*useId' packages/core/src -g '*field*' -g '*form*' -g '*Field*' -g '*Form*'
echo ""
echo "=== Check Form component for useId usage ==="
find packages/core/src -name 'Form*' -o -name 'form*' | head -5 | xargs -I {} sh -c 'echo "=== {} ===" && cat {} 2>/dev/null | head -40'Repository: vuepont/base-ui-vue
Length of output: 3689
🏁 Script executed:
#!/bin/bash
# Find field components by searching for their imports/exports
echo "=== Search for FieldRoot, FieldControl, FieldError files ==="
fd -t f 'Field' packages/core/src
echo ""
echo "=== Check field directory structure ==="
ls -la packages/core/src/field/ 2>/dev/null || echo "Directory not found"
echo ""
echo "=== Search for useId imports across all components ==="
rg -l 'useId' packages/core/src --type ts --type vue
echo ""
echo "=== Search for Vue 3.4 vs 3.5 differences in comments ==="
rg -n '3\.[45]' packages/core/srcRepository: vuepont/base-ui-vue
Length of output: 2731
🏁 Script executed:
#!/bin/bash
# Check if useId is exported/re-exported
echo "=== Check if useId is exported in index files ==="
rg -n 'useId' packages/core/src --type ts -B 2 -A 2
echo ""
echo "=== Check main package exports ==="
cat packages/core/src/index.ts 2>/dev/null | head -60
echo ""
echo "=== Check if useId appears in any export ==="
grep -r 'export.*useId' packages/core/src --include='*.ts' 2>/dev/null || echo "No direct useId exports found"Repository: vuepont/base-ui-vue
Length of output: 2109
🌐 Web query:
Vue 3.4 useId release notes introduction
💡 Result:
useId() was not introduced in Vue 3.4—it was added in Vue 3.5.0 (released 2024-09-03) as a runtime-core feature, together with app.config.idPrefix. In the Vue core changelog it appears under the 3.5.0 feature list as: “useId() and app.config.idPrefix”. [1]
What it’s for: useId() generates unique IDs that are stable between SSR and client hydration, helping avoid hydration mismatches (useful for form/control id + for, accessibility, etc.). [2]
Sources: [1] Vue core CHANGELOG (3.5.0 feature list) https://github.com/vuejs/core/blob/main/CHANGELOG.md, [2] Ninja Squad overview of Vue 3.5 https://blog.ninja-squad.com/2024/09/05/what-is-new-vue-3.5/
🏁 Script executed:
#!/bin/bash
# Check if useBaseUiId is actually used in components
echo "=== Search for useBaseUiId usage ==="
rg -n 'useBaseUiId' packages/core/src --type ts
echo ""
echo "=== Search for any ID generation in field/form components ==="
rg -n 'id\|Id' packages/core/src/field/root/useFieldRootState.ts -i -A 2 -B 2 | head -60Repository: vuepont/base-ui-vue
Length of output: 901
🏁 Script executed:
#!/bin/bash
# Check if labelable-provider is used by field/form components
echo "=== Search for labelable-provider usage in field ==="
rg -n 'labelable\|useLabelableId\|useAriaLabelledBy' packages/core/src/field packages/core/src/form --type ts -i
echo ""
echo "=== Check what useLabelableId and useAriaLabelledBy do ==="
head -20 packages/core/src/labelable-provider/useLabelableId.ts
echo ""
head -40 packages/core/src/labelable-provider/useAriaLabelledBy.tsRepository: vuepont/base-ui-vue
Length of output: 1921
🏁 Script executed:
#!/bin/bash
# Check if there's documentation or PR context about the Vue 3.5 bump
echo "=== Check recent git history for Vue version changes ==="
git log --oneline -n 20 packages/core/package.json 2>/dev/null | head -10
echo ""
echo "=== Check if LabelableProvider is re-exported (used externally) ==="
grep -r 'useLabelableId\|useAriaLabelledBy\|LabelableProvider' packages/core/src/index.ts packages/core/src/*/index.ts 2>/dev/null || echo "Not found in main exports"
echo ""
echo "=== Check README or changelog for version requirements ==="
cat packages/core/README.md 2>/dev/null | grep -i 'vue\|version\|require' | head -10 || echo "README not found or no version info"
echo ""
echo "=== Summary: Check which components actually use useBaseUiId chain ==="
echo "useCollapsibleRoot uses useBaseUiId:"
rg -n 'export.*useCollapsibleRoot' packages/core/src --type ts
</parameter>Repository: vuepont/base-ui-vue
Length of output: 1350
Verify Vue 3.5+ features justify this breaking peer dependency bump.
Updating the minimum Vue version from >= 3.2.0 to >= 3.5.0 is a breaking change for users on Vue 3.2.x–3.4.x. The new field/form components use useId() through the useLabelableId and useAriaLabelledBy utilities to generate stable IDs for SSR hydration, which is only available in Vue 3.5+. While a client-side fallback exists for earlier versions, it is not SSR-safe.
If SSR support is required, this bump is justified. Consider documenting this requirement change in the PR description or changelog.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/core/package.json` at line 51, You changed the peerDependency in
package.json from "vue": ">= 3.2.0" to "vue": ">= 3.5.0" because new components
use Vue's useId() via useLabelableId and useAriaLabelledBy; verify and document
this breaking requirement: if we intend to require Vue 3.5+ for SSR-safe ID
generation, keep the peerDependency at ">= 3.5.0" and add a clear note to the PR
description and changelog stating SSR support now requires Vue 3.5+ (mention
useId/useLabelableId/useAriaLabelledBy); otherwise, revert package.json back to
">= 3.2.0" and modify useLabelableId/useAriaLabelledBy to detect availability of
useId at runtime and use an SSR-safe fallback for server renders to preserve
compatibility with Vue 3.2–3.4.
| watchEffect(() => { | ||
| const el = inputElementRef.value as HTMLInputElement | null | ||
| if (!el) | ||
| return | ||
|
|
||
| if (el.value || (isControlled.value && props.value !== '')) { | ||
| setFilled(true) | ||
| } | ||
| else if (isControlled.value && props.value === '') { | ||
| setFilled(false) | ||
| } | ||
| }) |
There was a problem hiding this comment.
Uncontrolled empty input may not set filled to false.
The watchEffect handles controlled inputs with empty values but doesn't explicitly set filled to false for uncontrolled inputs with empty el.value. When el.value is an empty string, the first condition is falsy and the else if only handles controlled inputs.
🛡️ Proposed fix
watchEffect(() => {
const el = inputElementRef.value as HTMLInputElement | null
if (!el)
return
if (el.value || (isControlled.value && props.value !== '')) {
setFilled(true)
}
- else if (isControlled.value && props.value === '') {
+ else {
setFilled(false)
}
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| watchEffect(() => { | |
| const el = inputElementRef.value as HTMLInputElement | null | |
| if (!el) | |
| return | |
| if (el.value || (isControlled.value && props.value !== '')) { | |
| setFilled(true) | |
| } | |
| else if (isControlled.value && props.value === '') { | |
| setFilled(false) | |
| } | |
| }) | |
| watchEffect(() => { | |
| const el = inputElementRef.value as HTMLInputElement | null | |
| if (!el) | |
| return | |
| if (el.value || (isControlled.value && props.value !== '')) { | |
| setFilled(true) | |
| } | |
| else { | |
| setFilled(false) | |
| } | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/core/src/field/control/FieldControl.vue` around lines 130 - 141, The
watchEffect for inputElementRef currently only clears filled for controlled
inputs; update the logic inside the watchEffect (the block referencing
inputElementRef, el.value, isControlled, props.value, and setFilled) so that
when el.value is an empty string it calls setFilled(false) regardless of
isControlled, e.g. add an explicit else branch that sets setFilled(false) when
!el.value; keep existing handling for controlled inputs (props.value === '')
intact.
|
|
||
| const attrs = useAttrs() | ||
|
|
||
| const id = useBaseUiId(props.id) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check useBaseUiId implementation to verify reactivity
cat packages/core/src/utils/useBaseUiId.tsRepository: vuepont/base-ui-vue
Length of output: 327
🏁 Script executed:
cat packages/core/src/utils/useId.tsRepository: vuepont/base-ui-vue
Length of output: 787
🏁 Script executed:
cat packages/core/src/field/error/FieldError.vueRepository: vuepont/base-ui-vue
Length of output: 4136
Make id reactive to handle dynamic prop changes.
const id = useBaseUiId(props.id) assigns a static string value that won't update if props.id changes. Since useId returns a string (not a reactive value), if the id prop is dynamic, the generated ID will become stale, causing the watch on line 73 to use an outdated ID value when registering/unregistering message IDs.
Wrap it in a computed:
const id = computed(() => useBaseUiId(props.id))This ensures the ID is regenerated whenever props.id changes, keeping the message ID registration in sync.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/core/src/field/error/FieldError.vue` at line 50, The current id is a
static string from useBaseUiId(props.id) and won't update when props.id changes;
wrap it in a computed (i.e. make id a reactive computed that calls
useBaseUiId(props.id)) and update any direct usages to reference the reactive
value (e.g., use id.value where necessary) so the watch/registration logic (the
code that registers/unregisters message IDs) sees the updated ID; also add an
import for computed from 'vue' if it's not already imported.
| let initialValue = value.value | ||
| if (initialValue === undefined) { | ||
| initialValue = getValue() | ||
| } | ||
|
|
||
| if (validityData.value.initialValue === null && initialValue !== null) { | ||
| setValidityData({ ...validityData.value, initialValue }) | ||
| } |
There was a problem hiding this comment.
Don't conflate null with "not initialized".
A field whose real initial value is null never gets its initial state recorded here, because null is treated as the sentinel. The first non-null edit then becomes the stored baseline, which breaks dirty/reset semantics for nullable fields.
Please use a separate initialization flag/sentinel instead of overloading null for both meanings.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/core/src/field/useField.ts` around lines 30 - 37, The code treats
validityData.value.initialValue === null as "not initialized", which conflates a
real null initial value with an uninitialized state; change the validity
tracking to use an explicit initialization flag (e.g.,
validityData.value.initialized or initialValueInitialized) rather than relying
on null. Update the logic around initialValue, getValue(), and setValidityData
so you always record the true initialValue (including null) on first
initialization and set the new flag to true, and replace any checks of
validityData.value.initialValue === null with checks of the new initialized flag
wherever initialization state is needed.
# Conflicts: # packages/core/src/index.ts
This reverts commit b90a3f2.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/core/src/field/root/useFieldValidation.ts`:
- Around line 190-248: The async validate result is applied unconditionally so
stale promises can overwrite newer state; fix by introducing a validation token
(e.g., incrementing counter or unique id stored on the field instance) captured
before awaiting resultOrPromise and checked after await—only apply
element.setCustomValidity, mutate formRef fields, and call
setValidityData/getCombinedFieldValidityData when the token still matches the
current validation token for that field (use controlId/value on the same scope
to identify the field); also add a regression test that triggers two overlapping
async validate calls that resolve in reverse order and assert the later input's
message/state wins.
- Around line 95-123: The fast-path incorrectly checks
!currentNativeValidity.valueMissing which matches other native invalid states
(typeMismatch, patternMismatch, etc.) and causes still-invalid controls to be
marked valid; change the condition to only proceed when the browser reports the
control as fully valid (use currentNativeValidity.valid or equivalent) before
creating nextValidityData and clearing element.setCustomValidity(''); when
syncing into formRef.fields (using controlId and currentFieldData) ensure you do
not override any external invalid/ref state—merge with
getCombinedFieldValidityData as before to preserve external invalid refs; also
add/extend a regression test that calls commit(..., true) with a non-empty value
that produces a native-invalid state to assert the field remains invalid and
message is preserved.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 600438ce-8a8e-482d-a580-c65bb5232dee
📒 Files selected for processing (2)
packages/core/src/field/root/useFieldValidation.test.tspackages/core/src/field/root/useFieldValidation.ts
| if (revalidate) { | ||
| if (state.value.valid !== false) { | ||
| return | ||
| } | ||
|
|
||
| const currentNativeValidity = element.validity | ||
|
|
||
| if (!currentNativeValidity.valueMissing) { | ||
| const nextValidityData: FieldValidityData = { | ||
| value, | ||
| state: { ...DEFAULT_VALIDITY_STATE, valid: true }, | ||
| error: '', | ||
| errors: [], | ||
| initialValue: validityData.value.initialValue, | ||
| } | ||
| element.setCustomValidity('') | ||
|
|
||
| const cId = controlId.value | ||
| if (cId) { | ||
| const currentFieldData = formRef.value.fields.get(cId) | ||
| if (currentFieldData) { | ||
| formRef.value.fields.set(cId, { | ||
| ...currentFieldData, | ||
| validityData: getCombinedFieldValidityData(nextValidityData, false), | ||
| }) | ||
| } | ||
| } | ||
| setValidityData(nextValidityData) | ||
| return |
There was a problem hiding this comment.
Revalidate fast-path is clearing still-invalid native states.
!currentNativeValidity.valueMissing also matches typeMismatch, patternMismatch, rangeOverflow, etc. In those cases this branch writes valid: true and clears the message, so a still-invalid control flips back to valid as soon as the value becomes non-empty. This short-circuit should only run when the browser reports the control as actually valid, and it should keep honoring the external invalid ref when syncing formRef.
🐛 Proposed fix
- if (!currentNativeValidity.valueMissing) {
+ if (currentNativeValidity.valid) {
const nextValidityData: FieldValidityData = {
value,
state: { ...DEFAULT_VALIDITY_STATE, valid: true },
error: '',
errors: [],
initialValue: validityData.value.initialValue,
}
element.setCustomValidity('')
const cId = controlId.value
if (cId) {
const currentFieldData = formRef.value.fields.get(cId)
if (currentFieldData) {
formRef.value.fields.set(cId, {
...currentFieldData,
- validityData: getCombinedFieldValidityData(nextValidityData, false),
+ validityData: getCombinedFieldValidityData(nextValidityData, invalid.value),
})
}
}
setValidityData(nextValidityData)
returnPlease add or extend a regression test that exercises commit(..., true) with a non-empty native-invalid value. As per coding guidelines, "Any behavior change in packages/core/src/ should include updated or new tests covering interaction/accessibility expectations".
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (revalidate) { | |
| if (state.value.valid !== false) { | |
| return | |
| } | |
| const currentNativeValidity = element.validity | |
| if (!currentNativeValidity.valueMissing) { | |
| const nextValidityData: FieldValidityData = { | |
| value, | |
| state: { ...DEFAULT_VALIDITY_STATE, valid: true }, | |
| error: '', | |
| errors: [], | |
| initialValue: validityData.value.initialValue, | |
| } | |
| element.setCustomValidity('') | |
| const cId = controlId.value | |
| if (cId) { | |
| const currentFieldData = formRef.value.fields.get(cId) | |
| if (currentFieldData) { | |
| formRef.value.fields.set(cId, { | |
| ...currentFieldData, | |
| validityData: getCombinedFieldValidityData(nextValidityData, false), | |
| }) | |
| } | |
| } | |
| setValidityData(nextValidityData) | |
| return | |
| if (revalidate) { | |
| if (state.value.valid !== false) { | |
| return | |
| } | |
| const currentNativeValidity = element.validity | |
| if (currentNativeValidity.valid) { | |
| const nextValidityData: FieldValidityData = { | |
| value, | |
| state: { ...DEFAULT_VALIDITY_STATE, valid: true }, | |
| error: '', | |
| errors: [], | |
| initialValue: validityData.value.initialValue, | |
| } | |
| element.setCustomValidity('') | |
| const cId = controlId.value | |
| if (cId) { | |
| const currentFieldData = formRef.value.fields.get(cId) | |
| if (currentFieldData) { | |
| formRef.value.fields.set(cId, { | |
| ...currentFieldData, | |
| validityData: getCombinedFieldValidityData(nextValidityData, invalid.value), | |
| }) | |
| } | |
| } | |
| setValidityData(nextValidityData) | |
| return |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/core/src/field/root/useFieldValidation.ts` around lines 95 - 123,
The fast-path incorrectly checks !currentNativeValidity.valueMissing which
matches other native invalid states (typeMismatch, patternMismatch, etc.) and
causes still-invalid controls to be marked valid; change the condition to only
proceed when the browser reports the control as fully valid (use
currentNativeValidity.valid or equivalent) before creating nextValidityData and
clearing element.setCustomValidity(''); when syncing into formRef.fields (using
controlId and currentFieldData) ensure you do not override any external
invalid/ref state—merge with getCombinedFieldValidityData as before to preserve
external invalid refs; also add/extend a regression test that calls commit(...,
true) with a non-empty value that produces a native-invalid state to assert the
field remains invalid and message is preserved.
| const resultOrPromise = validate(value, formValues) | ||
| if ( | ||
| typeof resultOrPromise === 'object' | ||
| && resultOrPromise !== null | ||
| && 'then' in resultOrPromise | ||
| ) { | ||
| result = await resultOrPromise | ||
| } | ||
| else { | ||
| result = resultOrPromise | ||
| } | ||
|
|
||
| if (result !== null) { | ||
| nextState.valid = false | ||
| nextState.customError = true | ||
|
|
||
| if (Array.isArray(result)) { | ||
| validationErrors = result | ||
| element.setCustomValidity(result.join('\n')) | ||
| } | ||
| else if (result) { | ||
| validationErrors = [result] | ||
| element.setCustomValidity(result) | ||
| } | ||
| } | ||
| else if (validateOnChange) { | ||
| element.setCustomValidity('') | ||
| nextState.customError = false | ||
|
|
||
| if (element.validationMessage) { | ||
| defaultValidationMessage = element.validationMessage | ||
| validationErrors = [element.validationMessage] | ||
| } | ||
| else if (element.validity.valid && !nextState.valid) { | ||
| nextState.valid = true | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const nextValidityData: FieldValidityData = { | ||
| value, | ||
| state: nextState, | ||
| error: defaultValidationMessage ?? (Array.isArray(result) ? result[0] : (result ?? '')), | ||
| errors: validationErrors, | ||
| initialValue: validityData.value.initialValue, | ||
| } | ||
|
|
||
| const cId = controlId.value | ||
| if (cId) { | ||
| const currentFieldData = formRef.value.fields.get(cId) | ||
| if (currentFieldData) { | ||
| formRef.value.fields.set(cId, { | ||
| ...currentFieldData, | ||
| validityData: getCombinedFieldValidityData(nextValidityData, invalid.value), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| setValidityData(nextValidityData) |
There was a problem hiding this comment.
Discard stale async validation results.
validate explicitly supports Promises, but after await the result is always applied to element, formRef, and setValidityData. If the user types again before the first Promise resolves, the older result can win and overwrite the newer value's state/message.
🐛 Proposed fix
const timeout = useTimeout()
const inputRef = shallowRef<HTMLInputElement | null>(null)
+ let validationRun = 0
async function commit(value: unknown, revalidate = false) {
+ const runId = ++validationRun
const element = inputRef.value
if (!element) {
return
}
@@
if (
typeof resultOrPromise === 'object'
&& resultOrPromise !== null
&& 'then' in resultOrPromise
) {
result = await resultOrPromise
+ if (runId !== validationRun) {
+ return
+ }
}
else {
result = resultOrPromise
}Please add or extend a regression test with two overlapping async validations resolving in reverse order. As per coding guidelines, "Any behavior change in packages/core/src/ should include updated or new tests covering interaction/accessibility expectations".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/core/src/field/root/useFieldValidation.ts` around lines 190 - 248,
The async validate result is applied unconditionally so stale promises can
overwrite newer state; fix by introducing a validation token (e.g., incrementing
counter or unique id stored on the field instance) captured before awaiting
resultOrPromise and checked after await—only apply element.setCustomValidity,
mutate formRef fields, and call setValidityData/getCombinedFieldValidityData
when the token still matches the current validation token for that field (use
controlId/value on the same scope to identify the field); also add a regression
test that triggers two overlapping async validate calls that resolve in reverse
order and assert the later input's message/state wins.
Summary by CodeRabbit
New Features
Documentation
Style
Tests
Chores