These changes are application-agnostic. Libraries do not choose an application's auth scope, UI kit, notifications, navigation behavior, or cache backend.
This guide covers cache and form contracts to review when upgrading from v4.x
to the coordinated v5 release. External-cache users must coordinate the key-format
migration, and consumers relying on previous deepEqual results must review those
assumptions. If a v4.x prerelease already provided these contracts, retain that
integration. The public form APIs described below remain available.
Previously, selected values were converted to strings and joined with delimiters.
Numeric 42 and string "42" could share record:id=42; server-side date keys
could collapse different times on the same day. The server helper, client helper,
and response middleware did not agree on date precision.
All computed keys now use one browser-safe encoder exported as computeCacheKey
from @cleverbrush/server/contract (also from the server entry point) and as
computeCacheTagKey from @cleverbrush/client/cache:
import { computeCacheKey } from '@cleverbrush/server/contract';
const tag = {
name: 'record',
properties: {
id: { getValue: (root: { params: { id?: unknown } }) => ({
success: true, value: root.params.id
}) }
}
};
const root = { params: { id: 42 }, query: {}, headers: {}, body: undefined };
computeCacheKey(tag, root);
// ct2:["record",[["id",["number","42"]]]]
computeCacheKey({ name: 'records', properties: {} }, root);
// ct2:["records",[]] — NOT the literal base label "records"- Property names and plain-object keys are sorted by code units. Types are tagged; delimiters are escaped by JSON. Arrays preserve order; dates preserve milliseconds. Bigints, non-finite numbers and negative zero remain distinguishable.
- Failed accessors and top-level
undefinedselections are omitted, as before. Nestedundefinedandnullare distinct. Array holes encode asundefined. - Cycles, invalid dates, functions, symbols, accessor properties, maps, sets, class
instances and named array properties throw
TypeError; select plain data instead. - Treat helper output as opaque. Do not parse or hand-build computed keys.
Upgrade every writer and invalidator sharing an external cache together. Flush
old entries (or let them expire before enabling upgraded readers). There is no
legacy-format fallback. externalCacheTags sends the literal base label and
the new computed key by default; invalidateBaseTags: false sends computed keys
only, including for property-free tags. Ensure the backend supports their length
and characters; adapters can apply the same hash on both sides.
In-memory response caches retain TTL configuration and tag-name-prefix invalidation coverage, using metadata instead of encoded-key prefixes. A successful 2xx mutation advances tag generations; a read started before it cannot refill any alias afterward. Failed HTTP responses or thrown mutations do not invalidate. A multi-tag cached response becomes unusable if any of its tags changes. External writers need their own concurrency controls; invalidation callbacks alone cannot prevent stale refills.
Scope remains the consumer's responsibility. Use distinct names for distinct
response shapes (records-list versus record-detail) and select every value that
changes the response. Keys do not automatically include URL, principal, tenant,
locale or headers. Keep authenticated caches request/session-scoped unless every
isolation dimension is encoded. Sharing a base invalidation label does not make
different response shapes interchangeable.
@cleverbrush/deep now exports deepClone<T>(value: T): T. React forms use it for
snapshots and reuse deepEqual for dirty checks instead of maintaining private
copies of these helpers:
import { deepClone, deepEqual } from '@cleverbrush/deep';
const saved = { tags: ['a'], updatedAt: new Date(1) };
const draft = deepClone(saved);
draft.tags.push('b'); // saved is unchanged
deepEqual(draft, saved); // false
draft.tags.pop();
deepEqual(draft, saved); // trueThe clone copies plain objects, arrays and Dates. It preserves cycles, shared references, sparse-array lengths, null prototypes and enumerable own string/symbol properties. Getters become data properties; non-enumerables/descriptors are not copied. Special property names are copied without invoking prototype setters. Files, Maps, Sets and other opaque instances remain references, not isolated copies.
Existing deepEqual behavior is corrected for every consumer, without a legacy
mode. Object/null comparisons no longer throw; Dates cannot equal plain objects;
opaque objects compare only by identity. Dates compare timestamps, including two
invalid Dates as equal. Primitives follow Object.is (NaN equals itself; signed
zeros differ). Plain data compares structurally, including equivalent cycles and
shared references, without requiring identical sharing topology. Enumerable symbol
keys participate. Array holes differ from explicit undefined; unordered matching
preserves duplicate/hole counts and does not use hashes or mutate inputs.
Audit code relying on previous results, particularly opaque-object content comparisons. Project such values to relevant plain data when structural equality is desired. See the deep package README for the complete supported-value contract.
deepExtend, HashObject and schema Transaction behavior are unchanged.
Transaction tracks mutations through a proxy; it is not a structural comparison
against a reset snapshot, and adopting it here would require changes to its dirty
semantics (including equal replacements, falsy values and array removals).
Previously edit forms could require remount keys or manual field setters to display new values. Now mounted fields read the same values as the form:
const form = useSchemaForm(ProfileSchema);
const city = form.useField(t => t.address.city);
useEffect(() => {
form.reset(profile); // mounted fields update; new clean baseline
}, [form, profile]); // controller identity stays stable
form.setValue({ address: { city: 'Berlin' } }); // shallow merge; city becomes dirty
city.setValue('Paris'); // central values and parent fields update too
form.reset(); // clears values, NOT restoration of the previous baselinereset(values) clones plain input data into the new initialValue baseline. It
clears errors, touched/dirty/validating flags and submission error. Returning to the
baseline clears dirty, including arrays and nested values. Setters do not mark
fields touched. createMissingStructure: false rejects writes to absent parents;
the field no longer displays a value that was not saved. Treat snapshots returned
by getValue()/fields as read-only and use setters. Keep the schema fixed for the
form's lifetime (normally define it outside render).
Validation is discarded immediately when values change, even if the next validation
is still debounced. Reset/unmount cancels scheduled validation and ignores stale
async results. Stable useSyncExternalStore snapshots support SSR and Strict Mode.
The local documentation demo shows a successful save clearing mounted fields:
Previously each form repeated validation, pending state, duplicate-submit guards, error conversion and success handling. The new API supplies these mechanics:
const form = useSchemaForm(ProfileSchema);
const submit = form.handleSubmit(
async values => {
const response = await saveProfile(values);
if (!response.ok) return { ok: false, error: 'Could not save profile' };
return { ok: true, data: response.data }; // success data inferred
},
{
onSuccess: profile => {
showConfirmation('Saved'); // notifications/navigation remain app-owned
closeDialog();
},
onError: error => {
if (isExpectedNetworkError(error)) return 'Please try again';
throw error; // preserve unexpected/control-flow exceptions
}
}
);
return <form onSubmit={submit}>
{/* fields */}
{form.error && <p role="alert">{form.error}</p>}
<button disabled={form.submitting}>
{form.submitting ? 'Saving…' : 'Save'}
</button>
</form>;onValid accepts synchronous/async void (success), { ok: true, data? }, or
{ ok: false, error: string }. onError is optional: without it exceptions reject
the handler promise; returning a string translates an exception to form.error,
and rethrowing preserves it. Exceptions from onSuccess always propagate and are
not misreported as failed writes. The awaitable handler calls preventDefault and
locks duplicates from validation through success/error handling. Failures retain
inputs; the next attempt clears submission error. submitting and error are
reactive, read-only flags. submit()/validate() still return ValidationResult.
Reset/unmount suppress callbacks/results from older submissions, but do not cancel network operations. Reset keeps the submission lock until that operation settles. A value change during async validation prevents dispatch of stale validated values.
Legacy Field, FieldRenderer, FormSystemProvider and nested providers remain
supported. For new code, capture renderer types once instead of casting custom props:
import { createFormSystem, defineFieldRenderer, useSchemaForm } from '@cleverbrush/react-form';
import { object, string } from '@cleverbrush/schema';
const text = defineFieldRenderer<string, { placeholder?: string }>(props => (
<input value={props.value ?? ''} placeholder={props.fieldProps?.placeholder}
onChange={e => props.onChange(e.target.value)} onBlur={props.onBlur} />
));
const select = defineFieldRenderer<string, { options: string[] }>(props => (
<select value={props.value ?? ''} onChange={e => props.onChange(e.target.value)}>
{props.fieldProps?.options.map(value => <option key={value}>{value}</option>)}
</select>
));
const basic = createFormSystem({ renderers: { string: text } });
const ui = createFormSystem({ renderers: {
...basic.renderers, 'string:select': select
} });
const ProfileSchema = object({ name: string(), role: string() });
function ProfileForm() {
const form = useSchemaForm(ProfileSchema);
return <ui.Field form={form} forProperty={t => t.role} variant="select"
fieldProps={{ options: ['reader', 'editor'] }} />;
// Unknown variant, missing options, or numeric options: compile-time errors.
}The factory returns Field, Provider, and a composable renderers registry.
Typed fields use their factory's registry without needing a provider; an untyped
ancestor cannot replace a renderer with incompatible props. ui.Provider also
configures legacy descendants and supports normal nesting. Required custom props
make fieldProps required at the call site.
Use boolean for checkboxes, string[] for multi-selects, number | undefined
for numeric inputs that can be cleared, and string | null for nullable strings.
Values can initially be undefined; optional/nested fields retain their types.
No UI-kit dependency or application-specific field vocabulary is introduced.
form.useField() and typed Field also support properties such as
string().default('Untitled'), boolean().default(true), and
array(string()).default(() => []). The default flag does not change which
renderer value types are compatible. Nullable fields still require nullable
renderers, enum setters still accept only their literals, and arrays retain
their element type. Defaults run during validation; initialize visible fields
with reset(values). A bare reset() continues to clear values.
An inferred registry and its field component can be exported from a shared
package compiled with declaration: true:
export const SharedFormSystem = createFormSystem({
renderers: { string: text }
});
export const SchemaField = SharedFormSystem.Field;The public TypedFormSystem<R> and TypedFieldComponent<R> names keep these
exports declaration-safe; no consumer type assertion is required.
When different value kinds reuse the same variant name (such as
string:select and number:select), TypeScript can require explicit parameter
types for inline callbacks in fieldProps. Annotate those parameters or pass
an already typed handler; the selected renderer still checks its callback
signature and rejects incompatible props.
