@validup/vue
v2.0.1
Published
A Vue 3 integration for validup.
Maintainers
Readme
@validup/vue 🛡️
A validup integration for Vue 3 — drive reactive form state from a Container<T>.
Drive form validation from a validup Container<T> with a vuelidate-shaped composable. The same validator runs on the server and the client — no rule duplication, no schema drift.
Table of Contents
- Installation
- Quick Start
- Real-World Pattern
- Per-Field State
- Nested Paths
- Groups
- Optional Validation
- Async & Debouncing
- Lazy Validation
- Auto-Dirty (External State Mutations)
- External (Server) Errors
- Cross-Cutting Errors
- Nested Forms
- Severity
- API Reference
- Migrating from Vuelidate
- Stability
- License
Installation
npm install @validup/vue validup vue --save| Peer dependency | Supported versions |
|-----------------|--------------------|
| validup | ^2.0.0 |
| vue | ^3.3 |
Quick Start
import { ref, reactive } from 'vue';
import { Container, ValidupError } from 'validup';
import { createValidator } from '@validup/zod';
import { useValidup, getSeverity } from '@validup/vue';
import { z } from 'zod';
const userValidator = new Container<{ name: string; email: string }>();
userValidator.mount('name', createValidator(z.string().min(2)));
userValidator.mount('email', createValidator(z.string().email()));
export default {
setup() {
const form = reactive({ name: '', email: '' });
const v = useValidup(userValidator, form);
async function submit() {
const result = await v.$validate();
if (!result.success) return;
// result.data: { name, email }
}
return { form, v, submit };
},
};In the template:
<input v-model="v.fields.name.$model" :class="getSeverity(v.fields.name)" />
<small v-for="err in v.fields.name.$errors" :key="err.code">{{ err.message }}</small>⚠️ Don't name the setup return
$v(Vue 3.5+ SSR gotcha). Vue 3.5'sPublicInstanceProxyHandlers.gettreats every template identifier starting with$as a Vue built-in lookup, skipping thesetupStateresolution chain entirely.$visn't in Vue's built-in allowlist ($attrs,$emit,$props,$refs, …), so a template that reads$v.fields.Xresolves$vtoundefinedand crashes at first SSR render withCannot read properties of undefined (reading 'fields').vue-tscdoes not flag this — the source type-checks fine. Usev,validation, or any non-$-prefixed name. Inner$-prefixed properties (v.$invalid,v.fields.name.$model) are unaffected — only the outer setup-return name has to be$-free. See #396 for the full diagnosis.
Real-World Pattern
The dominant pattern is a subclassed Container<T> you can reuse on both server and client:
// shared validator (server + client)
import { Container } from 'validup';
import { createValidator } from '@validup/zod';
import { z } from 'zod';
type Role = { name: string; description?: string };
export class RoleValidator extends Container<Role> {
protected override initialize() {
super.initialize();
const nameValidator = createValidator(z.string().min(3).max(128));
this.mount('name', { group: 'create' }, nameValidator);
this.mount('name', { group: 'update', optional: true }, nameValidator);
this.mount(
'description',
{ optional: true },
createValidator(z.string().max(4096).nullable()),
);
}
}Then in any Vue form:
import { reactive, ref } from 'vue';
import { useValidup } from '@validup/vue';
import { RoleValidator } from '@my-app/validators';
const form = reactive({ name: '', description: '' });
const group = ref<'create' | 'update'>('create');
const v = useValidup(new RoleValidator(), form, { group });The state argument is typed as Partial<T>, so a form that only carries a subset of the validator's entity fields (e.g. a Container<User> against a { name, email } create form where id / createdAt are server-set) type-checks without any cast. T stays bound to the container's entity type — typed-field access (v.fields.name) still narrows against the full entity, not the narrower form.
Per-Field State
v.fields.<key> returns a FieldState. Top-level keys narrow to FieldState<T[K]> (strict-mode clean — never | undefined). For dotted / bracketed / runtime-computed paths use v.fields.at('user.email') — see Nested Paths below.
This holds for entity types that carry an index signature too. Many entity types declare [key: string]: any alongside their known fields so extra attributes can ride along:
interface User {
name: string;
email: string;
[key: string]: any;
}
const v = useValidup(new UserValidator(), form);
v.fields.name; // FieldState<string> — declared keys keep their type
v.fields.email; // FieldState<string>
v.fields.extra; // any — keys the index signature admits stay untypedKeys that only the index signature admits resolve to any rather than FieldState<any>; for an entity without an index signature an unknown key stays a compile error, so typos are still caught wherever the entity type can catch them.
| Member | Type | Description |
|----------------|---------------------------------------|----------------------------------------------------------------------|
| $model | WritableComputedRef<V> | Two-way bound to state[<key>]. Writing flips $dirty automatically (no-op writes don't). |
| $invalid | ComputedRef<boolean> | True iff one or more issues are at this path. |
| $dirty | ComputedRef<boolean> | True after $touch() or first non-no-op $model write. |
| $pending | ComputedRef<boolean> | Form-level pending — true while any run is in flight. validup runs the whole container in one pass, so per-field pending is impossible. |
| $errors | ComputedRef<IssueItem[]> | Visible errors — gated on $dirty. |
| $issues | ComputedRef<Issue[]> | Raw issues at or below this path (groups + items), regardless of dirty state. |
| $touch() | () => void | Mark this field dirty. |
| $reset() | () => void | Clear dirty + drop any external issues for this path. |
Nested Paths
fields.at(<path>) accepts dotted, bracketed, or mixed paths — and any runtime-computed key:
v.fields.at('user.email').$model.value = '[email protected]';
v.fields.at('tags[0]').$model.value = 'urgent';
v.fields.at('matrix[0].name').$model.value = 'first';Why at(...) instead of bracket access? Under strict-mode TypeScript (noUncheckedIndexedAccess, the default for Nuxt and most modern setups) a string-keyed index signature returns FieldState | undefined, forcing a non-null assertion on every template reference. fields.at(...) keeps the typed-key path (v.fields.name) strict-mode clean and still gives you a dynamic accessor for paths the typed keys can't express. Caveat: a field literally named at is shadowed by the accessor — use fields.at('at') to reach it.
Touching an ancestor (v.fields.user.$touch()) surfaces every descendant error (user.email, user.profile.bio, …) — useful for "validate this whole section on submit" UX.
Object/array intermediates are auto-created on write, so writing v.fields.at('address.city').$model.value = 'Berlin' against state = {} produces state.address = { city: 'Berlin' }.
Groups
Pass a reactive group to drive 'create' / 'update' (or any custom) validation modes from the same container:
import { ref, reactive } from 'vue';
const group = ref<'create' | 'update'>('create');
const v = useValidup(new RoleValidator(), reactive({ name: '' }), { group });
// Switching groups re-validates the whole form eagerly.
// Per-field $errors stay quiet until that field is dirty.
group.value = 'update';Optional Validation
Optional mounts on the container behave the same way they do server-side — pass { optional: true } to Container.mount(...) and the field is skipped when its value qualifies as missing. Without configuration, @validup/vue uses validup core's conservative defaults (optionalValue: 'undefined', no optionalAs). To opt into a form-friendly app-wide idiom, install the plugin:
import { createApp } from 'vue';
import { createValidup } from '@validup/vue';
const app = createApp(/* ... */);
app.use(createValidup({
// Treat both `undefined` AND empty string as "missing" — fits the
// typical untouched-<input>-via-v-model case.
optionalValue: ['undefined', 'empty_string'],
// Collapse every optional sentinel to `null` on the way out, so
// the backend always sees one canonical shape for "no value".
optionalAs: null,
}));With the plugin installed, every useValidup in the app picks up those defaults; per-mount optionalValue / optionalAs still wins, and ComposableOptions can override per-form.
useValidup(container, state, {
// Override the install default for this form only.
optionalValue: 'falsy',
optionalAs: '',
});Precedence (highest → lowest): MountOptions → ContainerRunOptions (forwarded from ComposableOptions) → ContainerOptions → ComposableOptions (when no install) → install options → core default.
For the atomic vocabulary ('undefined', 'null', 'empty_string', 'zero', 'false', 'nan', 'falsy') see validup's Optional Values docs.
Result Caching (automatic)
useValidup owns one ResultCache per composable scope and passes it on every safeRun call. Cross-keystroke runs reuse the cached outcome of any mount whose (value, context, group) snapshot didn't change — $validate() at submit time skips validators that the per-keystroke runs already proved fresh, so async checks (uniqueness, captcha) don't refire when their inputs haven't changed.
The cache is cleared automatically on $reset() and when the container reference swaps; in both cases any pending or in-flight safeRun is invalidated first so it can't repopulate the cache or internalIssues after the clear.
Mark a validator sideEffect: true (via defineValidator or an adapter's { sideEffect: true } option) whenever its result is NOT a pure function of (ctx.value, ctx.context, ctx.group) — the cache snapshot only captures those three inputs, so anything else the validator reads goes stale silently. That includes:
- Cross-field validators that read
ctx.data.otherField(@validup/validator-js'sequals(key)withoutexpectedValuedoes this automatically). - Network-dependent validators — uniqueness checks against an API, captcha verifications, server-side rules where the answer can change between calls.
- Time-varying or globally-mutable state — anything reading the system clock, a feature flag, a shared store, the URL, etc.
Pure-input validators (regex, length, enum, schema parse against the value alone) stay default-cached and benefit from per-keystroke skip + submit-time skip naturally.
Async & Debouncing
By default, every state change triggers a fresh safeRun() on the container. Concurrent runs are coalesced via a run-id token so "last write wins" — older async results are dropped if newer state has already been written.
A defensive try/catch wraps safeRun so a buggy IContainer implementation that throws (instead of returning { success: false, error }) is surfaced as a synthetic path-less failure in $crossCuttingErrors rather than crashing the watcher.
For expensive async validators (e.g. uniqueness checks against an HTTP endpoint), set options.debounce:
const v = useValidup(uniqueUsernameValidator, form, { debounce: 300 });When a state change schedules a new run, the previous in-flight run is aborted via AbortSignal — async validators that pass ctx.signal to their I/O (e.g. fetch(url, { signal: ctx.signal })) cancel cleanly instead of completing wasted work. The composable also aborts pending runs when the owning effect scope tears down (component unmount). $validate() is intentionally not auto-cancellable, so a submit-time check can't be aborted by an intervening keystroke.
Lazy Validation
By default, useValidup runs validation on mount so $invalid reflects the initial state. For forms with expensive async validators (e.g. an HTTP-backed uniqueness check), this on-mount probe is wasteful. Pass lazy: true to skip it:
const v = useValidup(usernameValidator, form, { lazy: true });
// At this point: no validation has run.
// v.$invalid.value === false, v.$pending.value === false.
// Validation kicks in on the first $model write…
v.fields.username.$model.value = 'peter';
// …or on an explicit $touch() / $validate() call.
await v.$validate();lazy does not affect what happens after the first interaction. Subsequent state changes still trigger validation as normal (subject to debounce).
Auto-Dirty (External State Mutations)
When state is mutated through $model, useValidup flips the matching field's $dirty to true automatically. When state is mutated outside of $model — e.g. by a Pinia store action, a programmatic reset, or a parent-driven update — the field stays "clean" by default. This is intentional: it keeps form hydration via Object.assign(state, entity) from flashing errors on load.
For Pinia-driven forms where the consumer wants store-action mutations to surface validation errors, opt in with autoDirty: true:
const store = useFormStore();
const v = useValidup(validator, store.form, { autoDirty: true });
// A store action that mutates store.form will now mark every top-level
// field dirty (errors surface, severity flips). Without `autoDirty`, the
// form would stay quiet until the user interacts via $model.Notes:
autoDirtydoes not fire on the initial mount — only on subsequent state changes.- It marks every top-level field dirty (coarser than per-field). For wizard-style flows where this is too aggressive, use explicit
v.fields.<name>.$touch()calls instead. - Default-off keeps hydration semantics (
Object.assign(state, entity)leaves the form clean).
External (Server) Errors
When the server rejects a submission, feed its Issue[] straight back into the composable:
import { isValidupError } from 'validup';
async function submit() {
try {
await api.createRole(form);
} catch (e) {
if (isValidupError(e)) {
v.setExternalIssues(e.issues);
}
}
}External issues:
- Surface via
$errorsat the matching path (assuming the field is$dirty). - Carry
meta.external = true(deep — group children too) so themes can distinguish them from local validation errors. - Auto-clear the moment the user edits the affected field (
$modelwrite at the same path drops them — including any group descendants under that path).
Cross-Cutting Errors
$crossCuttingErrors collects every path-less issue (path: []) — backend rate limits, CSRF failures, schema-level container errors, generic submit failures. Pulled from both internal validation runs and setExternalIssues, so the same accessor surfaces:
- a synthetic failure produced when a buggy
IContainerthrows (auto-wrapped — see Async & Debouncing); - a server-supplied path-less issue (
{ path: [], message: 'rate_limit' }).
Cross-cutting errors are always visible — no dirty gate, no field to attach to. External entries also carry meta.external = true. Cleared by $reset() (external) or overwritten by the next run (internal).
<div v-for="err in v.$crossCuttingErrors.value" :key="err.code">
{{ err.message }}
</div>Nested Forms
A parent form aggregates one or more child forms via provide / inject. Children declare their name and the parent collects them via $getResultsForChild(name):
// parent.vue
import { useValidup, extractResultsFromChild } from '@validup/vue';
const v = useValidup(new Container(), {}, { stopPropagation: true });
async function submit() {
const data = {
...extractResultsFromChild(v, 'basic'),
...extractResultsFromChild(v, 'connection'),
};
await api.create(data);
}// child.vue (BasicFields)
const v = useValidup(new BasicFieldsValidator(), form, { name: 'basic' });onScopeDispose automatically unregisters children when their component unmounts.
How Children Register
The composable wires up child forms with Vue's provide / inject. The flow:
- Every
useValidup(...)call (unlessdetached: true) callsprovide(PARENT_INJECTION_KEY, ownRegistry)— exposing itself as a potential parent for descendants. - Every
useValidup(...)call (unlessdetachedorstopPropagationis set) callsinject(PARENT_INJECTION_KEY, undefined)— looking up the nearest ancestor. - If a parent is found and the child specifies
options.name, the child auto-registers with the parent's registry under that name. - When the child component's setup scope disposes,
onScopeDisposefires and the child unregisters automatically — no leaks, no manual cleanup.
[!IMPORTANT] Vue's
inject()only resolves values provided by ancestor components — a component never sees its ownprovide(). TwouseValidup()calls in the same<script setup>therefore never link; the child must live in its own component, rendered below the one that owns the parent composable.
The registry is shallowReactive, so $getResultsForChild(name) is usable reactively — a template binding or computed over it re-evaluates when the child registers/unregisters, and tracking continues through the returned composable's refs ($invalid, $dirty, …):
// live parent-side aggregation, not just submit-time reads
const address = computed(() => v.$getResultsForChild<AddressShape>('address'));
const sectionInvalid = computed(() => address.value?.$invalid.value ?? false);The injection key is exported as PARENT_INJECTION_KEY if you ever need to reach into the registry directly. Scoped trees use a per-scope Symbol.for('validup:parent:<scope>') key (see below).
Scoped Parent / Child Trees
For multi-step wizards or tab panels where each section needs its own aggregation root, use scope on both the parent and its children. Same-scope children register only with same-scope parents; unscoped children attach only to the nearest unscoped parent.
// Wizard parent — has two scoped collectors, one per tab.
const tab1 = useValidup(new Container(), {}, { scope: 'tab1', stopPropagation: true });
const tab2 = useValidup(new Container(), {}, { scope: 'tab2', stopPropagation: true });
// Child component in tab 1
const v = useValidup(profileValidator, form, { scope: 'tab1', name: 'profile' });
// tab1.$getResultsForChild('profile') → defined
// tab2.$getResultsForChild('profile') → undefined (different scope)Without scopes, every nested useValidup call attaches to the single nearest parent collector — fine for forms with one aggregation root, restrictive for split-tab layouts.
stopPropagation vs detached
Both flags break the default parent/child auto-attach, but they are not the same:
| Flag | Calls inject? | Calls provide? | Use case |
|----------------------------|-----------------|------------------|------------------------------------------------------------------|
| (default — neither set) | yes | yes | Ordinary nested form. Auto-attaches to the nearest parent and exposes itself as a parent for its own descendants. |
| stopPropagation: true | no | yes | Aggregation root. Doesn't attach to a higher-level collector, but its descendants still register with it. The dominant pattern for top-level forms. |
| detached: true | no | no | Self-contained widget rendered inside a collector tree but invisible to it (e.g. an embedded settings panel inside a wizard step). Neither attaches to a parent nor exposes itself as one. |
// Aggregation root — collects children, ignores any outer parent.
const root = useValidup(new Container(), {}, { stopPropagation: true });
// Standalone widget — won't see `root` and `root` won't see it.
const widget = useValidup(widgetValidator, widgetForm, { detached: true });Severity
getSeverity(field) returns a literal that drops straight into design-system components (e.g. @vuecs/form-controls):
| Field state | Severity |
|-----------------------------------------------------------------------------|---------------|
| $pending | 'warning' |
| not yet $dirty + $errors contains a required-mount item | 'warning' |
| not yet $dirty + $errors empty | undefined |
| $dirty + $errors contains at least one required-mount item | 'error' |
| $dirty + $errors contains only optional-mount items | 'warning' |
| $dirty + $errors empty | 'success' |
The required-vs-optional split is driven by IssueItem.meta.optional, which the validup runtime stamps on issues emitted from mounts declared as optional: true. The rationale: if the schema permits a field to be blank, invalid content shouldn't gate the user as hard as a required-field failure. Mixed bags (one required + one optional issue on the same path) surface as 'error' — the required one wins.
meta.optional reflects only the most-local mount, so a required leaf inside an optional sub-form still surfaces as 'error'. ("Role is optional" means you don't have to provide a role; once you do, the role's required fields stay required.)
The pristine 'warning' lets a form communicate "there is work to do" on initial render — e.g. a required email field outlines as amber and shows its message before the user touches anything. This works because $errors already filters the items it contains: required-mount items surface as soon as validation has run (pre-touch included); optional-mount items wait until $dirty flips. Consumers using field.$errors.value.map((i) => i.message) for rendering automatically pair with getSeverity — no $issues walk required.
import { getSeverity } from '@validup/vue';
buildFormGroup({
validationSeverity: getSeverity(v.fields.name),
validationMessages: v.fields.name.$errors.value.map((i) => i.message),
// …
});API Reference
useValidup(container, state, options?)
function useValidup<T extends ObjectLiteral, C = unknown>(
container: IContainer<T, C> | Ref<IContainer<T, C>>,
state: T | Ref<T>,
options?: ComposableOptions<T, C>,
): Composable<T>;
interface ComposableOptions<T, C = unknown> {
group?: MaybeRef<string | undefined>;
context?: MaybeRef<C | undefined>; // forwarded as ValidatorContext.context; reactive
debounce?: number;
name?: string;
stopPropagation?: boolean; // skip inject(), still provide() — aggregation root
detached?: boolean; // skip both inject() and provide() — invisible
lazy?: boolean; // skip the on-mount validation run
autoDirty?: boolean; // mark dirty on any state change, not just $model writes
scope?: string; // partition the parent/child collector tree
}Composable<T>
| Member | Description |
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
| $invalid / $pending / $dirty| Form-level computeds. $pending is form-wide (one run per container). |
| $errors | Flat list of visible leaf issues (path-attached only). Required-mount items show immediately; optional-mount items wait for $dirty. |
| $issues | Raw Issue[] for the whole form. |
| $crossCuttingErrors | Path-less IssueItem[] (always visible). Sources: internal runs + setExternalIssues. |
| $groupErrors | IssueGroup[] — group-level issues like ONE_OF_FAILED, dirty-gated. |
| $touch() / $reset() | Form-level dirty toggles. $reset does not clear internal issues (they reflect current state). |
| $validate() | Touch every field, run, return the Result<T>. Cancels any pending debounced run first. |
| setExternalIssues(issues) | Inject server-side issues. Tagged meta.external = true (deep — group children too). Cleared by $reset(). |
| $getResultsForChild(name) | Resolve a registered child composable (only if options.name was set on the child). Reactive — the registry is shallowReactive, so templates / computeds track register/unregister. |
| fields.<key> | Per-field FieldState<T[K]> for top-level entity keys (strict-mode clean — never \| undefined). |
| fields.at(<path>) | Per-field FieldState accessor for dotted / bracketed / runtime-computed paths ('user.email', 'tags[0]'). |
Helpers
| Export | Purpose |
|-----------------------------------|--------------------------------------------------------|
| getSeverity(field) | 'success' \| 'warning' \| 'error' \| undefined — optional-aware (see Severity) |
| extractResultsFromChild | Splice a child composable's $model values into one object |
| PARENT_INJECTION_KEY | The provide/inject key used for parent collectors |
Migrating from Vuelidate
The composable shape is intentionally vuelidate-compatible. For most templates you only swap the call-site:
- import useVuelidate from '@vuelidate/core';
- import { required, minLength, maxLength } from '@vuelidate/validators';
+ import { useValidup } from '@validup/vue';
+ import { RoleValidator } from '@my-app/validators';
const form = reactive({ name: '' });
- const $v = useVuelidate({
- name: { required, minLength: minLength(3), maxLength: maxLength(128) },
- }, form);
+ const v = useValidup(new RoleValidator(), form, { group: 'create' });Templates rarely change:
| Vuelidate | @validup/vue |
|---------------------------------------------------|-----------------------------------------------------------|
| $v.value.$invalid | v.$invalid.value |
| $v.value.<field>.$model | v.fields.<field>.$model.value |
| $v.value.<field>.$dirty | v.fields.<field>.$dirty.value |
| $v.value.<field>.$invalid | v.fields.<field>.$invalid.value |
| $v.value.<field>.$errors | v.fields.<field>.$errors.value (validup IssueItem[]) |
| $v.value.$touch() | v.$touch() |
| $v.value.$reset() | v.$reset() |
| getSeverity($v.value.<field>) (@ilingo/vuelidate) | getSeverity(v.fields.<field>) |
| useVuelidate({ $stopPropagation: true }) parent | useValidup(container, state, { stopPropagation: true }) |
| nested child useVuelidate(rules, form) | nested child useValidup(container, form, { name }) |
| $v.value.$getResultsForChild(name) | v.$getResultsForChild(name) |
| extractVuelidateResultsFromChild(...) (custom) | extractResultsFromChild(v, name) |
What changes substantively:
- Per-field error keys — Vuelidate's
$errorsis a list of{ $validator, $message, $data }objects keyed by rule name. validup's$errorsis a list ofIssueItem({ code, path, message, expected, received, meta }). If your template iterates$v.value.<field>.$errors, switch the loop variable to readerr.messageanderr.code. - Translation — built-in messages live in
@ilingo/validup(sibling to the existing@ilingo/vuelidate). The migration replacesuseTranslationsForNestedValidation($v.value)withuseTranslationsForValidup(v). - Async timing —
options.lazyis the analog of Vuelidate's$lazy: true. By default, validation runs eagerly internally and per-field error rendering is gated on$dirty, so the visible UX matches Vuelidate's$lazy: true. Passlazy: trueto additionally skip the on-mount probe (useful for expensive async validators). $autoDirty— setoptions.autoDirty: truewhen state is mutated outside of$model(e.g. via Pinia store actions). Default-off preserves silent hydration.$scope— setoptions.scopeon both parent and children to partition multiple aggregation roots (multi-step wizards, tab panels). Replaces Vuelidate's$scopeconfig.
Stability
What's covered by semver:
- Public exports —
useValidup,getSeverity,extractResultsFromChild,PARENT_INJECTION_KEY, and theComposable/FieldState/ComposableOptions/Severitytypes. Composable<T>shape — every documented member in the API Reference table, including the$crossCuttingErrors/$groupErrorsaccessors and thesetExternalIssues/$validate/$getResultsForChildmethods.- Options contract —
group,context,debounce,name,stopPropagation,detached,lazy,autoDirty,scope. New options will be additive within a major. - Parent / child collector protocol —
PARENT_INJECTION_KEYand per-scopeSymbol.for('validup:parent:<scope>')are the wire format. Third-party composables canprovide/injectthe same key to participate. - Dirty / external-issue semantics —
$modelwrites flip dirty + auto-clear external issues at the same path;$resetclears dirty + external but leaves internal issues (which reflect current state);setExternalIssuestags entries withmeta.external = true.
Extension points:
- Custom aggregation roots —
stopPropagation: true(collect descendants, ignore ancestors) anddetached: true(invisible in both directions) are the two blessed shapes. - Buggy
IContainersafety net — a defensivetry/catcharoundsafeRunsurfaces a path-less syntheticIssueItem(code: VALUE_INVALID) in$crossCuttingErrorsrather than crashing the watcher.
Internal (no semver guarantee):
- The
Proxy-backedfieldsaccessor's exact dispatch (getOwnPropertyDescriptorreturning a data descriptor withvalue, etc.) — observable behavior (per-field state, reactive key tracking) is stable; the underlying mechanism is not. - The run-id / abort token internals.
Peer dependency policy:
validup ^2.0.0,vue ^3.3. Both must be installed by the consumer;@validup/vuedoes not bundle either.@vueuse/core— transitive runtime dependency.
Deprecation policy: matches validup — at least one minor release of @deprecated notice before removal in a major.
License
Made with 💚
Published under Apache 2.0 License.
