diff --git a/.changeset/cache-form-correctness.md b/.changeset/cache-form-correctness.md new file mode 100644 index 00000000..a2f73b35 --- /dev/null +++ b/.changeset/cache-form-correctness.md @@ -0,0 +1,12 @@ +--- +'@cleverbrush/server': minor +'@cleverbrush/client': minor +'@cleverbrush/deep': minor +'@cleverbrush/react-form': minor +--- + +Use one browser-safe, deterministic `ct2:` cache key encoder across server and client helpers, response caches, and external invalidation. Selected properties and plain-object keys are sorted, values retain their types, and dates retain millisecond precision. Unsupported values fail explicitly. **Breaking:** every computed key changes, including property-free tags. Upgrade external cache writers and invalidators together, and flush or expire previous entries; literal base invalidation names and TTL settings remain unchanged. Successful mutations invalidate cached aliases and prevent older in-flight reads from refilling them; failed writes preserve entries. + +Synchronize mounted schema-form fields with form/field setters and reset baselines, using descriptor identities and stable external-store subscriptions. Discard obsolete async validation after value changes or reset. Add `handleSubmit`, reactive `submitting`/`error`, duplicate-submit protection, explicit success/failure results and opt-in error translation. Add `defineFieldRenderer` and `createFormSystem` for typed renderer values, variants, props and composable registries while retaining existing form APIs. See the cache/form migration guide for semantics and examples. + +Add reusable `deepClone` for plain objects, arrays and Dates with cycle/shared-reference preservation, sparse-array lengths, null prototypes and safe enumerable own string/symbol properties. React forms now use `deepClone` and `deepEqual` from `@cleverbrush/deep` for snapshots and dirty checks. **Breaking:** correct existing `deepEqual` semantics for null/type mismatches, equivalent cycles and shared references, signed zero, invalid Dates, symbol keys and sparse arrays. Opaque values such as Files, Maps, Sets and custom instances compare only by identity and remain references when cloned. Unordered arrays preserve duplicate/hole counts without hash-based matching or input mutation. Review consumers relying on the old outcomes; `deepExtend`, `HashObject` and `Transaction` are unchanged. diff --git a/.changeset/defaulted-form-fields.md b/.changeset/defaulted-form-fields.md new file mode 100644 index 00000000..e8a02108 --- /dev/null +++ b/.changeset/defaulted-form-fields.md @@ -0,0 +1,5 @@ +--- +'@cleverbrush/react-form': minor +--- + +Accept defaulted schema properties in typed Field, headless useField, and renderer schema bounds without losing value inference. Return a named TypedFormSystem so shared UI packages can export inferred registries while emitting declarations. diff --git a/docs/assets/form-lifecycle.png b/docs/assets/form-lifecycle.png new file mode 100644 index 00000000..6244d692 Binary files /dev/null and b/docs/assets/form-lifecycle.png differ diff --git a/docs/cache-form-migration.md b/docs/cache-form-migration.md new file mode 100644 index 00000000..be9e262d --- /dev/null +++ b/docs/cache-form-migration.md @@ -0,0 +1,261 @@ +# Cache keys and schema-form lifecycle + +These changes are application-agnostic. Libraries do not choose an application's +auth scope, UI kit, notifications, navigation behavior, or cache backend. + +This batch is scheduled as a coordinated **minor release** of the fixed package +group. This release classification does not remove the compatibility changes +below: external-cache users must coordinate the key-format migration, and +consumers relying on previous `deepEqual` results must review those assumptions. +The existing public form APIs remain available. + +## Cache key migration (breaking) + +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`: + +```ts +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 `undefined` selections are omitted, as before. + Nested `undefined` and `null` are distinct. Array holes encode as `undefined`. +- 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. + +## Shared data snapshots and equality (breaking) + +`@cleverbrush/deep` now exports `deepClone(value: T): T`. React forms use it for +snapshots and reuse `deepEqual` for dirty checks instead of maintaining private +copies of these helpers: + +```ts +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); // true +``` + +The 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](../libs/deep/README.md) 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). + +## Form values and reset + +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: + +```tsx +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 baseline +``` + +`reset(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. + +## Submission lifecycle + +The local documentation demo shows a successful save clearing mounted fields: + +![Successful save with cleared text, select and checkbox controls](assets/form-lifecycle.png) + +Previously each form repeated validation, pending state, duplicate-submit guards, +error conversion and success handling. The new API supplies these mechanics: + +```tsx +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
+ {/* fields */} + {form.error &&

{form.error}

} + +
; +``` + +`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. + +## Typed renderers without a UI dependency + +Legacy `Field`, `FieldRenderer`, `FormSystemProvider` and nested providers remain +supported. For new code, capture renderer types once instead of casting custom props: + +```tsx +import { createFormSystem, defineFieldRenderer, useSchemaForm } from '@cleverbrush/react-form'; +import { object, string } from '@cleverbrush/schema'; + +const text = defineFieldRenderer(props => ( + props.onChange(e.target.value)} onBlur={props.onBlur} /> +)); +const select = defineFieldRenderer(props => ( + +)); +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 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. + +### Defaulted properties and shared UI packages + +`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`: + +```tsx +export const SharedFormSystem = createFormSystem({ + renderers: { string: text } +}); +export const SchemaField = SharedFormSystem.Field; +``` + +The public `TypedFormSystem` and `TypedFieldComponent` 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. diff --git a/libs/client/README.md b/libs/client/README.md index a4d8649a..3e1dcbd4 100644 --- a/libs/client/README.md +++ b/libs/client/README.md @@ -256,7 +256,7 @@ const client = createClient(api, { middlewares: [cacheTags({ defaultTtl: 5000 })], }); -// Populates cache entries for 'todo-list' and 'todo:id=1' tags. +// Populates versioned keys for the 'todo-list' and 'todo' tag definitions. await client.todos.list({ query: { page: 1 } }); await client.todos.get({ params: { id: 1 } }); @@ -307,8 +307,20 @@ externalCacheTags({ invalidateTag: revalidateTag }); ``` The middleware runs after successful `POST`, `PUT`, `PATCH`, and `DELETE` -responses. Dynamic tags invalidate both the base tag name and the computed key -by default, for example `expense` and `expense:id=42`. +responses. Tags invalidate both the literal base tag name and the versioned computed +key by default, for example `expense` and `ct2:["expense",[["id",["number","42"]]]]`. +Property-free tags also have a computed key: `ct2:["expense",[]]`. + +Computed keys share a deterministic, type-tagged encoder with the server; +property/object key order is normalized and dates retain millisecond precision. +Only successful mutations invalidate the in-memory cache; older in-flight reads +cannot refill invalidated entries or aliases. Tag-name-prefix coverage and TTLs +are unchanged. Unsupported selected values throw `TypeError`. + +**Breaking migration:** upgrade all external cache writers and invalidators together +and retire old entries. Base invalidation labels remain literal names, but all +computed keys changed. Endpoint and auth/tenant scope are still consumer-defined. +See the [migration guide](../../docs/cache-form-migration.md#cache-key-migration-breaking). ## Per-Call Overrides diff --git a/libs/client/src/middlewares/cacheTags.test.ts b/libs/client/src/middlewares/cacheTags.test.ts index 14545185..9c905d85 100644 --- a/libs/client/src/middlewares/cacheTags.test.ts +++ b/libs/client/src/middlewares/cacheTags.test.ts @@ -1,6 +1,7 @@ +import { computeCacheKey } from '@cleverbrush/server/contract'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { EndpointMeta, FetchLike } from '../middleware.js'; -import { cacheTags } from './cacheTags.js'; +import { cacheTags, computeCacheTagKey } from './cacheTags.js'; // --------------------------------------------------------------------------- // Helpers @@ -495,3 +496,166 @@ describe('cacheTags middleware', () => { expect(r1).not.toBe(r2); }); }); + +describe('cache invalidation races', () => { + const tag = (name = 'records') => ({ name, properties: {} }); + function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { + resolve = done; + }); + return { promise, resolve }; + } + test.each([ + 400, 500 + ])('failed mutation %s preserves cached reads', async status => { + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response('old')) + .mockResolvedValueOnce(new Response('failed', { status })); + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + const meta = makeTagMeta([tag()]); + await mw('/records', makeInit(meta)); + await mw('/records', makeInit(meta, 'PATCH')); + expect(await (await mw('/records', makeInit(meta))).text()).toBe('old'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + test('transport rejection preserves cached reads', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response('old')) + .mockRejectedValueOnce(new Error('offline')); + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + const meta = makeTagMeta([tag()]); + await mw('/records', makeInit(meta)); + await expect(mw('/records', makeInit(meta, 'DELETE'))).rejects.toThrow( + 'offline' + ); + expect(await (await mw('/records', makeInit(meta))).text()).toBe('old'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + test('does not clear entries until the write succeeds', async () => { + const write = deferred(); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response('old')) + .mockReturnValueOnce(write.promise) + .mockResolvedValueOnce(new Response('new')); + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + const meta = makeTagMeta([tag()]); + await mw('/records', makeInit(meta)); + const pending = mw('/records', makeInit(meta, 'PATCH')); + expect(await (await mw('/records', makeInit(meta))).text()).toBe('old'); + write.resolve(new Response(null, { status: 204 })); + await pending; + expect(await (await mw('/records', makeInit(meta))).text()).toBe('new'); + }); + test.each([ + false, + true + ])('rejects old fills, read during write: %s', async duringWrite => { + const oldRead = deferred(); + const write = deferred(); + const fetch = vi + .fn() + .mockImplementation(async (_url, init) => { + if (init?.method === 'PATCH') return write.promise; + if ( + fetch.mock.calls.filter(([, i]) => i?.method === 'GET') + .length === 1 + ) { + return oldRead.promise; + } + return new Response('new'); + }); + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + const meta = makeTagMeta([tag(), tag('other')]); + let pendingWrite: Promise; + let pendingRead: Promise; + if (duringWrite) { + pendingWrite = mw( + '/records', + makeInit(makeTagMeta([tag()]), 'PATCH') + ); + pendingRead = mw('/records', makeInit(meta)); + } else { + pendingRead = mw('/records', makeInit(meta)); + pendingWrite = mw( + '/records', + makeInit(makeTagMeta([tag()]), 'PATCH') + ); + } + write.resolve(new Response(null, { status: 204 })); + await pendingWrite; + oldRead.resolve(new Response('old')); + expect(await (await pendingRead).text()).toBe('old'); + // No stale body may survive under a second tag either. + expect( + await ( + await mw('/records', makeInit(makeTagMeta([tag('other')]))) + ).text() + ).toBe('new'); + }); + test('invalidates every alias of a previously cached response', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response('old')) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(new Response('new')); + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + await mw('/records', makeInit(makeTagMeta([tag(), tag('alias')]))); + await mw('/records', makeInit(makeTagMeta([tag()]), 'PATCH')); + expect( + await ( + await mw('/alias', makeInit(makeTagMeta([tag('alias')]))) + ).text() + ).toBe('new'); + }); + test('freezes selected values at dispatch', async () => { + const response = deferred(); + const fetch = vi + .fn() + .mockReturnValueOnce(response.promise) + .mockResolvedValueOnce(new Response('second')); + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + const meta = makeTagMeta( + [ + { + name: 'records', + properties: { + id: { + getValue: root => ({ + success: true, + value: root.params.id + }) + } + } + } + ], + { params: { id: 1 } } + ); + const pending = mw('/records', makeInit(meta)); + (meta.params as any).id = 2; + response.resolve(new Response('first')); + await pending; + expect(await (await mw('/records', makeInit(meta))).text()).toBe( + 'second' + ); + expect(fetch).toHaveBeenCalledTimes(2); + }); + test('client and server helpers use exactly the same encoding', () => { + const definition = { + name: 'records', + properties: { + value: makeConstAccessor({ + date: new Date(1234), + filter: 'x,y=z' + }) + } + }; + const root = { params: {}, body: undefined, query: {}, headers: {} }; + expect(computeCacheTagKey(definition, root)).toBe( + computeCacheKey(definition, root) + ); + }); +}); diff --git a/libs/client/src/middlewares/cacheTags.ts b/libs/client/src/middlewares/cacheTags.ts index d6ad5b09..fa4cf7c4 100644 --- a/libs/client/src/middlewares/cacheTags.ts +++ b/libs/client/src/middlewares/cacheTags.ts @@ -3,7 +3,8 @@ * * Caches successful GET responses keyed by endpoint-defined cache tags. * Mutating requests (POST, PUT, DELETE, PATCH) invalidate all cache - * entries whose key starts with each of the endpoint's tag names. + * entries whose tag name starts with each of the endpoint's tag names, only + * after a successful response. Older in-flight reads cannot refill those tags. * * @example * ```ts @@ -18,6 +19,7 @@ * @module */ +import { computeCacheKey } from '@cleverbrush/server/contract'; import type { EndpointMeta, Middleware } from '../middleware.js'; // --------------------------------------------------------------------------- @@ -82,6 +84,7 @@ export interface SerializedCacheTag { interface CacheEntry { response: Response; expiresAt: number; + generations: ReadonlyArray; } /** @@ -94,35 +97,15 @@ export function isMutatingMethod(method: string): boolean { /** * Computes a deterministic cache key from a tag and request data. * - * - Tags with no properties produce just the tag name. - * - Tags with properties produce `name:key1=val1,key2=val2` where - * keys are sorted alphabetically for determinism. + * Uses the shared, versioned `ct2:` encoding, including property-free tags. + * Selected values are type-tagged and retain date precision. Unsupported values + * throw TypeError. External cache writers and invalidators must upgrade together. */ export function computeCacheTagKey( tag: SerializedCacheTag, root: CacheTagRoot ): string { - const entries = Object.entries(tag.properties); - - if (entries.length === 0) { - return tag.name; - } - - const parts: string[] = []; - for (const [key, accessor] of entries.sort(([a], [b]) => - a.localeCompare(b) - )) { - const result = accessor.getValue(root); - if (result.success && result.value !== undefined) { - parts.push(`${key}=${String(result.value)}`); - } - } - - if (parts.length === 0) { - return tag.name; - } - - return `${tag.name}:${parts.join(',')}`; + return computeCacheKey(tag, root); } /** @@ -148,8 +131,9 @@ export function createCacheTagRoot(meta: EndpointMeta): CacheTagRoot { * compute cache keys. If a valid (non-expired) cache entry exists, the * cached response is returned immediately (cloned). * - * On mutating requests (POST, PUT, DELETE, PATCH), all cache entries whose - * key starts with any of the endpoint's tag names are invalidated. + * Successful mutations (POST, PUT, DELETE, PATCH) invalidate entries whose + * tag names start with any of the endpoint's tag names, including all aliases. + * Generations also prevent older in-flight reads from repopulating them. * * @param options - Cache configuration. * @returns A {@link Middleware} that caches and invalidates by tag. @@ -162,42 +146,58 @@ export function cacheTags(options: CacheTagMiddlewareOptions = {}): Middleware { } = options; const cache = new Map(); + const generations = new Map(); + const isCurrent = (snapshot: CacheEntry['generations']) => + snapshot.every( + ([name, generation]) => generations.get(name) === generation + ); return next => (url, init) => { const meta = (init as any).__endpointMeta as EndpointMeta | undefined; const tags: readonly SerializedCacheTag[] | undefined = meta?.cacheTags; const method = (init.method ?? 'GET').toUpperCase(); - // -- Invalidation on mutating requests -- + // Invalidate only after success, including reads still in flight. if (isMutatingMethod(method) && meta && tags && tags.length > 0) { - const root = createCacheTagRoot(meta); - - for (const tag of tags) { - const tagKey = computeCacheTagKey(tag, root); - // Invalidate the exact key and any prefixed variants - // (tag name prefix match handles dynamic property variants - // when the mutation didn't provide the same properties). - for (const [cachedKey] of cache) { - if ( - cachedKey === tagKey || - cachedKey.startsWith(tag.name) - ) { - cache.delete(cachedKey); + const names = tags.map(tag => tag.name); + return next(url, init).then(response => { + if (response.ok) { + for (const [name, generation] of generations) { + if (names.some(prefix => name.startsWith(prefix))) { + generations.set(name, generation + 1); + } + } + for (const [key, entry] of cache) { + if (!isCurrent(entry.generations)) cache.delete(key); } } - } + return response; + }); } // -- Cache lookup for GET requests -- if (method === 'GET' && meta && tags && tags.length > 0) { const root = createCacheTagRoot(meta); + // Resolve keys once: caller-owned request objects can change while + // awaiting the response. A fill must belong to the original read. + const keys = tags.map(tag => ({ + name: tag.name, + key: computeCacheTagKey(tag, root) + })); + const snapshot = keys.map(({ name }) => { + if (!generations.has(name)) generations.set(name, 0); + return [name, generations.get(name)!] as const; + }); let foundEntry: CacheEntry | undefined; - for (const tag of tags) { - const cacheKey = computeCacheTagKey(tag, root); + for (const { key: cacheKey } of keys) { const entry = cache.get(cacheKey); - if (entry && entry.expiresAt > Date.now()) { + if ( + entry && + entry.expiresAt > Date.now() && + isCurrent(entry.generations) + ) { foundEntry = entry; break; } @@ -211,17 +211,17 @@ export function cacheTags(options: CacheTagMiddlewareOptions = {}): Middleware { } return next(url, init).then(response => { - if (condition(response)) { - for (const tag of tags) { - const cacheKey = computeCacheTagKey(tag, root); + if (isCurrent(snapshot) && condition(response)) { + for (const { name, key: cacheKey } of keys) { const ttl = - ttlByTag[tag.name] !== undefined - ? ttlByTag[tag.name] + ttlByTag[name] !== undefined + ? ttlByTag[name] : defaultTtl; if (ttl > 0) { cache.set(cacheKey, { response: response.clone(), - expiresAt: Date.now() + ttl + expiresAt: Date.now() + ttl, + generations: snapshot }); } } diff --git a/libs/client/src/middlewares/externalCacheTags.test.ts b/libs/client/src/middlewares/externalCacheTags.test.ts index 8a6ddfc8..8d360009 100644 --- a/libs/client/src/middlewares/externalCacheTags.test.ts +++ b/libs/client/src/middlewares/externalCacheTags.test.ts @@ -37,6 +37,62 @@ function makeMeta(overrides: Partial = {}): EndpointMeta { } describe('externalCacheTags middleware', () => { + test('property-free tags retain the base label and use a versioned computed key', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + const invalidateTag = vi.fn(); + await externalCacheTags({ invalidateTag })(fetch)('/records', { + method: 'POST', + __endpointMeta: makeMeta({ + cacheTags: [{ name: 'records', properties: {} }] + }) + } as any); + expect(invalidateTag.mock.calls).toEqual([ + ['records'], + ['ct2:["records",[]]'] + ]); + }); + + test('freezes keys before dispatch and rejects unsupported values before a write', async () => { + const meta = makeMeta(); + const invalidateTag = vi.fn(); + const fetch = vi.fn().mockImplementation(async () => { + (meta.params as any).id = 99; + return new Response(null, { status: 204 }); + }); + const middleware = externalCacheTags({ invalidateTag })(fetch); + await middleware('/records', { + method: 'POST', + __endpointMeta: meta + } as any); + expect(invalidateTag).toHaveBeenCalledWith( + 'ct2:["expense",[["id",["number","42"]]]]' + ); + (meta.params as any).id = new Map(); + await expect( + middleware('/records', { + method: 'POST', + __endpointMeta: meta + } as any) + ).rejects.toThrow(TypeError); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('a thrown mutation never calls the invalidator', async () => { + const fetch = vi + .fn() + .mockRejectedValue(new Error('offline')); + const invalidateTag = vi.fn(); + await expect( + externalCacheTags({ invalidateTag })(fetch)('/records', { + method: 'POST', + __endpointMeta: makeMeta() + } as any) + ).rejects.toThrow('offline'); + expect(invalidateTag).not.toHaveBeenCalled(); + }); + test('invalidates base and dynamic tags after a successful mutation', async () => { const fetch = vi .fn() @@ -52,7 +108,9 @@ describe('externalCacheTags middleware', () => { expect(response.status).toBe(204); expect(fetch).toHaveBeenCalledTimes(1); expect(invalidateTag).toHaveBeenCalledWith('expense'); - expect(invalidateTag).toHaveBeenCalledWith('expense:id=42'); + expect(invalidateTag).toHaveBeenCalledWith( + 'ct2:["expense",[["id",["number","42"]]]]' + ); expect(invalidateTag).toHaveBeenCalledTimes(2); }); @@ -102,7 +160,9 @@ describe('externalCacheTags middleware', () => { __endpointMeta: makeMeta() } as any); - expect(invalidateTag).toHaveBeenCalledWith('expense:id=42'); + expect(invalidateTag).toHaveBeenCalledWith( + 'ct2:["expense",[["id",["number","42"]]]]' + ); expect(invalidateTag).toHaveBeenCalledTimes(1); }); }); diff --git a/libs/client/src/middlewares/externalCacheTags.ts b/libs/client/src/middlewares/externalCacheTags.ts index cbc161af..fa47aaef 100644 --- a/libs/client/src/middlewares/externalCacheTags.ts +++ b/libs/client/src/middlewares/externalCacheTags.ts @@ -67,19 +67,12 @@ export function externalCacheTags( } = options; return next => async (url, init) => { - const response = await next(url, init); const meta = (init as any).__endpointMeta as EndpointMeta | undefined; const method = (init.method ?? meta?.method ?? 'GET').toUpperCase(); const tags: readonly SerializedCacheTag[] | undefined = meta?.cacheTags; - if ( - !meta || - !tags || - tags.length === 0 || - !isMutatingMethod(method) || - !condition(response) - ) { - return response; + if (!meta || !tags || tags.length === 0 || !isMutatingMethod(method)) { + return next(url, init); } const root = createCacheTagRoot(meta); @@ -91,7 +84,12 @@ export function externalCacheTags( tagKeys.add(dynamicKey); } - await Promise.all([...tagKeys].map(tag => invalidateTag(tag))); + // Freeze keys before dispatch and reject invalid selectors before a + // write is sent, not after the server has already committed it. + const response = await next(url, init); + if (condition(response)) { + await Promise.all([...tagKeys].map(tag => invalidateTag(tag))); + } return response; }; } diff --git a/libs/deep/README.md b/libs/deep/README.md index 2149c114..7c70dc8e 100644 --- a/libs/deep/README.md +++ b/libs/deep/README.md @@ -6,7 +6,7 @@ ![Coverage](https://img.shields.io/badge/coverage-97.8%25-brightgreen) -A library for deep operations on JavaScript objects — deep equality, deep merge, and flattening. +A library for deep operations on JavaScript objects — cloning, equality, merging, and flattening. ## Installation @@ -17,14 +17,55 @@ npm install @cleverbrush/deep ## Usage ```typescript -import { deepEqual, deepExtend, deepFlatten } from '@cleverbrush/deep'; +import { deepClone, deepEqual, deepExtend, deepFlatten } from '@cleverbrush/deep'; ``` ## API +### `deepClone(value: T): T` + +Creates an isolated copy of plain data while preserving its TypeScript type. + +```typescript +import { deepClone } from '@cleverbrush/deep'; + +const input = { tags: ['a'], savedAt: new Date(1) }; +const snapshot = deepClone(input); +input.tags.push('b'); +input.savedAt.setTime(2); +// snapshot is still { tags: ['a'], savedAt: new Date(1) } +``` + +| Value | `deepClone` | `deepEqual` | +| --- | --- | --- | +| Primitives, functions | Returned unchanged | `Object.is` | +| Plain objects (including null prototypes) | Recursively cloned; prototype preserved | Enumerable own properties compared structurally; null and ordinary prototypes may compare equal | +| Arrays | Recursively cloned; length and holes preserved | Length, holes, elements and enumerable extra properties compared | +| Dates | Cloned by timestamp, including invalid dates | Timestamps compared with `Object.is`; two invalid dates compare equal | +| Files, Maps, Sets, typed arrays, custom instances and other objects | Returned by reference | Identity only | + +Cloning preserves cycles and repeated references, including shared Dates. Own +enumerable string and symbol properties are copied as writable data properties. +Getters are read once per copied property; accessor descriptors, non-enumerable +properties and frozen/sealed state are not preserved. Date timestamps and array +length are copied explicitly. Special keys such as `__proto__` are defined as own +properties without invoking inherited setters. This is a data snapshot utility, +not a clone of arbitrary object internals or a replacement for serialization. + ### `deepEqual(a, b, options?)` -Recursively compares two values and returns `true` if they are deeply equal. Supports nested objects, arrays, `Date` instances, and handles circular references. +Recursively compares supported data values according to the table above. It handles +nulls and cycles, and does not require identical reference-sharing topology: one +shared child can compare equal to two separate, structurally equal children. Object +key order does not matter; symbol keys participate by identity. Dates compare only +their timestamps, not extra properties. Other opaque objects are never traversed. + +Primitives follow `Object.is`: `NaN` equals `NaN`, but `0` differs from `-0`. +Array holes differ from explicit `undefined`. With `disregardArrayOrder`, each +element must match one unused element on the other side, preserving duplicate and +hole counts. Named/symbol array properties still compare by key. Inputs are not +sorted or mutated, and hash collisions cannot decide equality. Unordered matching +can require quadratic comparisons; prefer ordered equality for large arrays. **Parameters:** @@ -49,8 +90,21 @@ deepEqual({ a: { b: 1, c: 2 } }, { a: { b: 1 } }); // Array order can be ignored deepEqual([1, 2, 3], [3, 1, 2], { disregardArrayOrder: true }); // => true + +deepEqual([1, 1, 2], [1, 2, 2], { disregardArrayOrder: true }); +// => false (duplicate counts differ) + +deepEqual(new Map(), new Map()); +// => false (opaque objects compare by identity) ``` +**Breaking comparison corrections:** previous versions could throw for object/null +pairs, consider Dates equal to unrelated objects, compare distinct opaque objects +by enumerable shape, and reject equivalent cycles or repeated references. Signed +zero, invalid Dates, symbol keys and sparse arrays now follow the rules above. +Audit consumers relying on those outcomes. To compare Maps/Sets/custom instances +by content, explicitly project their relevant state into plain data first. + ### `deepExtend(...objects)` Deeply merges multiple objects. Works like `Object.assign`, but recursively merges nested objects instead of overwriting them. All arguments must be non-null objects. diff --git a/libs/deep/src/data.ts b/libs/deep/src/data.ts new file mode 100644 index 00000000..de5b6926 --- /dev/null +++ b/libs/deep/src/data.ts @@ -0,0 +1,12 @@ +/** The structural data types supported by cloning and equality. */ +export function isPlainObject(value: unknown): value is object { + if (value === null || typeof value !== 'object') return false; + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype === Object.prototype; +} + +export function enumerableKeys(value: object): (string | symbol)[] { + return Reflect.ownKeys(value).filter(key => + Object.prototype.propertyIsEnumerable.call(value, key) + ); +} diff --git a/libs/deep/src/deepClone.test.ts b/libs/deep/src/deepClone.test.ts new file mode 100644 index 00000000..903925e2 --- /dev/null +++ b/libs/deep/src/deepClone.test.ts @@ -0,0 +1,138 @@ +import { expect, expectTypeOf, test } from 'vitest'; +import { deepClone } from './index.js'; + +test('deepClone - isolates nested data and preserves the input type', () => { + const source = { profile: { name: 'Ada' }, tags: ['a'], date: new Date(1) }; + const result = deepClone(source); + expectTypeOf(result).toEqualTypeOf(source); + expect(result).toEqual(source); + expect(result).not.toBe(source); + expect(result.profile).not.toBe(source.profile); + expect(result.tags).not.toBe(source.tags); + expect(result.date).not.toBe(source.date); + result.profile.name = 'Grace'; + result.tags.push('b'); + result.date.setTime(2); + expect(source).toEqual({ + profile: { name: 'Ada' }, + tags: ['a'], + date: new Date(1) + }); +}); + +test('deepClone - primitives and opaque objects retain their identity', () => { + class RecordValue { + value = 1; + } + for (const value of [ + null, + undefined, + Number.NaN, + -0, + 1n, + true, + 'text', + Symbol('key'), + () => 1, + new Map(), + new Set(), + new RecordValue(), + new File(['text'], 'file.txt'), + /pattern/, + new Uint8Array([1]) + ]) { + expect(deepClone(value)).toBe(value); + expect(deepClone({ value }).value).toBe(value); + } +}); + +test('deepClone - preserves cycles and repeated objects, arrays and Dates', () => { + const child = { value: 1 }; + const date = new Date(Number.NaN); + const source: any = { + child, + again: child, + date, + againDate: date, + array: [] + }; + source.self = source; + source.array.push(source, source.array); + source.againArray = source.array; + date['owner'] = source; + const result = deepClone(source); + expect(result.self).toBe(result); + expect(result.child).toBe(result.again); + expect(result.child).not.toBe(child); + expect(result.date).toBe(result.againDate); + expect(result.date).not.toBe(date); + expect(result.date.getTime()).toBeNaN(); + expect(result.date.owner).toBe(result); + expect(result.array[0]).toBe(result); + expect(result.array[1]).toBe(result.array); + expect(result.againArray).toBe(result.array); +}); + +test('deepClone - preserves sparse array lengths and enumerable extra keys', () => { + const symbol = Symbol('metadata'); + const source = Object.assign(new Array(4), { label: { text: 'a' } }); + source[1] = { value: 1 }; + source[symbol] = { value: 2 }; + const result = deepClone(source); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(4); + expect(Object.keys(result)).toEqual(['1', 'label']); + expect(0 in result).toBe(false); + expect(3 in result).toBe(false); + expect(result[1]).toEqual({ value: 1 }); + expect(result[1]).not.toBe(source[1]); + expect(result.label).not.toBe(source.label); + expect(result[symbol]).toEqual({ value: 2 }); + expect(result[symbol]).not.toBe(source[symbol]); +}); + +test('deepClone - preserves null prototypes and safely copies special keys', () => { + for (const prototype of [null, Object.prototype]) { + const source = Object.assign(Object.create(prototype), { + child: Object.assign(Object.create(null), { value: 1 }) + }); + for (const key of ['__proto__', 'constructor', 'prototype']) { + Object.defineProperty(source, key, { + enumerable: true, + value: { cloned: true } + }); + } + const result = deepClone(source); + expect(Object.getPrototypeOf(result)).toBe(prototype); + expect(Object.getPrototypeOf(result.child)).toBeNull(); + expect(result.child).not.toBe(source.child); + for (const key of ['__proto__', 'constructor', 'prototype']) { + expect(Object.hasOwn(result, key)).toBe(true); + expect(result[key]).toEqual({ cloned: true }); + expect(result[key]).not.toBe(source[key]); + } + expect(Object.hasOwn(Object.prototype, 'cloned')).toBe(false); + } +}); + +test('deepClone - reads enumerable getters as data and omits non-enumerables', () => { + const child = { value: 1 }; + const symbol = Symbol('key'); + const source = { + get child() { + return child; + }, + [symbol]: child + }; + Object.defineProperty(source, 'hidden', { value: 1 }); + const result = deepClone(source); + expect(result.child).not.toBe(child); + expect(result[symbol]).toBe(result.child); + expect(Object.hasOwn(result, 'hidden')).toBe(false); + expect(Object.getOwnPropertyDescriptor(result, 'child')).toEqual({ + value: result.child, + enumerable: true, + configurable: true, + writable: true + }); +}); diff --git a/libs/deep/src/deepClone.ts b/libs/deep/src/deepClone.ts new file mode 100644 index 00000000..ba8f6452 --- /dev/null +++ b/libs/deep/src/deepClone.ts @@ -0,0 +1,41 @@ +import { enumerableKeys, isPlainObject } from './data.js'; + +/** + * Copies plain objects, arrays and Dates, preserving cycles, shared references, + * sparse arrays and null prototypes. Other values (including class instances, + * Files, Maps and Sets) retain their identity. + * + * Enumerable own string/symbol properties are read and copied as writable data + * properties; accessors, non-enumerable properties and descriptors are not cloned. + */ +export function deepClone(value: T): T { + const seen = new WeakMap(); + + function clone(source: any): any { + if ( + !Array.isArray(source) && + !(source instanceof Date) && + !isPlainObject(source) + ) { + return source; + } + if (seen.has(source)) return seen.get(source); + const result = Array.isArray(source) + ? new Array(source.length) + : source instanceof Date + ? new Date(source.getTime()) + : Object.create(Object.getPrototypeOf(source)); + seen.set(source, result); + for (const key of enumerableKeys(source)) { + Object.defineProperty(result, key, { + value: clone(Reflect.get(source, key)), + enumerable: true, + configurable: true, + writable: true + }); + } + return result; + } + + return clone(value); +} diff --git a/libs/deep/src/deepEqual.test.ts b/libs/deep/src/deepEqual.test.ts index 655393e9..6fdc71b5 100644 --- a/libs/deep/src/deepEqual.test.ts +++ b/libs/deep/src/deepEqual.test.ts @@ -109,13 +109,12 @@ test('deepEqual - array vs non-array returns false', () => { expect(deepEqual({ 0: 1 }, [1])).toEqual(false); }); -test('deepEqual - circular reference returns false', () => { +test('deepEqual - equivalent circular references compare equal', () => { const a: Record = {}; a['self'] = a; const b: Record = {}; b['self'] = b; - // cyclic comparison should not infinitely recurse and return false - expect(deepEqual(a, b)).toEqual(false); + expect(deepEqual(a, b)).toEqual(true); }); test('deepEqual - objects with different key order but same keys are equal', () => { @@ -125,3 +124,178 @@ test('deepEqual - objects with different key order but same keys are equal', () test('deepEqual - objects with different key names return false', () => { expect(deepEqual({ a: 1 }, { b: 1 })).toEqual(false); }); + +test.each([ + null, + undefined, + true, + 1, + 'a', + Symbol('a'), + () => 1 +])('deepEqual - object versus %s is symmetric and does not throw', value => { + expect(deepEqual({}, value)).toBe(false); + expect(deepEqual(value, {})).toBe(false); +}); + +test('deepEqual - primitives follow Object.is', () => { + expect(deepEqual(Number.NaN, Number.NaN)).toBe(true); + expect(deepEqual(0, -0)).toBe(false); + expect(deepEqual({ value: -0 }, { value: 0 })).toBe(false); + expect(deepEqual(1, '1')).toBe(false); + expect(deepEqual(1n, 1n)).toBe(true); + expect(deepEqual(Symbol('key'), Symbol('key'))).toBe(false); +}); + +test('deepEqual - Dates compare timestamps, including invalid dates', () => { + expect(deepEqual(new Date(1), new Date(1))).toBe(true); + expect(deepEqual(new Date(1), new Date(2))).toBe(false); + expect(deepEqual(new Date(Number.NaN), new Date(Number.NaN))).toBe(true); + expect(deepEqual(new Date(1), new Date(Number.NaN))).toBe(false); + expect(deepEqual(new Date(1), {})).toBe(false); + expect(deepEqual({}, new Date(1))).toBe(false); + expect(deepEqual(new Date(1), null)).toBe(false); +}); + +test('deepEqual - opaque objects compare by identity', () => { + class RecordValue { + value = 1; + } + for (const create of [ + () => new RecordValue(), + () => new Map([['a', 1]]), + () => new Set([1]), + () => /pattern/g, + () => new Uint8Array([1]), + () => new File(['text'], 'file.txt') + ]) { + const value = create(); + expect(deepEqual(value, value)).toBe(true); + expect(deepEqual(value, create())).toBe(false); + expect(deepEqual(value, {})).toBe(false); + expect(deepEqual({}, value)).toBe(false); + } +}); + +test('deepEqual - only enumerable own properties participate', () => { + const key = Symbol('key'); + const left = Object.assign(Object.create(null), { a: 1, [key]: 2 }); + expect(deepEqual(left, { a: 1, [key]: 2 })).toBe(true); + expect(deepEqual(left, { a: 1, [key]: 3 })).toBe(false); + expect(deepEqual(left, { a: 1, [Symbol('key')]: 2 })).toBe(false); + Object.defineProperty(left, 'hidden', { value: 3 }); + expect(deepEqual(left, { a: 1, [key]: 2 })).toBe(true); + expect(deepEqual({ value: undefined }, {})).toBe(false); + expect( + deepEqual({ a: 1 }, Object.defineProperty({}, 'a', { value: 1 })) + ).toBe(false); +}); + +test.each([ + false, + true +])('deepEqual - preserves array length, holes and extra properties (unordered=%s)', disregardArrayOrder => { + const options = { disregardArrayOrder }; + expect(deepEqual(new Array(3), new Array(3), options)).toBe(true); + expect(deepEqual(new Array(3), new Array(4), options)).toBe(false); + expect(deepEqual(new Array(1), [undefined], options)).toBe(false); + const key = Symbol('metadata'); + const left = Object.assign([1], { label: 'a', [key]: 2 }); + expect(deepEqual(left, [1], options)).toBe(false); + expect( + deepEqual(left, Object.assign([1], { label: 'a', [key]: 2 }), options) + ).toBe(true); + expect( + deepEqual(left, Object.assign([1], { label: 'a', [key]: 3 }), options) + ).toBe(false); +}); + +test('deepEqual - ordered holes retain their positions', () => { + const left = new Array(2); + left[0] = 1; + const right = new Array(2); + right[1] = 1; + expect(deepEqual(left, right)).toBe(false); + expect(deepEqual(left, right, { disregardArrayOrder: true })).toBe(true); +}); + +test.each([ + false, + true +])('deepEqual - cycles terminate and still detect mismatches (unordered=%s)', disregardArrayOrder => { + const options = { disregardArrayOrder }; + const left: any = { value: 1 }; + left.self = left; + const right: any = { value: 1 }; + right.self = right; + expect(deepEqual(left, right, options)).toBe(true); + right.value = 2; + expect(deepEqual(left, right, options)).toBe(false); + const leftArray: any[] = []; + leftArray.push(leftArray, { value: 1 }); + const rightArray: any[] = []; + rightArray.push(rightArray, { value: 1 }); + expect(deepEqual(leftArray, rightArray, options)).toBe(true); + rightArray[1].value = 2; + expect(deepEqual(leftArray, rightArray, options)).toBe(false); +}); + +test('deepEqual - sharing topology is not part of structural equality', () => { + const shared = { value: 1 }; + const left = { a: shared, b: shared }; + const right = { a: { value: 1 }, b: { value: 1 } }; + expect(deepEqual(left, right)).toBe(true); + expect(deepEqual(right, left)).toBe(true); + right.b.value = 2; + expect(deepEqual(left, right)).toBe(false); + const cycle: any = { value: 1 }; + cycle.next = cycle; + const twoCycle: any = { value: 1, next: { value: 1 } }; + twoCycle.next.next = twoCycle; + expect(deepEqual(cycle, twoCycle)).toBe(true); + expect(deepEqual(twoCycle, cycle)).toBe(true); + twoCycle.next.value = 2; + expect(deepEqual(cycle, twoCycle)).toBe(false); +}); + +test('deepEqual - unordered comparisons count duplicates without mutating inputs', () => { + const options = { disregardArrayOrder: true }; + const left = Object.freeze([{ value: 1 }, { value: 2 }, { value: 1 }]); + const right = Object.freeze([{ value: 1 }, { value: 1 }, { value: 2 }]); + expect(deepEqual(left, right, options)).toBe(true); + expect( + deepEqual(left, [{ value: 1 }, { value: 2 }, { value: 2 }], options) + ).toBe(false); + expect(deepEqual([0, -0, Number.NaN], [Number.NaN, -0, 0], options)).toBe( + true + ); + expect(deepEqual([0, 0], [0, -0], options)).toBe(false); + expect(deepEqual([[1, 2], [3]], [[3], [2, 1]], options)).toBe(true); + expect(left.map(value => value.value)).toEqual([1, 2, 1]); + expect(right.map(value => value.value)).toEqual([1, 1, 2]); +}); + +test('deepEqual - failed cyclic candidates do not poison unordered comparisons', () => { + function cycle(value: number) { + const result: any = {}; + result.self = result; + result.value = value; + return result; + } + const options = { disregardArrayOrder: true }; + const shared = cycle(1); + expect( + deepEqual( + [shared, cycle(2), shared], + [cycle(2), cycle(1), cycle(1)], + options + ) + ).toBe(true); + expect( + deepEqual( + [shared, cycle(2), shared], + [cycle(2), cycle(1), cycle(3)], + options + ) + ).toBe(false); +}); diff --git a/libs/deep/src/deepEqual.ts b/libs/deep/src/deepEqual.ts index 2e1fa033..71152482 100644 --- a/libs/deep/src/deepEqual.ts +++ b/libs/deep/src/deepEqual.ts @@ -1,113 +1,117 @@ -import { HashObject } from './hashObject.js'; +import { enumerableKeys, isPlainObject } from './data.js'; -const isBothNaN = (v1: any, v2: any) => Number.isNaN(v1) && Number.isNaN(v2); +function isArrayIndex(key: string | symbol): boolean { + if (typeof key !== 'string') return false; + const index = Number(key); + return ( + Number.isInteger(index) && + index >= 0 && + index < 2 ** 32 - 1 && + String(index) === key + ); +} /** - * Compares two objects and returns true if they - * have the same structure and values. Can work - * with arrays and objects, nested objects, - * recursive objects, dates, etc. - * @param p1 first object - * @param p2 second object - * @param options additional options - * @returns {boolean} `true` if the objects are equal, `false` otherwise + * Structurally compares plain objects and arrays (including cycles). Dates + * compare by timestamp; primitives and opaque objects use Object.is semantics. + * Only enumerable own string/symbol properties participate in structural + * comparisons. Shared-reference topology does not affect equality. */ export const deepEqual = ( p1: any, p2: any, options?: { - /** - * if true, the order of the elements in the array will be disregarded - * e.g. [1, 2] and [2, 1] will be considered equal - */ + /** Ignore array element order, but preserve duplicate and hole counts. */ disregardArrayOrder?: boolean; } ): boolean => { - const cache = new Map(); + // Track pairs on the current recursion path, not all objects ever visited. + // Removing pairs on return also isolates failed unordered-array candidates. + const active = new WeakMap>(); - const compare = (...args: any[]) => { - const arraysAreIdentical = (a1: any, a2: any) => { - if (a1.length !== a2.length) return false; - if (a1.length === 0 && a2.length === 0) return true; - - const c1 = options?.disregardArrayOrder - ? a1 - .map((t: any) => t) - .sort((l: any, r: any) => { - const hash1 = HashObject(l); - const hash2 = HashObject(r); - return hash1 < hash2 ? 1 : -1; - }) - : a1.map((t: any) => t); - const c2 = options?.disregardArrayOrder - ? a2 - .map((t: any) => t) - .sort((l: any, r: any) => { - const hash1 = HashObject(l); - const hash2 = HashObject(r); - return hash1 < hash2 ? 1 : -1; - }) - : a2.map((t: any) => t); - - for (let i = 0; i < c1.length; i++) { - if (!compare(c1[i], c2[i])) return false; - } - return true; - }; - - if (args.length !== 2) return false; - const [o1, o2] = args; - if (o1 === o2) return true; - if (typeof o1 !== typeof o2) return false; - if (isBothNaN(o1, o2)) return true; + function compareProperties( + left: object, + right: object, + leftKeys: (string | symbol)[], + rightKeys: (string | symbol)[] + ): boolean { + const rightKeySet = new Set(rightKeys); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + key => + rightKeySet.has(key) && + compare(Reflect.get(left, key), Reflect.get(right, key)) + ) + ); + } + function compareUnordered( + left: object, + right: object, + leftKeys: (string | symbol)[], + rightKeys: (string | symbol)[] + ): boolean { + const leftIndices = leftKeys.filter(isArrayIndex); + const rightIndices = rightKeys.filter(isArrayIndex); + if (leftIndices.length !== rightIndices.length) return false; if ( - (Array.isArray(o1) && !Array.isArray(o2)) || - (!Array.isArray(o1) && Array.isArray(o2)) + !compareProperties( + left, + right, + leftKeys.filter(key => !isArrayIndex(key)), + rightKeys.filter(key => !isArrayIndex(key)) + ) ) { return false; } - if (Array.isArray(o1) && Array.isArray(o2)) { - return arraysAreIdentical(o1, o2); + + const unmatched = new Set(rightIndices); + for (const key of leftIndices) { + const value = Reflect.get(left, key); + const match = Array.from(unmatched).find(candidate => + compare(value, Reflect.get(right, candidate)) + ); + if (match === undefined) return false; + unmatched.delete(match); } + return true; + } - if (typeof o1 === 'object' && o1 !== null) { - if (cache.get(o1) === true) { + function compare(left: any, right: any): boolean { + if (Object.is(left, right)) return true; + if (left instanceof Date || right instanceof Date) { + return ( + left instanceof Date && + right instanceof Date && + Object.is(left.getTime(), right.getTime()) + ); + } + if (Array.isArray(left)) { + if (!Array.isArray(right) || left.length !== right.length) { return false; } - - cache.set(o1, true); - - if (o1 instanceof Date && o2 instanceof Date) { - return ( - // biome-ignore lint/suspicious/noGlobalIsNan: isNaN coerces Date to number, which is the intended behavior here - !isNaN(o1 as any) && - // biome-ignore lint/suspicious/noGlobalIsNan: isNaN coerces Date to number, which is the intended behavior here - !isNaN(o2 as any) && - o1.getTime() === o2.getTime() - ); - } - - const keys1 = Object.keys(o1); - const keys2 = Object.keys(o2); - if (keys1.length !== keys2.length) return false; - - keys1.sort(); - keys2.sort(); - for (let i = 0; i < keys1.length; i++) { - if (keys1[i] !== keys2[i]) { - return false; - } - - const v1 = o1[keys1[i]]; - const v2 = o2[keys1[i]]; - if (!compare(v1, v2)) return false; - } - return true; + } else if (!isPlainObject(left) || !isPlainObject(right)) { + return false; } - return false; - }; + let partners = active.get(left); + if (partners?.has(right)) return true; + if (!partners) { + partners = new Set(); + active.set(left, partners); + } + partners.add(right); + try { + const leftKeys = enumerableKeys(left); + const rightKeys = enumerableKeys(right); + return Array.isArray(left) && options?.disregardArrayOrder + ? compareUnordered(left, right, leftKeys, rightKeys) + : compareProperties(left, right, leftKeys, rightKeys); + } finally { + partners.delete(right); + } + } return compare(p1, p2); }; diff --git a/libs/deep/src/index.ts b/libs/deep/src/index.ts index 60599a9e..6d58eb19 100644 --- a/libs/deep/src/index.ts +++ b/libs/deep/src/index.ts @@ -1,5 +1,13 @@ +import { deepClone } from './deepClone.js'; import { deepEqual } from './deepEqual.js'; import { deepExtend, type Merge, type MergeTwo } from './deepExtend.js'; import { deepFlatten } from './deepFlatten.js'; -export { deepEqual, deepExtend, deepFlatten, type Merge, type MergeTwo }; +export { + deepClone, + deepEqual, + deepExtend, + deepFlatten, + type Merge, + type MergeTwo +}; diff --git a/libs/react-form/README.md b/libs/react-form/README.md index 8734102c..25e8fc36 100644 --- a/libs/react-form/README.md +++ b/libs/react-form/README.md @@ -54,6 +54,21 @@ npm install @cleverbrush/react-form ## Quick Start +For typed renderer props/variants and managed submission, see the new +[consumer examples and migration guide](../../docs/cache-form-migration.md). +The provider-based APIs below remain supported. + +Typed fields and headless `form.useField()` accept properties with schema +defaults, including enums, booleans, arrays, and nullable values. A default +does not erase the property's inferred type. Defaults are applied during +validation; call `form.reset(values)` to establish a visible clean baseline. +`reset()` still clears the store rather than restoring schema defaults. + +Shared UI packages can directly export `createFormSystem(...)` results and +`system.Field` while emitting TypeScript declarations. The named +`TypedFormSystem` and `TypedFieldComponent` types preserve the registry's +field/variant/props checks across package boundaries. + ```tsx import { object, string, number } from '@cleverbrush/schema'; import { useSchemaForm, FormSystemProvider, Field } from '@cleverbrush/react-form'; @@ -316,9 +331,21 @@ const form = useSchemaForm(UserSchema, { | `form.useField(forProperty)` | Bind a field by PropertyDescriptor selector | | `form.submit()` | Validate and return `ValidationResult` (includes `result.object` on success) | | `form.validate()` | Run validation, propagate errors to fields | -| `form.reset(values?)` | Reset all fields; optionally set new initial values | +| `form.reset(values?)` | Clear values, or establish supplied values as a clean baseline; synchronize mounted fields and clear errors/touched/dirty/validation | +| `form.handleSubmit(onValid, options?)` | Awaitable event handler: validate, prevent duplicate submits, handle success/failure results | +| `form.submitting` | Reactive, read-only pending state from validation through callbacks | +| `form.error` | Reactive, read-only submission error; cleared on reset or the next attempt | | `form.getValue()` | Get current form values as plain object | -| `form.setValue(values)` | Merge values into form state | +| `form.setValue(values)` | Shallow-merge values and synchronize mounted fields without marking touched | + +Form snapshots and dirty checks use `deepClone` and `deepEqual` from +[`@cleverbrush/deep`](../deep/README.md). Plain objects, arrays and Dates are cloned +on reset/setters; changing caller-owned input cannot change their baseline. Cycles, +shared references, sparse arrays and null prototypes are preserved. Replacing a +value with structurally equal data clears dirty, even with different sharing of +child references. Files and other opaque objects retain identity: a different File +is dirty even if its name/content match. Treat returned snapshots and opaque values +as read-only; update through setters rather than mutating them in place. ## useField diff --git a/libs/react-form/package.json b/libs/react-form/package.json index 512ab6ec..e107bd94 100644 --- a/libs/react-form/package.json +++ b/libs/react-form/package.json @@ -5,6 +5,7 @@ "email": "andrew_zol@cleverbrush.com" }, "dependencies": { + "@cleverbrush/deep": "^4.4.3", "@cleverbrush/schema": "^4.4.3" }, "devDependencies": { diff --git a/libs/react-form/src/FormStore.test.ts b/libs/react-form/src/FormStore.test.ts new file mode 100644 index 00000000..013a83d4 --- /dev/null +++ b/libs/react-form/src/FormStore.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'vitest'; +import { createFormStore } from './FormStore.js'; + +function setup(profile: any) { + const store = createFormStore({ profile }); + store.registerField('/profile', { + getValue: values => ({ success: true, value: values.profile }), + setValue: (values, value) => { + values.profile = value; + return true; + } + }); + return store; +} + +describe('shared deep utilities in the form store', () => { + test('reset isolates caller-owned nested objects, arrays and Dates', () => { + const store = setup(undefined); + const profile = { name: 'Ada', tags: ['a'], date: new Date(1) }; + store.resetAll({ profile }); + profile.name = 'Grace'; + profile.tags.push('b'); + profile.date.setTime(2); + expect(store.getFieldState('/profile')).toMatchObject({ + value: { name: 'Ada', tags: ['a'], date: new Date(1) }, + initialValue: { name: 'Ada', tags: ['a'], date: new Date(1) }, + dirty: false + }); + }); + + test('equal replacement clears dirty even with different reference sharing', () => { + const child = { value: 1 }; + const store = setup({ left: child, right: child }); + store.setFieldValue('/profile', { left: { value: 2 } }, true); + expect(store.getFieldState('/profile').dirty).toBe(true); + store.setFieldValue( + '/profile', + { left: { value: 1 }, right: { value: 1 } }, + true + ); + expect(store.getFieldState('/profile').dirty).toBe(false); + }); + + test('Files retain identity and distinct replacements become dirty', () => { + const original = new File(['text'], 'file.txt'); + const replacement = new File(['text'], 'file.txt'); + const store = setup(original); + expect(store.getFieldState('/profile').value).toBe(original); + store.setFieldValue('/profile', replacement, true); + expect(store.getFieldState('/profile')).toMatchObject({ + value: replacement, + initialValue: original, + dirty: true + }); + store.setFieldValue('/profile', original, true); + expect(store.getFieldState('/profile').dirty).toBe(false); + store.resetAll({ profile: replacement }); + expect(store.getFieldState('/profile').initialValue).toBe(replacement); + expect(store.getFieldState('/profile').dirty).toBe(false); + }); + + test('cyclic values and sparse array baselines survive setters', () => { + const profile: any = { entries: new Array(2) }; + profile.self = profile; + const store = setup(profile); + const replacement: any = { entries: new Array(2) }; + replacement.self = replacement; + store.setFieldValue('/profile', replacement, true); + expect(store.getFieldState('/profile').dirty).toBe(false); + replacement.entries[0] = undefined; + store.setFieldValue('/profile', replacement, true); + expect(store.getFieldState('/profile').dirty).toBe(true); + }); +}); diff --git a/libs/react-form/src/FormStore.ts b/libs/react-form/src/FormStore.ts index 7dd2d432..45971e0e 100644 --- a/libs/react-form/src/FormStore.ts +++ b/libs/react-form/src/FormStore.ts @@ -1,21 +1,38 @@ -import type { FieldState } from './types.js'; +import { deepClone, deepEqual } from '@cleverbrush/deep'; +import type { FieldState, FormSubmissionState } from './types.js'; -/** - * Internal form store — manages field state and notification. - * Does not depend on React; used by hooks for state management. - */ +type Binding = { + getValue: (values: any) => { success: boolean; value?: any }; + setValue: (values: any, value: any, options: any) => boolean; +}; + +/** Internal immutable snapshots shared by all bindings of a form. */ export function createFormStore(initialValues: any) { - let values: any = initialValues != null ? { ...initialValues } : {}; + let values = deepClone(initialValues ?? {}); + let baseline = values; + let revision = 0; + let submission: FormSubmissionState = { + submitting: false, + error: undefined + }; + const bindings = new Map(); const fieldStates = new Map(); const listeners = new Map void>>(); const globalListeners = new Set<() => void>(); - function ensureFieldState(path: string): FieldState { + function read(binding: Binding | undefined, source: any) { + const result = binding?.getValue(source); + return result?.success ? result.value : undefined; + } + function getFieldState(path: string): FieldState { if (!fieldStates.has(path)) { + const binding = bindings.get(path); + const value = read(binding, values); + const initialValue = read(binding, baseline); fieldStates.set(path, { - value: undefined, - initialValue: undefined, - dirty: false, + value, + initialValue, + dirty: !deepEqual(value, initialValue), touched: false, error: undefined, validating: false @@ -23,83 +40,123 @@ export function createFormStore(initialValues: any) { } return fieldStates.get(path)!; } - - function getFieldState(path: string): FieldState { - return ensureFieldState(path); + function registerField(path: string, binding: Binding) { + if (!bindings.has(path)) bindings.set(path, binding); + return getFieldState(path); } - - function updateFieldState(path: string, patch: Partial) { - const current = ensureFieldState(path); - const updated = { ...current, ...patch }; - fieldStates.set(path, updated); - notifyPath(path); - } - function notifyPath(path: string) { - const pathListeners = listeners.get(path); - if (pathListeners) { - for (const listener of pathListeners) { - listener(); - } - } + for (const listener of listeners.get(path) ?? []) listener(); } - function notifyAll() { - for (const [, pathListeners] of listeners) { - for (const listener of pathListeners) { - listener(); - } - } - for (const listener of globalListeners) { - listener(); + for (const path of listeners.keys()) notifyPath(path); + for (const listener of globalListeners) listener(); + } + function updateFieldState(path: string, patch: Partial) { + const current = getFieldState(path); + if ( + Object.entries(patch).every(([key, value]) => + Object.is(current[key as keyof FieldState], value) + ) + ) + return; + fieldStates.set(path, { ...current, ...patch }); + notifyPath(path); + } + function syncFields(reset: boolean) { + for (const [path, binding] of bindings) { + const value = read(binding, values); + const initialValue = read(binding, baseline); + updateFieldState(path, { + value, + initialValue, + dirty: !deepEqual(value, initialValue), + validating: false, + ...(reset ? { touched: false, error: undefined } : {}) + }); } } - function subscribe(path: string, listener: () => void): () => void { - if (!listeners.has(path)) { - listeners.set(path, new Set()); + let set = listeners.get(path); + if (!set) { + set = new Set(); + listeners.set(path, set); } - listeners.get(path)!.add(listener); + set.add(listener); return () => { - listeners.get(path)?.delete(listener); + set.delete(listener); + if (set.size === 0) listeners.delete(path); }; } - function subscribeGlobal(listener: () => void): () => void { globalListeners.add(listener); return () => { globalListeners.delete(listener); }; } - - function getValues(): any { + function getSubmissionState() { + return submission; + } + function setSubmissionState(patch: Partial) { + const next = { ...submission, ...patch }; + if ( + next.submitting === submission.submitting && + next.error === submission.error + ) + return; + submission = next; + for (const listener of globalListeners) listener(); + } + function getValues() { return values; } - + function getRevision() { + return revision; + } function setValues(newValues: any) { - values = newValues != null ? { ...newValues } : {}; + values = deepClone(newValues ?? {}); + revision++; + syncFields(false); + } + function setFieldValue( + path: string, + value: any, + createMissingStructure: boolean + ) { + const next = deepClone(values); + if ( + bindings + .get(path) + ?.setValue(next, deepClone(value), { createMissingStructure }) + ) { + setValues(next); + } } - function resetAll(newInitialValues?: any) { - values = newInitialValues != null ? { ...newInitialValues } : {}; - fieldStates.clear(); - notifyAll(); + values = deepClone(newInitialValues ?? {}); + baseline = values; + revision++; + syncFields(true); + setSubmissionState({ error: undefined }); } - - function getAllFieldPaths(): string[] { - return Array.from(fieldStates.keys()); + function getAllFieldPaths() { + return Array.from(bindings.keys()); } return { + registerField, getFieldState, updateFieldState, subscribe, subscribeGlobal, getValues, setValues, + setFieldValue, resetAll, - notifyAll, - getAllFieldPaths + getAllFieldPaths, + getRevision, + getSubmissionState, + setSubmissionState, + notifyAll }; } diff --git a/libs/react-form/src/components.tsx b/libs/react-form/src/components.tsx index 517c387d..1922fa0d 100644 --- a/libs/react-form/src/components.tsx +++ b/libs/react-form/src/components.tsx @@ -114,11 +114,7 @@ export function FormProvider< */ export function useField< TSchema extends ObjectSchemaBuilder, - TPropertySchema extends SchemaBuilder = SchemaBuilder< - any, - any, - any - > + TPropertySchema extends SchemaBuilder = any >( forProperty: ( tree: PropertyDescriptorTree diff --git a/libs/react-form/src/contexts.ts b/libs/react-form/src/contexts.ts index ac3da588..ddea0016 100644 --- a/libs/react-form/src/contexts.ts +++ b/libs/react-form/src/contexts.ts @@ -22,6 +22,7 @@ export type FormContextValue = { schema: ObjectSchemaBuilder; options: UseSchemaFormOptions; pathMap: Map, string>; + triggerValidation?: (markTouched: boolean) => Promise; }; /** diff --git a/libs/react-form/src/declarations.test.ts b/libs/react-form/src/declarations.test.ts new file mode 100644 index 00000000..32f9d9c1 --- /dev/null +++ b/libs/react-form/src/declarations.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment node + +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { expect, test } from 'vitest'; + +test('consumers can emit declarations for exported inferred form systems', () => { + const fixture = fileURLToPath( + new URL('../test-fixtures/exported-system.tsx', import.meta.url) + ); + const options: ts.CompilerOptions = { + declaration: true, + emitDeclarationOnly: true, + strict: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: ts.JsxEmit.ReactJSX + }; + const host = ts.createCompilerHost(options); + const output: string[] = []; + host.writeFile = (_fileName, content) => { + output.push(content); + }; + const program = ts.createProgram([fixture], options, host); + const diagnostics = [ + ...ts.getPreEmitDiagnostics(program), + ...program.emit().diagnostics + ]; + expect( + diagnostics.map(diagnostic => + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ) + ).toEqual([]); + expect(output.join('\n')).toContain('TypedFormSystem'); +}); diff --git a/libs/react-form/src/defaults.test-d.tsx b/libs/react-form/src/defaults.test-d.tsx new file mode 100644 index 00000000..6b00e011 --- /dev/null +++ b/libs/react-form/src/defaults.test-d.tsx @@ -0,0 +1,141 @@ +import { + array, + boolean, + date, + enumOf, + number, + object, + string +} from '@cleverbrush/schema'; +import { expectTypeOf, test } from 'vitest'; +import { + createFormSystem, + defineFieldRenderer, + Field, + type FieldRenderProps, + FormProvider, + useField, + useSchemaForm +} from './index.js'; + +const nameSchema = string().default('Untitled'); +const schema = object({ + name: nameSchema, + kind: enumOf('normal', 'offset').optional().default('normal'), + active: boolean().default(true), + count: number().default(1), + tags: array(string()).default(() => []), + when: date().default(() => new Date()), + optional: string().optional(), + nullable: string().nullable().default('Fallback') +}); +const system = createFormSystem({ + renderers: { + string: defineFieldRenderer(() => null), + 'string:select': defineFieldRenderer< + string, + { onSelect?: (value: string) => void } + >(() => null), + 'number:select': defineFieldRenderer< + number, + { onSelect?: (value: number) => void } + >(() => null), + boolean: defineFieldRenderer(() => null), + array: defineFieldRenderer(() => null), + date: defineFieldRenderer(() => null), + 'string:nullable': defineFieldRenderer(() => null) + } +}); + +test('defaulted properties retain their values in headless and rendered fields', () => { + const form = useSchemaForm(schema); + const name = form.useField(t => t.name); + const kind = form.useField(t => t.kind); + const active = form.useField(t => t.active); + const count = form.useField(t => t.count); + const tags = form.useField(t => t.tags); + const when = form.useField(t => t.when); + const optional = form.useField(t => t.optional); + const nullable = form.useField(t => t.nullable); + expectTypeOf(name.value).toEqualTypeOf(); + expectTypeOf(kind.value).toEqualTypeOf<'normal' | 'offset' | undefined>(); + expectTypeOf(active.value).toEqualTypeOf(); + expectTypeOf(count.value).toEqualTypeOf(); + expectTypeOf(tags.value).toEqualTypeOf(); + expectTypeOf(when.value).toEqualTypeOf(); + expectTypeOf(optional.value).toEqualTypeOf(); + expectTypeOf(nullable.value).toEqualTypeOf(); + name.setValue('New'); + kind.setValue('offset'); + active.setValue(false); + tags.setValue(['new']); + nullable.setValue(null); + // @ts-expect-error defaults must not erase the property's value type + name.setValue(1); + // @ts-expect-error enum remains narrow + kind.setValue('invalid'); + // @ts-expect-error array element types remain checked + tags.setValue([1]); + // @ts-expect-error default does not make a non-nullable string nullable + name.setValue(null); + t.name} />; + t.active} />; + t.tags} />; + t.when} />; + t.kind} />; + t.nullable} + variant="nullable" + />; + // @ts-expect-error nullable default requires a nullable renderer + t.nullable} />; + // @ts-expect-error numeric field cannot use a string variant + t.count} variant="nullable" />; + + t.name} /> + ; + useField(t => t.name); + const contextField = useField( + t => t.name + ); + expectTypeOf(contextField.value).toEqualTypeOf(); + form.handleSubmit(values => { + expectTypeOf(values.name).toEqualTypeOf(); + expectTypeOf(values.tags).toEqualTypeOf(); + expectTypeOf(values.active).toEqualTypeOf(); + }); + const rendererSchema: FieldRenderProps['schema'] = nameSchema; + expectTypeOf(rendererSchema).not.toBeNever(); +}); + +test('same-name variants check explicitly typed callbacks against the selected property', () => { + const form = useSchemaForm(schema); + t.name} + variant="select" + fieldProps={{ + onSelect: (value: string) => { + expectTypeOf(value).toEqualTypeOf(); + } + }} + />; + t.count} + variant="select" + fieldProps={{ + onSelect: (value: number) => { + expectTypeOf(value).toEqualTypeOf(); + } + }} + />; + t.name} + variant="select" + // @ts-expect-error callbacks must match the selected renderer's props + fieldProps={{ onSelect: (_value: number) => {} }} + />; +}); diff --git a/libs/react-form/src/defaults.test.tsx b/libs/react-form/src/defaults.test.tsx new file mode 100644 index 00000000..5eafcc24 --- /dev/null +++ b/libs/react-form/src/defaults.test.tsx @@ -0,0 +1,72 @@ +import { array, boolean, enumOf, object, string } from '@cleverbrush/schema'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import { useSchemaForm } from './index.js'; + +afterEach(cleanup); +const schema = object({ + name: string().default('Untitled'), + kind: enumOf('normal', 'offset').optional().default('normal'), + active: boolean().default(true), + tags: array(string()).default(() => []) +}); + +test('validation applies schema defaults to submitted values without pre-filling the form store', async () => { + const { result } = renderHook(() => useSchemaForm(schema)); + expect(result.current.getValue()).toEqual({}); + const save = vi.fn(); + await act(async () => { + await result.current.handleSubmit(save)(); + }); + expect(save).toHaveBeenCalledOnce(); + expect(save).toHaveBeenCalledWith({ + name: 'Untitled', + kind: 'normal', + active: true, + tags: [] + }); + expect(result.current.submitting).toBe(false); + expect(result.current.error).toBeUndefined(); +}); + +test('headless defaulted properties remain synchronized through reset and submission', async () => { + const { result } = renderHook(() => { + const form = useSchemaForm(schema); + return { + form, + name: form.useField(t => t.name), + kind: form.useField(t => t.kind), + active: form.useField(t => t.active), + tags: form.useField(t => t.tags) + }; + }); + act(() => + result.current.form.reset({ + name: 'Existing', + kind: 'offset', + active: false, + tags: ['one'] + }) + ); + expect(result.current.name.value).toBe('Existing'); + expect(result.current.kind.value).toBe('offset'); + expect(result.current.active.value).toBe(false); + expect(result.current.tags.value).toEqual(['one']); + expect(result.current.name.dirty).toBe(false); + act(() => result.current.name.onChange('Edited')); + expect(result.current.name.dirty).toBe(true); + const save = vi.fn(); + await act(async () => { + await result.current.form.handleSubmit(save)(); + }); + expect(save).toHaveBeenCalledWith({ + name: 'Edited', + kind: 'offset', + active: false, + tags: ['one'] + }); + act(() => result.current.form.reset()); + expect(result.current.name.value).toBeUndefined(); + expect(result.current.name.dirty).toBe(false); + expect(result.current.active.value).toBeUndefined(); +}); diff --git a/libs/react-form/src/helpers.ts b/libs/react-form/src/helpers.ts index 428a8875..73f35c69 100644 --- a/libs/react-form/src/helpers.ts +++ b/libs/react-form/src/helpers.ts @@ -59,7 +59,9 @@ export function getDescriptorPath( /** * Returns the schema type string (e.g. "string", "number", "object"). */ -export function getSchemaType(schema: SchemaBuilder): string { +export function getSchemaType( + schema: SchemaBuilder +): string { const introspected = schema.introspect(); return introspected?.type ?? 'unknown'; } diff --git a/libs/react-form/src/hooks.ts b/libs/react-form/src/hooks.ts index 019997a8..1d77d4e6 100644 --- a/libs/react-form/src/hooks.ts +++ b/libs/react-form/src/hooks.ts @@ -10,71 +10,70 @@ import { ObjectSchemaBuilder, SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR } from '@cleverbrush/schema'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore +} from 'react'; import type { FormContextValue } from './contexts.js'; -import { debounce } from './debounce.js'; import type { FormStore } from './FormStore.js'; import { createFormStore } from './FormStore.js'; import { buildDescriptorPathMap, - buildSelectorFromPath, ensureNestedStructure, - getDescriptorPath, getSchemaType } from './helpers.js'; import type { FieldRenderer, + FormSubmissionState, + FormSubmitHandler, + FormSubmitOptions, + FormSubmitResult, FormSystemConfig, UseFieldResult, UseSchemaFormOptions } from './types.js'; -// ─── SchemaFormInstance ────────────────────────────────────────────────────── - -/** - * Return type for useSchemaForm — fully typed for IntelliSense. - * The `useField` method infers the field value type from the schema via PropertyDescriptor. - */ +/** A stable form controller with reactive field and submission subscriptions. */ export type SchemaFormInstance< TSchema extends ObjectSchemaBuilder > = { - useField: >( + useField: >( forProperty: ( tree: PropertyDescriptorTree ) => PropertyDescriptor ) => UseFieldResult>; submit: () => Promise>>; validate: () => Promise>>; + /** Clear values, or establish supplied values as the new clean baseline. */ reset: (values?: Partial>) => void; + /** Read the current snapshot. Use setters rather than mutating it. */ getValue: () => InferType; + /** Shallow-merge values without marking fields touched. */ setValue: (values: Partial>) => void; - /** @internal — Used by FormProvider and Field to access internal context */ + /** Validate and submit once; repeated calls while pending are ignored. */ + handleSubmit: ( + onValid: ( + values: InferType + ) => FormSubmitResult | Promise>, + options?: FormSubmitOptions, TData> + ) => FormSubmitHandler; + /** @internal — Used by FormProvider and Field to access internal context. */ _getFormContext: () => FormContextValue; -}; - -// ─── useSchemaForm ─────────────────────────────────────────────────────────── +} & FormSubmissionState; -/** - * Hook that binds a schema to a form instance. - * Provides field binding API, form-level validation, submit, reset. - */ +/** Bind a schema to independently subscribed fields and a stable form instance. */ export function useSchemaForm< TSchema extends ObjectSchemaBuilder >( schema: TSchema, options?: UseSchemaFormOptions ): SchemaFormInstance { - const resolvedOptions: UseSchemaFormOptions = { - createMissingStructure: true, - ...options - }; - const storeRef = useRef(null); - if (!storeRef.current) { - storeRef.current = createFormStore({}); - } + if (!storeRef.current) storeRef.current = createFormStore({}); const store = storeRef.current; - const descriptorTreeRef = useRef; } const descriptorTree = descriptorTreeRef.current; - const pathMapRef = useRef, string @@ -94,206 +92,238 @@ export function useSchemaForm< pathMapRef.current = buildDescriptorPathMap(descriptorTree, schema); } const pathMap = pathMapRef.current; - + for (const [descriptor] of pathMap) { + store.registerField(descriptor.toJsonPointer(), descriptor); + } const schemaRef = useRef(schema); - const optionsRef = useRef(resolvedOptions); - optionsRef.current = resolvedOptions; - - const formContextValue = useMemo( - () => ({ - store, - descriptorTree, - schema: schemaRef.current, - options: optionsRef.current, - pathMap - }), - [store, descriptorTree, pathMap] - ); - - const formContextRef = useRef(formContextValue); - formContextRef.current = formContextValue; - - // Generation counter to discard stale validation results from concurrent runs + const optionsRef = useRef({}); + optionsRef.current = { createMissingStructure: true, ...options }; + const mountedRef = useRef(true); + const epochRef = useRef(0); const validationGenRef = useRef(0); + const submissionLockRef = useRef(false); + const timerRef = useRef | null>(null); + const cancelScheduledValidation = useCallback(() => { + if (timerRef.current !== null) clearTimeout(timerRef.current); + timerRef.current = null; + }, []); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + epochRef.current++; + validationGenRef.current++; + cancelScheduledValidation(); + }; + }, [cancelScheduledValidation]); - /** - * Runs full schema validation using getErrorsFor to extract per-field errors. - * Optionally marks all fields as touched (used by submit/explicit validate). - */ const runValidation = useCallback( async ( markTouched: boolean ): Promise>> => { + cancelScheduledValidation(); const gen = ++validationGenRef.current; - const values = store.getValues(); - // Ensure all nested object structures exist to prevent - // ObjectSchemaBuilder.validateAsync() from throwing on undefined nested objects - const safeValues = ensureNestedStructure(values, schemaRef.current); + const revision = store.getRevision(); + for (const path of store.getAllFieldPaths()) { + store.updateFieldState(path, { validating: true }); + } + const safeValues = ensureNestedStructure( + store.getValues(), + schemaRef.current + ); let result: ValidationResult>; try { result = (await schemaRef.current.validateAsync(safeValues, { doNotStopOnFirstError: true })) as ValidationResult>; } catch { - // If validation itself throws, treat as invalid - return { valid: false } as ValidationResult>; - } - - // Discard results if a newer validation has started since this one began - if (gen !== validationGenRef.current) { - return result as ValidationResult>; + result = { valid: false } as ValidationResult< + InferType + >; } - - // Clear all existing field errors - const allPaths = store.getAllFieldPaths(); - for (const p of allPaths) { - const patch: Partial<{ - error: string | undefined; - touched: boolean; - }> = { error: undefined }; - if (markTouched) { - patch.touched = true; - } - store.updateFieldState(p, patch); + // A value update invalidates validation immediately, even while the next + // validation is only scheduled (debounced) and has not started yet. + if ( + !mountedRef.current || + gen !== validationGenRef.current || + revision !== store.getRevision() + ) + return result; + + for (const path of store.getAllFieldPaths()) { + store.updateFieldState(path, { + error: undefined, + validating: false, + ...(markTouched ? { touched: true } : {}) + }); } - - // Use getErrorsFor to extract per-field errors via tree selectors - const resultWithErrors = result as ValidationResult< - InferType - > & { - getErrorsFor?: (selector: (t: any) => any) => { - errors: ReadonlyArray; - isValid: boolean; - }; - errors?: ReadonlyArray<{ message: string; path?: string }>; - }; - - // Build a map of errors found via getErrorsFor so we can detect gaps - const fieldsWithErrors = new Set(); - - if (typeof resultWithErrors.getErrorsFor === 'function') { - const getErrorsFor = resultWithErrors.getErrorsFor; - - // Extract per-field errors by building selectors from field paths - for (const [, path] of pathMap) { + const getErrorsFor = (result as any).getErrorsFor; + if (typeof getErrorsFor === 'function') { + for (const [descriptor] of pathMap) { + const path = descriptor.toJsonPointer(); + const parts = path + .slice(1) + .split('/') + .map(part => + part.replace(/~1/g, '/').replace(/~0/g, '~') + ); try { - const selector = buildSelectorFromPath(path); - const fieldResult = getErrorsFor(selector); + const field = getErrorsFor((tree: any) => + parts.reduce( + (value: any, key) => value?.[key], + tree + ) + ); if ( - fieldResult && - Array.isArray(fieldResult.errors) && - fieldResult.errors.length > 0 + Array.isArray(field?.errors) && + field.errors.length ) { - const errorMessage = fieldResult.errors[0]; - const patch: Partial<{ - error: string | undefined; - touched: boolean; - }> = { error: errorMessage }; - if (markTouched) { - patch.touched = true; - } - store.updateFieldState(path, patch); - fieldsWithErrors.add(path); + store.updateFieldState(path, { + error: field.errors[0] + }); } } catch { - // If getErrorsFor fails for this path, skip + // A missing error selector must not prevent other fields + // from receiving their validation results. } } } - - return result as ValidationResult>; + return result; }, - [store, pathMap] + [cancelScheduledValidation, store, pathMap] ); - const validate = useCallback(async (): Promise< - ValidationResult> - > => { - return runValidation(true); - }, [runValidation]); - - const submit = useCallback(async (): Promise< - ValidationResult> - > => { - return validate(); - }, [validate]); - + const validate = useCallback(() => runValidation(true), [runValidation]); + const submit = useCallback(() => validate(), [validate]); const reset = useCallback( (values?: Partial>) => { - const newValues = values ?? {}; - store.resetAll(newValues); + epochRef.current++; + validationGenRef.current++; + cancelScheduledValidation(); + store.resetAll(values); }, + [store, cancelScheduledValidation] + ); + const getValue = useCallback( + (): InferType => store.getValues(), [store] ); - - const getValue = useCallback((): InferType => { - return store.getValues(); - }, [store]); - - const setValueFn = useCallback( + const setValue = useCallback( (values: Partial>) => { - const currentValues = store.getValues(); - const merged = { ...currentValues, ...values }; - store.setValues(merged); - store.notifyAll(); + cancelScheduledValidation(); + store.setValues({ ...store.getValues(), ...values }); }, - [store] + [store, cancelScheduledValidation] ); - - const _getFormContext = useCallback(() => formContextRef.current, []); - - // Validate on mount when requested — runs once after first render - const validateOnMountRef = useRef(resolvedOptions.validateOnMount); - // biome-ignore lint/correctness/useExhaustiveDependencies: We only want to check validateOnMount on the initial mount, ignoring changes to it after that - useEffect(() => { - if (validateOnMountRef.current) { - runValidation(true); - } - }, []); - - // Create a debounced version of runValidation for onChange triggers. - // validate(), submit(), and validateOnMount always use runValidation directly. - const debouncedValidationRef = useRef< - ((markTouched: boolean) => void) | null - >(null); - if ( - resolvedOptions.validationDebounceMs != null && - resolvedOptions.validationDebounceMs > 0 && - !debouncedValidationRef.current - ) { - debouncedValidationRef.current = debounce((markTouched: boolean) => { - runValidation(markTouched); - }, resolvedOptions.validationDebounceMs); - } - const triggerValidation = useCallback( (markTouched: boolean) => { - if (debouncedValidationRef.current) { - debouncedValidationRef.current(markTouched); - return Promise.resolve( - undefined as unknown as ValidationResult> - ); + cancelScheduledValidation(); + const delay = optionsRef.current.validationDebounceMs; + if (delay != null && delay > 0) { + timerRef.current = setTimeout(() => { + timerRef.current = null; + void runValidation(markTouched); + }, delay); + return Promise.resolve(); } return runValidation(markTouched); }, - [runValidation] + [runValidation, cancelScheduledValidation] + ); + + const handleSubmit = useCallback( + ( + onValid: ( + values: InferType + ) => FormSubmitResult | Promise>, + submissionOptions?: FormSubmitOptions, TData> + ): FormSubmitHandler => + async event => { + event?.preventDefault(); + if (submissionLockRef.current || !mountedRef.current) return; + submissionLockRef.current = true; + const epoch = epochRef.current; + const current = () => + mountedRef.current && epoch === epochRef.current; + store.setSubmissionState({ + submitting: true, + error: undefined + }); + try { + const revision = store.getRevision(); + const result = await runValidation(true); + if ( + !current() || + revision !== store.getRevision() || + !result.valid || + result.object === undefined + ) + return; + const values = result.object; + let outcome: FormSubmitResult; + try { + outcome = await onValid(values); + } catch (error) { + if (!current()) return; + if (!submissionOptions?.onError) throw error; + const message = await submissionOptions.onError(error); + if (current()) + store.setSubmissionState({ error: message }); + return; + } + if (!current()) return; + if (outcome && !outcome.ok) { + store.setSubmissionState({ error: outcome.error }); + return; + } + // Errors from success callbacks (including redirects) propagate; + // they are not translated as failures of a completed submission. + await submissionOptions?.onSuccess?.(outcome?.data, values); + } finally { + submissionLockRef.current = false; + if (mountedRef.current) + store.setSubmissionState({ submitting: false }); + } + }, + [runValidation, store] ); + const formContextValue = useMemo( + () => ({ + store, + descriptorTree, + schema: schemaRef.current, + pathMap, + get options() { + return optionsRef.current; + }, + triggerValidation + }), + [store, descriptorTree, pathMap, triggerValidation] + ); + const formContextRef = useRef(formContextValue); + formContextRef.current = formContextValue; + const _getFormContext = useCallback(() => formContextRef.current, []); const useFieldHook = useCallback( - >( - forProperty: ( + >( + selector: ( tree: PropertyDescriptorTree ) => PropertyDescriptor - ): UseFieldResult> => { - return useFieldFromContext( - formContextRef.current, - forProperty, - triggerValidation - ) as UseFieldResult>; - }, - [triggerValidation] + ): UseFieldResult> => + useFieldFromContext(formContextRef.current, selector), + [] ); + const validateOnMountRef = useRef(options?.validateOnMount); + useEffect(() => { + if (validateOnMountRef.current) void runValidation(true); + }, [runValidation]); + // Subscribe to submission state without changing the controller's identity. + useSyncExternalStore( + store.subscribeGlobal, + store.getSubmissionState, + store.getSubmissionState + ); return useMemo( () => ({ useField: useFieldHook, @@ -301,8 +331,15 @@ export function useSchemaForm< validate, reset, getValue, - setValue: setValueFn, - _getFormContext + setValue, + handleSubmit, + _getFormContext, + get submitting() { + return store.getSubmissionState().submitting; + }, + get error() { + return store.getSubmissionState().error; + } }), [ useFieldHook, @@ -310,122 +347,72 @@ export function useSchemaForm< validate, reset, getValue, - setValueFn, - _getFormContext + setValue, + handleSubmit, + _getFormContext, + store ] ); } -// ─── useField (from context) ───────────────────────────────────────────────── - -/** - * Internal useField implementation, requires FormContextValue. - * Returns UseFieldResult with untyped values — callers should cast to the proper generic type. - */ +/** Internal field binding shared by direct and context-based hooks. */ export function useFieldFromContext( formContext: FormContextValue, forProperty: (tree: any) => any, - triggerValidation?: (markTouched: boolean) => Promise + triggerValidation = formContext.triggerValidation ): UseFieldResult { - const { store, descriptorTree, options, pathMap } = formContext; - - const descriptor = forProperty(descriptorTree as any); - const inner = descriptor[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR]; - const path = getDescriptorPath(inner, pathMap); - const fieldSchema = inner.getSchema(); - - // Initialize field state from current values on first access - const initializedRef = useRef(null); - - if (initializedRef.current !== path) { - const values = store.getValues(); - const { success, value } = inner.getValue(values); - const currentState = store.getFieldState(path); - if (currentState.initialValue === undefined && !currentState.touched) { - const resolvedValue = success ? value : undefined; - store.updateFieldState(path, { - value: resolvedValue, - initialValue: resolvedValue, - dirty: false - }); - } - initializedRef.current = path; - } - - const [, setRenderTick] = useState(0); - - // Subscribe to field changes with proper cleanup on unmount/path change - useEffect(() => { - const unsub = store.subscribe(path, () => { - setRenderTick(c => c + 1); - }); - return unsub; - }, [store, path]); - + const { store, descriptorTree, options } = formContext; + const inner = + forProperty(descriptorTree)[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR]; + const path = inner.toJsonPointer(); + store.registerField(path, inner); + const subscribe = useCallback( + (listener: () => void) => store.subscribe(path, listener), + [store, path] + ); + const getSnapshot = useCallback( + () => store.getFieldState(path), + [store, path] + ); + const fieldState = useSyncExternalStore( + subscribe, + getSnapshot, + getSnapshot + ); const onChange = useCallback( (value: any) => { - const values = store.getValues(); - inner.setValue(values, value, { - createMissingStructure: options.createMissingStructure !== false - }); - store.setValues(values); - const currentState = store.getFieldState(path); - store.updateFieldState(path, { + store.setFieldValue( + path, value, - dirty: value !== currentState.initialValue - }); - // Run validation on every field change (without marking all fields touched) - if (triggerValidation) { - triggerValidation(false); - } + options.createMissingStructure !== false + ); + void triggerValidation?.(false); }, - [store, inner, path, options, triggerValidation] + [store, path, options.createMissingStructure, triggerValidation] ); - - const onBlur = useCallback(() => { - store.updateFieldState(path, { touched: true }); - }, [store, path]); - - const setValue = useCallback( - (value: any) => { - onChange(value); - }, - [onChange] + const onBlur = useCallback( + () => store.updateFieldState(path, { touched: true }), + [store, path] ); - - const fieldState = store.getFieldState(path); - return { - value: fieldState.value, - initialValue: fieldState.initialValue, - dirty: fieldState.dirty, - touched: fieldState.touched, - error: fieldState.error, - validating: fieldState.validating, + ...fieldState, onChange, onBlur, - setValue, - schema: fieldSchema + setValue: onChange, + schema: inner.getSchema() }; } -/** - * Resolves a renderer from the FormSystem config based on schema type and optional variant. - * - * When `variant` is provided the registry is checked for `"type:variant"` first - * (e.g. `"string:password"`). If no match is found it falls back to the base - * `"type"` key (e.g. `"string"`). - */ +/** Resolve type:variant first, falling back to the base type. */ export function resolveRenderer( config: FormSystemConfig | null, - schema: SchemaBuilder, + schema: SchemaBuilder, variant?: string ): FieldRenderer | undefined { if (!config?.renderers) return undefined; const type = getSchemaType(schema); - if (variant) { - const variantRenderer = config.renderers[`${type}:${variant}`]; - if (variantRenderer) return variantRenderer; - } - return config.renderers[type]; + return ( + (variant ? config.renderers[`${type}:${variant}`] : undefined) ?? + config.renderers[type] + ); } diff --git a/libs/react-form/src/index.ts b/libs/react-form/src/index.ts index eb479c21..60aeebc1 100644 --- a/libs/react-form/src/index.ts +++ b/libs/react-form/src/index.ts @@ -18,10 +18,22 @@ export { export type { SchemaFormInstance } from './hooks.js'; // Hooks export { useSchemaForm } from './hooks.js'; +export type { + TypedFieldComponent, + TypedFieldProps, + TypedFieldRenderer, + TypedFormSystem, + TypedRendererRegistry +} from './system.js'; +export { createFormSystem, defineFieldRenderer } from './system.js'; export type { FieldRenderer, FieldRenderProps, FieldState, + FormSubmissionState, + FormSubmitHandler, + FormSubmitOptions, + FormSubmitResult, FormSystemConfig, UseFieldResult, UseSchemaFormOptions diff --git a/libs/react-form/src/lifecycle.test.tsx b/libs/react-form/src/lifecycle.test.tsx new file mode 100644 index 00000000..38416931 --- /dev/null +++ b/libs/react-form/src/lifecycle.test.tsx @@ -0,0 +1,439 @@ +import { array, boolean, object, string } from '@cleverbrush/schema'; +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen +} from '@testing-library/react'; +import { StrictMode, useEffect } from 'react'; +import { renderToString } from 'react-dom/server'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { FormProvider, useField, useSchemaForm } from './index.js'; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); +const schema = object({ + name: string().minLength(1), + active: boolean(), + tags: array(string()), + nested: object({ city: string() }) +}); +const baseline = { + name: 'Ada', + active: true, + tags: ['a'], + nested: { city: 'Paris' } +}; +function useBoundForm() { + const form = useSchemaForm(schema); + return { + form, + name: form.useField(t => t.name), + active: form.useField(t => t.active), + tags: form.useField(t => t.tags), + parent: form.useField(t => t.nested), + city: form.useField(t => t.nested.city) + }; +} +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +describe('synchronized mounted fields', () => { + test('reset updates values and baselines without remounting', () => { + const { result } = renderHook(useBoundForm); + const instance = result.current.form; + act(() => result.current.form.reset(baseline)); + expect(result.current.form).toBe(instance); + expect(result.current.name.value).toBe('Ada'); + expect(result.current.name.initialValue).toBe('Ada'); + expect(result.current.active.value).toBe(true); + expect(result.current.tags.value).toEqual(['a']); + expect(result.current.city.value).toBe('Paris'); + expect(result.current.city.dirty).toBe(false); + act(() => { + result.current.name.onChange('Grace'); + result.current.name.onBlur(); + }); + act(() => result.current.form.reset(baseline)); + expect(result.current.name).toMatchObject({ + value: 'Ada', + dirty: false, + touched: false, + error: undefined + }); + act(() => result.current.form.reset()); + expect(result.current.form.getValue()).toEqual({}); + expect(result.current.city.value).toBeUndefined(); + }); + test('form and field setters synchronize parent, child and array bindings', () => { + const { result } = renderHook(useBoundForm); + act(() => result.current.form.reset(baseline)); + act(() => + result.current.form.setValue({ + nested: { city: 'Berlin' }, + tags: ['b'] + }) + ); + expect(result.current.city.value).toBe('Berlin'); + expect(result.current.parent.value).toEqual({ city: 'Berlin' }); + expect(result.current.city.touched).toBe(false); + expect(result.current.city.dirty).toBe(true); + expect(result.current.name.value).toBe('Ada'); + act(() => result.current.city.onChange('Rome')); + expect(result.current.parent.value).toEqual({ city: 'Rome' }); + expect(result.current.form.getValue().nested.city).toBe('Rome'); + expect(result.current.parent.initialValue).toEqual({ city: 'Paris' }); + act(() => result.current.tags.setValue(['a'])); + expect(result.current.tags.dirty).toBe(false); + }); + test('caller-owned reset objects cannot mutate the baseline', () => { + const { result } = renderHook(useBoundForm); + const values = { ...baseline, nested: { city: 'Paris' }, tags: ['a'] }; + act(() => result.current.form.reset(values)); + values.nested.city = 'Elsewhere'; + values.tags.push('b'); + expect(result.current.city.initialValue).toBe('Paris'); + expect(result.current.tags.value).toEqual(['a']); + }); + test('property names containing dots and slashes remain distinct', () => { + const special = object({ + 'a.b': string(), + 'a/b': string(), + a: object({ b: string() }) + }); + const { result } = renderHook(() => { + const form = useSchemaForm(special); + return { + form, + dotted: form.useField(t => t['a.b']), + slash: form.useField(t => t['a/b']), + nested: form.useField(t => t.a.b) + }; + }); + act(() => + result.current.form.reset({ + 'a.b': 'dot', + 'a/b': 'slash', + a: { b: 'nested' } + }) + ); + expect(result.current.dotted.value).toBe('dot'); + expect(result.current.slash.value).toBe('slash'); + expect(result.current.nested.value).toBe('nested'); + }); + test('text, select and checkbox controls follow programmatic updates in Strict Mode', () => { + function Controls() { + const { form, name, city, active } = useBoundForm(); + useEffect(() => form.reset(baseline), [form]); + return ( + <> + name.onChange(e.target.value)} + /> + + active.onChange(e.target.checked)} + /> + + + ); + } + render( + + + + ); + fireEvent.click(screen.getByText('Update')); + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe( + 'Grace' + ); + expect((screen.getByLabelText('City') as HTMLSelectElement).value).toBe( + 'Berlin' + ); + expect( + (screen.getByLabelText('Active') as HTMLInputElement).checked + ).toBe(false); + }); + test('server rendering supplies a stable server snapshot', () => { + function ServerForm() { + const form = useSchemaForm(object({ name: string() })); + const field = form.useField(t => t.name); + return ( + + ); + } + expect(renderToString()).toContain('value=""'); + }); + test('context-based fields share values and validation with direct hooks', async () => { + const simple = object({ name: string().minLength(2) }); + function Child() { + const name = useField(t => t.name); + return ( + <> + name.onChange(e.target.value)} + /> + {name.error} + + ); + } + function Parent() { + const form = useSchemaForm(simple); + return ( + + + + ); + } + render(); + await act(async () => + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'x' } + }) + ); + expect(screen.getByLabelText('Name').nextSibling?.textContent).not.toBe( + '' + ); + }); +}); + +describe('validation generations', () => { + test.each([ + 'reset', + 'setValue', + 'onChange' + ] as const)('ignores old async results after %s', async action => { + vi.useFakeTimers(); + const pending = deferred<{ + valid: boolean; + errors: { message: string }[]; + }>(); + const asyncSchema = object({ + name: string().addValidator(() => pending.promise) + }); + const { result } = renderHook(() => { + const form = useSchemaForm(asyncSchema, { + validationDebounceMs: 100 + }); + return { form, field: form.useField(t => t.name) }; + }); + act(() => result.current.form.reset({ name: 'old' })); + let validation!: ReturnType; + act(() => { + validation = result.current.form.validate(); + }); + act(() => { + if (action === 'onChange') result.current.field.onChange('new'); + else result.current.form[action]({ name: 'new' }); + }); + await act(async () => { + pending.resolve({ + valid: false, + errors: [{ message: 'obsolete' }] + }); + await validation; + }); + expect(result.current.field.value).toBe('new'); + expect(result.current.field.error).toBeUndefined(); + expect(result.current.field.validating).toBe(false); + }); + test('reset and unmount cancel scheduled validation', async () => { + vi.useFakeTimers(); + const validator = vi.fn(() => ({ valid: true })); + const simple = object({ name: string().addValidator(validator) }); + const { result, unmount } = renderHook(() => { + const form = useSchemaForm(simple, { validationDebounceMs: 100 }); + return { form, field: form.useField(t => t.name) }; + }); + act(() => { + result.current.field.onChange('one'); + result.current.form.reset(); + }); + await act(() => vi.advanceTimersByTimeAsync(101)); + expect(validator).not.toHaveBeenCalled(); + act(() => result.current.field.onChange('two')); + unmount(); + await vi.advanceTimersByTimeAsync(101); + expect(validator).not.toHaveBeenCalled(); + }); +}); + +describe('submission lifecycle', () => { + const simple = object({ name: string().minLength(1) }); + function ready() { + const hook = renderHook(() => useSchemaForm(simple)); + act(() => hook.result.current.reset({ name: 'Ada' })); + return hook; + } + test('exposes reactive state with stable identity and prevents double submits', async () => { + const { result } = ready(); + const form = result.current; + const pending = deferred<{ ok: true; data: number }>(); + const send = vi.fn(() => pending.promise); + const success = vi.fn(); + const handler = form.handleSubmit(send, { onSuccess: success }); + const preventDefault = vi.fn(); + let first!: Promise; + await act(async () => { + first = handler({ preventDefault }); + await handler(); + }); + expect(result.current).toBe(form); + expect(result.current.submitting).toBe(true); + expect(send).toHaveBeenCalledTimes(1); + expect(preventDefault).toHaveBeenCalledTimes(1); + await act(async () => { + pending.resolve({ ok: true, data: 42 }); + await first; + }); + expect(success).toHaveBeenCalledWith(42, { name: 'Ada' }); + expect(result.current.submitting).toBe(false); + }); + test('invalid values never reach the submit callback', async () => { + const { result } = ready(); + const send = vi.fn(); + act(() => result.current.setValue({ name: '' })); + await act(() => result.current.handleSubmit(send)()); + expect(send).not.toHaveBeenCalled(); + expect(result.current.submitting).toBe(false); + }); + test('explicit and translated failures preserve input; retry clears the error', async () => { + const { result } = ready(); + const success = vi.fn(); + await act(() => + result.current.handleSubmit( + () => ({ ok: false, error: 'Rejected' }), + { onSuccess: success } + )() + ); + expect(result.current.error).toBe('Rejected'); + expect(result.current.getValue()).toEqual({ name: 'Ada' }); + expect(success).not.toHaveBeenCalled(); + await act(() => + result.current.handleSubmit( + () => { + throw new Error('offline'); + }, + { onError: () => 'Try again' } + )() + ); + expect(result.current.error).toBe('Try again'); + await act(() => + result.current.handleSubmit(() => undefined, { + onSuccess: success + })() + ); + expect(result.current.error).toBeUndefined(); + expect(success).toHaveBeenCalledTimes(1); + }); + test('unhandled errors and callback rethrows propagate', async () => { + const { result } = ready(); + const redirect = new Error('application redirect'); + await act(async () => { + await expect( + result.current.handleSubmit(() => { + throw redirect; + })() + ).rejects.toBe(redirect); + await expect( + result.current.handleSubmit( + () => { + throw redirect; + }, + { + onError: error => { + throw error; + } + } + )() + ).rejects.toBe(redirect); + await expect( + result.current.handleSubmit(() => undefined, { + onSuccess: () => { + throw redirect; + } + })() + ).rejects.toBe(redirect); + }); + expect(result.current.submitting).toBe(false); + }); + test.each([ + 'reset', + 'unmount' + ] as const)('ignores obsolete success after %s', async action => { + const { result, unmount } = ready(); + const pending = deferred(); + const success = vi.fn(); + let submitted!: Promise; + await act(async () => { + submitted = result.current.handleSubmit(() => pending.promise, { + onSuccess: success + })(); + }); + if (action === 'reset') + act(() => result.current.reset({ name: 'New' })); + else unmount(); + await act(async () => { + pending.resolve(); + await submitted; + }); + expect(success).not.toHaveBeenCalled(); + if (action === 'reset') + expect(result.current.getValue()).toEqual({ name: 'New' }); + }); + test('does not dispatch values changed during asynchronous validation', async () => { + const pending = deferred<{ valid: true }>(); + const guarded = object({ + name: string().addValidator(() => pending.promise) + }); + const { result } = renderHook(() => useSchemaForm(guarded)); + act(() => result.current.reset({ name: 'Old' })); + const send = vi.fn(); + let submitted!: Promise; + act(() => { + submitted = result.current.handleSubmit(send)(); + }); + act(() => result.current.setValue({ name: 'New' })); + await act(async () => { + pending.resolve({ valid: true }); + await submitted; + }); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/react-form/src/react.test.tsx b/libs/react-form/src/react.test.tsx index 403abb74..73c3b8f8 100644 --- a/libs/react-form/src/react.test.tsx +++ b/libs/react-form/src/react.test.tsx @@ -357,8 +357,8 @@ describe('nested fields', () => { result.current.cityField.onChange('Berlin'); }); - // The field value in state is still updated - expect(result.current.cityField.value).toBe('Berlin'); + // A rejected setter must not display a value missing from form data. + expect(result.current.cityField.value).toBeUndefined(); }); }); diff --git a/libs/react-form/src/system.test-d.tsx b/libs/react-form/src/system.test-d.tsx new file mode 100644 index 00000000..fac3a739 --- /dev/null +++ b/libs/react-form/src/system.test-d.tsx @@ -0,0 +1,148 @@ +import { array, boolean, number, object, string } from '@cleverbrush/schema'; +import { expectTypeOf, test } from 'vitest'; +import { + createFormSystem, + defineFieldRenderer, + Field, + type FieldRenderer, + type FieldRenderProps, + useSchemaForm +} from './index.js'; + +const schema = object({ + name: string(), + age: number(), + active: boolean(), + tags: array(string()), + optional: string().optional(), + nullable: string().nullable(), + nested: object({ name: string() }) +}); +const system = createFormSystem({ + renderers: { + string: defineFieldRenderer( + () => null + ), + 'string:select': defineFieldRenderer< + string, + { + options: readonly string[]; + disabled?: boolean; + } + >(() => null), + 'boolean:checkbox': defineFieldRenderer(() => null), + 'array:tags': defineFieldRenderer( + () => null + ), + 'string:nullable': defineFieldRenderer(() => null) + } +}); + +test('typed fields preserve schema and renderer props', () => { + const form = useSchemaForm(schema); + t.name} + fieldProps={{ placeholder: 'Name' }} + />; + t.optional} />; + t.nested.name} />; + t.name} + variant="select" + fieldProps={{ options: ['a'] }} + />; + t.active} variant="checkbox" />; + t.tags} + variant="tags" + fieldProps={{ choices: ['a'] }} + />; + t.nullable} + variant="nullable" + />; + t.name} + // @ts-expect-error misspelled custom prop + fieldProps={{ placehoder: 'Name' }} + />; + // @ts-expect-error required options missing + t.name} variant="select" />; + t.name} + variant="select" + // @ts-expect-error options must be strings + fieldProps={{ options: [1] }} + />; + // @ts-expect-error variant does not exist for strings + t.name} variant="checkbox" />; + // @ts-expect-error no numeric renderer registered + t.age} />; + // @ts-expect-error this renderer does not support null + t.nullable} />; + // @ts-expect-error invalid schema property + t.missing} />; +}); + +test('registry extensions and legacy integrations remain supported', () => { + const extended = createFormSystem({ + renderers: { + ...system.renderers, + 'number:slider': defineFieldRenderer( + () => null + ) + } + }); + const form = useSchemaForm(schema); + t.age} + variant="slider" + fieldProps={{ min: 0 }} + />; + t.name} />; + const legacy: FieldRenderer = (props: FieldRenderProps) => props.value; + t.name} + renderer={legacy} + fieldProps={{ custom: true }} + />; +}); + +test('renderer values and submission callbacks infer correctly', () => { + defineFieldRenderer(props => { + expectTypeOf(props.value).toEqualTypeOf(); + expectTypeOf(props.fieldProps).toEqualTypeOf< + { step: number } | undefined + >(); + props.onChange(1); + // @ts-expect-error renderer cannot write strings + props.onChange('1'); + return null; + }); + const form = useSchemaForm(schema); + form.handleSubmit( + async values => { + expectTypeOf(values.name).toEqualTypeOf(); + return { ok: true, data: { id: 1 } }; + }, + { + onSuccess: data => { + expectTypeOf(data).toEqualTypeOf<{ id: number } | undefined>(); + } + } + ); + form.handleSubmit(() => ({ ok: false, error: 'Try again' })); + form.handleSubmit(() => undefined); + // @ts-expect-error errors are displayable strings + form.handleSubmit(() => ({ ok: false, error: 123 })); + expectTypeOf(form.submitting).toEqualTypeOf(); + expectTypeOf(form.error).toEqualTypeOf(); +}); diff --git a/libs/react-form/src/system.test.tsx b/libs/react-form/src/system.test.tsx new file mode 100644 index 00000000..34149013 --- /dev/null +++ b/libs/react-form/src/system.test.tsx @@ -0,0 +1,121 @@ +import { boolean, object, string } from '@cleverbrush/schema'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, test } from 'vitest'; +import { Field, FormSystemProvider, useSchemaForm } from './index.js'; +import { createFormSystem, defineFieldRenderer } from './system.js'; + +afterEach(cleanup); +const text = defineFieldRenderer(props => ( + props.onChange(event.target.value)} + /> +)); +const base = createFormSystem({ renderers: { string: text } }); +const extended = createFormSystem({ + renderers: { + ...base.renderers, + 'string:select': defineFieldRenderer( + props => ( + + ) + ), + boolean: defineFieldRenderer(props => ( + props.onChange(event.target.checked)} + /> + )) + } +}); + +test('typed registries pass custom props and synchronize controls without a provider', () => { + function Demo() { + const form = useSchemaForm( + object({ name: string(), choice: string(), active: boolean() }) + ); + return ( + <> + t.name} + fieldProps={{ id: 'Name' }} + /> + t.choice} + variant="select" + fieldProps={{ options: ['one', 'two'] }} + /> + t.active} /> + + + ); + } + render(); + fireEvent.click(screen.getByText('Reset')); + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe( + 'Ada' + ); + expect((screen.getByLabelText('Choice') as HTMLSelectElement).value).toBe( + 'two' + ); + expect((screen.getByLabelText('Active') as HTMLInputElement).checked).toBe( + true + ); +}); + +test('Provider supports legacy nesting without overriding a typed Field contract', () => { + function Demo() { + const form = useSchemaForm(object({ name: string() })); + return ( + + t.name} + fieldProps={{ id: 'Legacy' }} + /> + Inner legacy renderer + }} + > + t.name} /> + t.name} + fieldProps={{ id: 'Typed' }} + /> + + + ); + } + render(); + expect(screen.getByLabelText('Legacy')).toBeTruthy(); + expect(screen.getByText('Inner legacy renderer')).toBeTruthy(); + fireEvent.change(screen.getByLabelText('Typed'), { + target: { value: 'shared' } + }); + expect((screen.getByLabelText('Legacy') as HTMLInputElement).value).toBe( + 'shared' + ); +}); diff --git a/libs/react-form/src/system.tsx b/libs/react-form/src/system.tsx new file mode 100644 index 00000000..67d3a85b --- /dev/null +++ b/libs/react-form/src/system.tsx @@ -0,0 +1,150 @@ +import type { + InferType, + ObjectSchemaBuilder, + PropertyDescriptorTree, + SchemaBuilder +} from '@cleverbrush/schema'; +import { SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR } from '@cleverbrush/schema'; +import type { ReactNode } from 'react'; +import { Field, type FieldProps, FormSystemProvider } from './components.js'; +import { getSchemaType } from './helpers.js'; +import type { SchemaFormInstance } from './hooks.js'; +import type { FieldRenderer } from './types.js'; + +declare const rendererTypes: unique symbol; + +/** A renderer whose accepted values and custom props survive registration. */ +export type TypedFieldRenderer = FieldRenderer< + TValue, + TProps +> & { + readonly [rendererTypes]: { value: TValue; props: TProps }; +}; + +/** + * Attach type information without adding a UI dependency or runtime wrapper. + * Include null in TValue when the renderer supports nullable fields. + */ +export function defineFieldRenderer>( + renderer: FieldRenderer +): TypedFieldRenderer { + return renderer as TypedFieldRenderer; +} + +export type TypedRendererRegistry = Readonly< + Record> +>; + +type ValueOf = T extends TypedFieldRenderer ? V : never; +type PropsOf = T extends TypedFieldRenderer ? P : never; +type KindOf = 0 extends 1 & T + ? string + : [NonNullable] extends [string] + ? 'string' + : [NonNullable] extends [number] + ? 'number' + : [NonNullable] extends [boolean] + ? 'boolean' + : [NonNullable] extends [Date] + ? 'date' + : [NonNullable] extends [readonly unknown[]] + ? 'array' + : [NonNullable] extends [object] + ? 'object' + : never; +type CustomProps

= {} extends P ? { fieldProps?: P } : { fieldProps: P }; +type RendererChoice = { + [K in keyof R & string]: [Exclude] extends [ValueOf] + ? K extends `${KindOf}:${infer Variant}` + ? { variant: Variant } & CustomProps> + : K extends KindOf + ? { variant?: undefined } & CustomProps> + : never + : never; +}[keyof R & string]; + +type FieldDescriptor = { + readonly [SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR]: { + getSchema: () => SchemaBuilder; + }; +}; +type DescriptorValue = InferType< + ReturnType +>; + +/** Props are inferred from the selected schema property and registered variant. */ +export type TypedFieldProps< + R extends TypedRendererRegistry, + TSchema extends ObjectSchemaBuilder, + TDescriptor extends FieldDescriptor +> = { + form: SchemaFormInstance; + forProperty: ( + tree: PropertyDescriptorTree + ) => TDescriptor; + label?: string; + name?: string; +} & RendererChoice>>; + +/** Named callable type also supports exporting system.Field from UI packages. */ +export type TypedFieldComponent = < + TSchema extends ObjectSchemaBuilder, + TDescriptor extends FieldDescriptor +>( + props: TypedFieldProps +) => ReactNode; + +/** Named return type keeps exported consumer registries declaration-safe. */ +export type TypedFormSystem = { + Field: TypedFieldComponent; + Provider: (props: { children: ReactNode }) => ReactNode; + renderers: Readonly; +}; + +/** + * Create a typed Field and a provider for an application's renderer registry. + * The typed Field resolves from this factory's closed registry, so an untyped + * ancestor provider cannot silently replace a renderer with incompatible props. + * The Provider also configures legacy Field consumers and supports nesting. + * Extend a system with createFormSystem({ renderers: { ...system.renderers, + * 'string:custom': customRenderer } }). Existing global APIs remain available. + */ +export function createFormSystem< + const R extends TypedRendererRegistry +>(config: { renderers: R }): TypedFormSystem { + const renderers = Object.freeze({ ...config.renderers }); + function TypedField< + TSchema extends ObjectSchemaBuilder, + TDescriptor extends FieldDescriptor + >(props: TypedFieldProps): ReactNode { + const descriptor = props.forProperty( + props.form._getFormContext() + .descriptorTree as PropertyDescriptorTree + ); + const type = getSchemaType( + descriptor[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR].getSchema() + ); + const key = props.variant ? `${type}:${props.variant}` : type; + const renderer = renderers[key]; + if (!renderer) + throw new Error( + `No renderer registered for "${key}" in this form system.` + ); + // Field's legacy signature erases descriptor parent types. The factory + // has already checked the selected value and renderer-specific props. + return ( + )} + renderer={renderer} + /> + ); + } + function Provider({ children }: { children: ReactNode }): ReactNode { + return ( + + {children} + + ); + } + return { Field: TypedField, Provider, renderers }; +} diff --git a/libs/react-form/src/types.ts b/libs/react-form/src/types.ts index 5a2f2471..77f20191 100644 --- a/libs/react-form/src/types.ts +++ b/libs/react-form/src/types.ts @@ -1,25 +1,27 @@ import type { SchemaBuilder } from '@cleverbrush/schema'; -import type { ReactNode } from 'react'; +import type { FormEvent, ReactNode } from 'react'; /** * A renderer function that receives field state and returns a React node. */ -export type FieldRenderer = (props: FieldRenderProps) => ReactNode; +export type FieldRenderer> = ( + props: FieldRenderProps +) => ReactNode; /** * Props passed to a field renderer. */ -export type FieldRenderProps = { - value: any; - initialValue: any; +export type FieldRenderProps> = { + value: TValue | undefined; + initialValue: TValue | undefined; dirty: boolean; touched: boolean; error: string | undefined; validating: boolean; - onChange: (value: any) => void; + onChange: (value: TValue) => void; onBlur: () => void; - setValue: (value: any) => void; - schema: SchemaBuilder; + setValue: (value: TValue) => void; + schema: SchemaBuilder; /** * Rendering variant hint passed from the `Field` component. * Used by renderers to select a sub-variant of the base schema type @@ -69,7 +71,7 @@ export type FieldRenderProps = { * /> * ``` */ - fieldProps?: Record; + fieldProps?: TProps; }; /** @@ -105,7 +107,7 @@ export type UseFieldResult = { onChange: (value: T) => void; onBlur: () => void; setValue: (value: T) => void; - schema: SchemaBuilder; + schema: SchemaBuilder; }; /** @@ -136,3 +138,28 @@ export type UseSchemaFormOptions = { */ validationDebounceMs?: number; }; + +/** Reactive state for handleSubmit; independent of any UI kit. */ +export type FormSubmissionState = { + readonly submitting: boolean; + readonly error: string | undefined; +}; + +/** Void denotes success without data; explicit failures preserve user input. */ +export type FormSubmitResult = + // biome-ignore lint/suspicious/noConfusingVoidType: callbacks returning void are valid successful submissions + void | { ok: true; data?: TData } | { ok: false; error: string }; + +/** Application-owned success UI and exception translation. Rethrows propagate. */ +export type FormSubmitOptions = { + onSuccess?: ( + data: TData | undefined, + values: TValues + ) => void | Promise; + onError?: (error: unknown) => string | Promise; +}; + +/** Awaitable handler for a form event or a programmatic invocation. */ +export type FormSubmitHandler = ( + event?: Pick +) => Promise; diff --git a/libs/react-form/test-fixtures/exported-system.tsx b/libs/react-form/test-fixtures/exported-system.tsx new file mode 100644 index 00000000..e5a4704d --- /dev/null +++ b/libs/react-form/test-fixtures/exported-system.tsx @@ -0,0 +1,18 @@ +import { createFormSystem, defineFieldRenderer } from '@cleverbrush/react-form'; + +export const SharedFormSystem = createFormSystem({ + renderers: { + string: defineFieldRenderer( + () => null + ) + } +}); +export const WebFormSystem = createFormSystem({ + renderers: { + ...SharedFormSystem.renderers, + 'number:select': defineFieldRenderer( + () => null + ) + } +}); +export const SchemaField = WebFormSystem.Field; diff --git a/libs/react-form/tsconfig.build.json b/libs/react-form/tsconfig.build.json index fb65c726..024d11d0 100644 --- a/libs/react-form/tsconfig.build.json +++ b/libs/react-form/tsconfig.build.json @@ -16,6 +16,8 @@ }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": [ + "src/**/*.test-d.ts", + "src/**/*.test-d.tsx", "src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", diff --git a/libs/react-form/tsconfig.json b/libs/react-form/tsconfig.json index 4c936f39..6f474178 100644 --- a/libs/react-form/tsconfig.json +++ b/libs/react-form/tsconfig.json @@ -17,12 +17,14 @@ }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": [ + "src/**/*.test-d.ts", + "src/**/*.test-d.tsx", "src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx" ], - "references": [{ "path": "../schema" }], + "references": [{ "path": "../deep" }, { "path": "../schema" }], "watchOptions": { "excludeDirectories": ["./dist"] } diff --git a/libs/react-form/tsconfig.typecheck.json b/libs/react-form/tsconfig.typecheck.json new file mode 100644 index 00000000..825abbda --- /dev/null +++ b/libs/react-form/tsconfig.typecheck.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "noEmit": true, + "strict": true + }, + "include": ["src/**/*.test-d.ts", "src/**/*.test-d.tsx"], + "exclude": [] +} diff --git a/libs/react-form/vitest.config.mts b/libs/react-form/vitest.config.mts index 7db16b24..e7e30cc3 100644 --- a/libs/react-form/vitest.config.mts +++ b/libs/react-form/vitest.config.mts @@ -4,6 +4,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'jsdom', - include: ['src/**/*.{test,spec}.{ts,tsx}'] + include: ['src/**/*.{test,spec}.{ts,tsx}'], + typecheck: { + enabled: true, + include: ['src/**/*.test-d.{ts,tsx}'], + tsconfig: './tsconfig.typecheck.json' + } } }); diff --git a/libs/server/README.md b/libs/server/README.md index b4660b0d..52a224bf 100644 --- a/libs/server/README.md +++ b/libs/server/README.md @@ -147,7 +147,7 @@ const UpdateTodo = endpoint .patch('/api/todos/:id') .body(UpdateTodoBody) .clearsCacheTag('todo-list') // clears the collection cache - .clearsCacheTag('todo', p => ({ id: p.params.id })) // clears specific entity + .clearsCacheTag('todo', p => ({ id: p.params.id })) // entity label + external computed key .returns(TodoSchema); ``` @@ -160,6 +160,18 @@ const UpdateTodo = endpoint - **Immutability** — both methods return a new builder; the original is unchanged. +`computeCacheKey` (also exported from the browser-safe `@cleverbrush/server/contract`) +and the client's `computeCacheTagKey` use the same deterministic `ct2:` encoding. +It distinguishes types, normalizes property order and preserves date milliseconds. +`cacheResponse()` invalidates only after successful mutations and prevents older +in-flight reads from refilling any invalidated alias. In-memory invalidation retains +tag-name-prefix coverage; it is not limited to the mutation's selected entity ID. + +**Breaking:** computed keys changed, even for property-free tags. Upgrade external +writers/invalidators together and retire old entries. Literal base labels and TTLs +are unchanged. Endpoint response identity and auth/tenant isolation remain the +consumer's responsibility. See the [migration guide](../../docs/cache-form-migration.md#cache-key-migration-breaking). + ## Registering and Handling Endpoints ```ts diff --git a/libs/server/src/CacheTag.ts b/libs/server/src/CacheTag.ts index f6d44542..3376a86f 100644 --- a/libs/server/src/CacheTag.ts +++ b/libs/server/src/CacheTag.ts @@ -166,49 +166,4 @@ export function serializeTag( return { name, properties }; } -// --------------------------------------------------------------------------- -// Key computation (client-side) -// --------------------------------------------------------------------------- - -/** - * Computes a deterministic cache key from a tag definition and live - * request data. - * - * - Simple tags (no properties) produce just the tag name. - * - Tags with properties produce `name:key1=val1,key2=val2` where - * keys are sorted alphabetically for determinism. - * - * Properties whose `getValue` returns `success: false` are skipped - * (their value is not included in the key). - */ -export function computeCacheKey( - tag: CacheTagDefinition, - root: { - params: Record; - body: unknown; - query: Record; - headers: Record; - } -): string { - const entries = Object.entries(tag.properties); - - if (entries.length === 0) { - return tag.name; - } - - const parts: string[] = []; - for (const [key, accessor] of entries.sort(([a], [b]) => - a.localeCompare(b) - )) { - const result = accessor.getValue(root); - if (result.success && result.value !== undefined) { - parts.push(`${key}=${String(result.value)}`); - } - } - - if (parts.length === 0) { - return tag.name; - } - - return `${tag.name}:${parts.join(',')}`; -} +export { computeCacheKey } from './cacheKey.js'; diff --git a/libs/server/src/Endpoint.ts b/libs/server/src/Endpoint.ts index 0d00b238..4edc86ef 100644 --- a/libs/server/src/Endpoint.ts +++ b/libs/server/src/Endpoint.ts @@ -2206,7 +2206,7 @@ export class EndpointBuilder< * * @example * ```ts - * // PATCH — clears "todo-list" and "todo:id=42" on success + * // PATCH — clears "todo-list" / "todo" names and their computed ct2 keys * endpoint.patch('/api/todos/:id') * .clearsCacheTag('todo-list') * .clearsCacheTag('todo', p => ({ diff --git a/libs/server/src/cacheKey.test.ts b/libs/server/src/cacheKey.test.ts new file mode 100644 index 00000000..042ecb3d --- /dev/null +++ b/libs/server/src/cacheKey.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'vitest'; +import type { CacheTagDefinition } from './CacheTag.js'; +import { computeCacheKey } from './cacheKey.js'; + +const root = { params: {}, body: undefined, query: {}, headers: {} }; +function key(values: Record, name = 'records') { + const tag: CacheTagDefinition = { + name, + properties: Object.fromEntries( + Object.entries(values).map(([property, value]) => [ + property, + { getValue: () => ({ success: true, value }) } + ]) + ) + }; + return computeCacheKey(tag, root); +} + +describe('versioned cache keys', () => { + test('uses the same versioned envelope for simple and selected tags', () => { + expect(key({})).toBe('ct2:["records",[]]'); + expect(key({ id: 42 })).toBe( + 'ct2:["records",[["id",["number","42"]]]]' + ); + }); + test('sorts property and nested object keys deterministically', () => { + expect(key({ z: { b: 2, a: 1 }, a: true })).toBe( + key({ a: true, z: { a: 1, b: 2 } }) + ); + }); + test('separates strings containing delimiters from other properties', () => { + expect(key({ search: 'coffee,tagIds=1' })).not.toBe( + key({ search: 'coffee', tagIds: '1' }) + ); + expect(key({ 'x=y': 'z' })).not.toBe(key({ x: 'y=z' })); + expect(key({}, key({ id: 1 }))).not.toBe(key({ id: 1 })); + }); + test('distinguishes scalar types, nested undefined and dates', () => { + const values = [ + 1, + '1', + true, + 'true', + null, + 'null', + 1n, + ['a,b'], + ['a', 'b'], + { a: undefined }, + {}, + new Date('2026-01-01T00:00:00.001Z'), + new Date('2026-01-01T00:00:00.002Z'), + '2026-01-01T00:00:00.001Z', + -0, + 0, + NaN, + Infinity + ]; + expect(new Set(values.map(value => key({ value }))).size).toBe( + values.length + ); + }); + test('omits absent and undefined selected properties', () => { + expect(key({ missing: undefined })).toBe(key({})); + expect( + computeCacheKey( + { + name: 'records', + properties: { + absent: { + getValue: () => ({ success: false, value: 1 }) + } + } + }, + root + ) + ).toBe(key({})); + }); + test('allows repeated references that are not cycles', () => { + const child = { id: 1 }; + expect(key({ value: [child, child] })).toBe( + key({ value: [{ id: 1 }, { id: 1 }] }) + ); + }); + test.each([ + new Map(), + new Set(), + new Date(NaN), + Symbol(), + () => 1 + ])('rejects unsupported values explicitly: %s', value => { + expect(() => key({ value })).toThrow(TypeError); + }); + test('rejects cycles and getters', () => { + const cyclic: any = {}; + cyclic.self = cyclic; + expect(() => key({ cyclic })).toThrow(/cycles/); + expect(() => + key({ + value: { + get secret() { + throw new Error(); + } + } + }) + ).toThrow(/getters/); + }); + test('rejects array getters without invoking them and named array properties', () => { + let invoked = false; + const value = Object.defineProperty([], 0, { + get() { + invoked = true; + return 1; + } + }); + expect(() => key({ value })).toThrow(/getters/); + expect(invoked).toBe(false); + expect(() => key({ value: Object.assign([], { extra: 1 }) })).toThrow( + /named properties/ + ); + expect(key({ value: new Array(2) })).toBe( + key({ value: [undefined, undefined] }) + ); + }); +}); diff --git a/libs/server/src/cacheKey.ts b/libs/server/src/cacheKey.ts new file mode 100644 index 00000000..ca64130c --- /dev/null +++ b/libs/server/src/cacheKey.ts @@ -0,0 +1,128 @@ +import type { CacheTagDefinition } from './CacheTag.js'; + +type CacheRoot = Parameters< + CacheTagDefinition['properties'][string]['getValue'] +>[0]; + +/** Encode values without coercion, delimiter ambiguity, or lost date precision. */ +function encode(value: unknown, ancestors: Set): unknown { + if (value === null) return ['null']; + switch (typeof value) { + case 'undefined': + return ['undefined']; + case 'string': + case 'boolean': + return [typeof value, value]; + case 'number': + return ['number', Object.is(value, -0) ? '-0' : String(value)]; + case 'bigint': + return ['bigint', String(value)]; + case 'object': { + if (ancestors.has(value)) { + throw new TypeError( + 'Cache tag values must not contain cycles.' + ); + } + if (value instanceof Date) { + if (!Number.isFinite(value.getTime())) { + throw new TypeError('Cache tag dates must be valid.'); + } + return ['date', value.toISOString()]; + } + const prototype = Object.getPrototypeOf(value); + if ( + !Array.isArray(value) && + prototype !== Object.prototype && + prototype !== null + ) { + throw new TypeError('Unsupported cache tag object.'); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError( + 'Cache tag values cannot have symbol keys.' + ); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + if ( + Object.keys(value).some( + key => + !/^(0|[1-9]\d*)$/.test(key) || + Number(key) >= value.length + ) + ) { + throw new TypeError( + 'Cache tag arrays cannot have named properties.' + ); + } + return [ + 'array', + Array.from({ length: value.length }, (_, index) => { + const property = Object.getOwnPropertyDescriptor( + value, + index + ); + if (property && !('value' in property)) { + throw new TypeError( + 'Cache tag values cannot contain getters.' + ); + } + return encode(property?.value, ancestors); + }) + ]; + } + return [ + 'object', + Object.keys(value) + .sort() + .map(key => { + const property = Object.getOwnPropertyDescriptor( + value, + key + )!; + if (!('value' in property)) { + throw new TypeError( + 'Cache tag values cannot contain getters.' + ); + } + return [key, encode(property.value, ancestors)]; + }) + ]; + } finally { + ancestors.delete(value); + } + } + default: + throw new TypeError( + `Unsupported cache tag value: ${typeof value}.` + ); + } +} + +/** + * Compute a versioned, deterministic key from a tag and selected request values. + * + * Format: `ct2:` followed by JSON `[tagName, [[property, encodedValue], ...]]`. + * Properties and object keys are sorted by code units. Values are type-tagged; + * Dates retain millisecond precision. Missing/undefined selected properties are + * omitted. Even property-free tags use this format; base invalidation labels + * remain the original names. All cache writers/invalidators must use the same + * version and retire old entries when upgrading. + * + * @throws TypeError for cyclic values, invalid dates, functions, symbols, + * accessor properties, or objects other than arrays, dates and plain objects. + */ +export function computeCacheKey( + tag: CacheTagDefinition, + root: CacheRoot +): string { + const parts: Array<[string, unknown]> = []; + for (const key of Object.keys(tag.properties).sort()) { + const result = tag.properties[key].getValue(root); + if (result.success && result.value !== undefined) { + parts.push([key, encode(result.value, new Set())]); + } + } + return `ct2:${JSON.stringify([tag.name, parts])}`; +} diff --git a/libs/server/src/contract.ts b/libs/server/src/contract.ts index ac35f300..6fc5f109 100644 --- a/libs/server/src/contract.ts +++ b/libs/server/src/contract.ts @@ -31,6 +31,7 @@ export type { CacheTagDefinition, CacheTagPropertyAccessor } from './CacheTag.js'; +export { computeCacheKey } from './cacheKey.js'; export { type ActionContext, type AllowedResponseReturn, diff --git a/libs/server/src/middlewares/ResponseCache.test.ts b/libs/server/src/middlewares/ResponseCache.test.ts new file mode 100644 index 00000000..f77dbace --- /dev/null +++ b/libs/server/src/middlewares/ResponseCache.test.ts @@ -0,0 +1,146 @@ +import { createServer } from 'node:http'; +import { describe, expect, test, vi } from 'vitest'; +import type { RequestContext } from '../RequestContext.js'; +import { cacheResponse } from './ResponseCache.js'; + +function context(method = 'GET', names = ['records']) { + const response = { + statusCode: 200, + writeHead: vi.fn(function (this: any, status: number) { + this.statusCode = status; + return this; + }), + end: vi.fn() + }; + return { + method, + response, + pathParams: {}, + queryParams: {}, + headers: {}, + items: new Map([ + [ + '__endpoint_meta', + { + cacheTags: names.map(name => ({ name, properties: {} })) + } + ] + ]) + } as unknown as RequestContext; +} +function send(ctx: RequestContext, body: string, status = 200) { + ctx.response.writeHead(status, { 'content-type': 'text/plain' }); + ctx.response.end(body); +} + +describe('server response cache generations', () => { + test('preserves HTTP body, status and headers across cached responses', async () => { + const cache = cacheResponse(); + let reads = 0; + const server = createServer(async (request, response) => { + const ctx = Object.assign(context(request.method), { response }); + try { + await cache(ctx, async () => { + if (request.method === 'GET') { + reads++; + send(ctx, `read-${reads}`, 201); + } else send(ctx, '', request.url === '/failed' ? 500 : 204); + }); + } catch { + response.statusCode = 500; + response.end(); + } + }); + await new Promise(resolve => + server.listen(0, '127.0.0.1', resolve) + ); + const port = (server.address() as { port: number }).port; + const url = `http://127.0.0.1:${port}`; + try { + for (let i = 0; i < 2; i++) { + const response = await fetch(url); + expect(response.status).toBe(201); + expect(response.headers.get('content-type')).toBe('text/plain'); + expect(await response.text()).toBe('read-1'); + } + await (await fetch(`${url}/failed`, { method: 'PATCH' })).text(); + expect(await (await fetch(url)).text()).toBe('read-1'); + await (await fetch(url, { method: 'PATCH' })).text(); + expect(await (await fetch(url)).text()).toBe('read-2'); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close(error => (error ? reject(error) : resolve())) + ); + } + }); + + test('caches successful reads and invalidates on a successful write', async () => { + const cache = cacheResponse(); + const initial = context(); + await cache(initial, async () => send(initial, 'old')); + const hit = context(); + const handler = vi.fn(); + await cache(hit, handler); + expect(handler).not.toHaveBeenCalled(); + expect(hit.response.end).toHaveBeenCalledWith(Buffer.from('old')); + const write = context('PATCH'); + await cache(write, async () => send(write, '', 204)); + await cache(context(), handler); + expect(handler).toHaveBeenCalledTimes(1); + }); + test.each([ + 400, 500 + ])('failed write %s preserves cached entries', async status => { + const cache = cacheResponse(); + const initial = context(); + await cache(initial, async () => send(initial, 'old')); + const write = context('PATCH'); + await cache(write, async () => send(write, '', status)); + const handler = vi.fn(); + await cache(context(), handler); + expect(handler).not.toHaveBeenCalled(); + }); + test('a thrown write preserves entries', async () => { + const cache = cacheResponse(); + const initial = context(); + await cache(initial, async () => send(initial, 'old')); + await expect( + cache(context('PATCH'), async () => { + throw new Error('write failed'); + }) + ).rejects.toThrow('write failed'); + const handler = vi.fn(); + await cache(context(), handler); + expect(handler).not.toHaveBeenCalled(); + }); + test('in-flight reads cannot repopulate any alias after a write', async () => { + const cache = cacheResponse(); + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const initial = context('GET', ['records', 'other']); + const pending = cache(initial, async () => { + await gate; + send(initial, 'old'); + }); + const write = context('PATCH'); + await cache(write, async () => send(write, '', 204)); + release(); + await pending; + const handler = vi.fn(); + await cache(context('GET', ['other']), handler); + expect(handler).toHaveBeenCalledTimes(1); + }); + test('a mutation invalidates cached aliases and name-prefix variants', async () => { + const cache = cacheResponse(); + const initial = context('GET', ['records-window', 'other']); + await cache(initial, async () => send(initial, 'old')); + const write = context('DELETE'); + await cache(write, async () => send(write, '', 204)); + const handler = vi.fn(); + await cache(context('GET', ['other']), handler); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/server/src/middlewares/ResponseCache.ts b/libs/server/src/middlewares/ResponseCache.ts index 5286b25c..3edd4147 100644 --- a/libs/server/src/middlewares/ResponseCache.ts +++ b/libs/server/src/middlewares/ResponseCache.ts @@ -10,6 +10,8 @@ */ import type { ServerResponse } from 'node:http'; +import type { CacheTagDefinition } from '../CacheTag.js'; +import { computeCacheKey } from '../cacheKey.js'; import type { RequestContext } from '../RequestContext.js'; import type { Middleware } from '../types.js'; @@ -42,43 +44,13 @@ interface CacheEntry { headers: Record; body: Buffer; expiresAt: number; + generations: ReadonlyArray; } function isMutating(method: string): boolean { return ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method.toUpperCase()); } -function computeKey( - tags: ReadonlyArray<{ - name: string; - properties: Readonly< - Record< - string, - { - getValue(root: any): { - value?: unknown; - success: boolean; - }; - } - > - >; - }>, - root: any -): string[] { - return tags.map(tag => { - const parts: string[] = []; - for (const [key, accessor] of Object.entries(tag.properties).sort( - ([a], [b]) => a.localeCompare(b) - )) { - const result = accessor.getValue(root); - if (result.success && result.value !== undefined) { - parts.push(`${key}=${String(result.value)}`); - } - } - return parts.length > 0 ? `${tag.name}:${parts.join(',')}` : tag.name; - }); -} - // --------------------------------------------------------------------------- // Middleware // --------------------------------------------------------------------------- @@ -94,8 +66,8 @@ function computeKey( * handler never executes. On cache miss, runs the handler and caches * the response. * - **Mutation (POST/PUT/PATCH/DELETE)**: Lets the handler run, then - * invalidates all cache entries whose key starts with any of the - * endpoint's cache tag names. + * invalidates all cache entries whose tag names start with any of the + * endpoint's cache tag names. Older in-flight reads cannot refill them. * * @param options - Cache configuration. * @returns A server-side {@link Middleware}. @@ -111,22 +83,22 @@ export function cacheResponse(options: ServerCacheOptions = {}): Middleware { const { ttlByTag = {}, defaultTtl = 60_000 } = options; const cache = new Map(); + const generations = new Map(); + const isCurrent = (snapshot: CacheEntry['generations']) => + snapshot.every( + ([name, generation]) => generations.get(name) === generation + ); return async (ctx: RequestContext, next: () => Promise) => { const meta = ctx.items.get('__endpoint_meta') as any; - const tags: ReadonlyArray<{ - name: string; - properties: Record< - string, - { getValue(root: any): { value?: unknown; success: boolean } } - >; - }> = meta?.cacheTags ?? []; + const tags: readonly CacheTagDefinition[] = meta?.cacheTags ?? []; if (tags.length === 0) { return next(); } if (isMutating(ctx.method)) { + const names = tags.map(tag => tag.name); // Run handler first (so cache is invalidated only on success) await next(); @@ -134,34 +106,14 @@ export function cacheResponse(options: ServerCacheOptions = {}): Middleware { (ctx.response as ServerResponse).statusCode >= 200 && (ctx.response as ServerResponse).statusCode < 300 ) { - // Build root for key computation - const rawBody = ctx.items.get('__raw_body') as - | unknown - | undefined; - const root = { - params: ctx.pathParams ?? {}, - body: rawBody, - query: ctx.queryParams ?? {}, - headers: ctx.headers ?? {} - }; - - const keys = computeKey(tags, root); - for (const tag of tags) { - for (const [cachedKey] of cache) { - if ( - keys.includes(cachedKey) || - keys.some(_k => cachedKey.startsWith(tag.name)) - ) { - cache.delete(cachedKey); - } - } - // Also delete by pure tag name prefix - for (const [cachedKey] of cache) { - if (cachedKey.startsWith(tag.name)) { - cache.delete(cachedKey); - } + for (const [name, generation] of generations) { + if (names.some(prefix => name.startsWith(prefix))) { + generations.set(name, generation + 1); } } + for (const [key, entry] of cache) { + if (!isCurrent(entry.generations)) cache.delete(key); + } } return; } @@ -175,12 +127,20 @@ export function cacheResponse(options: ServerCacheOptions = {}): Middleware { headers: ctx.headers ?? {} }; - const keys = computeKey(tags, root); + const keys = tags.map(tag => computeCacheKey(tag, root)); + const snapshot = tags.map(({ name }) => { + if (!generations.has(name)) generations.set(name, 0); + return [name, generations.get(name)!] as const; + }); // Check all keys — first valid cache hit wins for (const key of keys) { const entry = cache.get(key); - if (entry && entry.expiresAt > Date.now()) { + if ( + entry && + entry.expiresAt > Date.now() && + isCurrent(entry.generations) + ) { // Serve from cache const res = ctx.response as ServerResponse; res.writeHead(entry.status, entry.headers); @@ -234,7 +194,11 @@ export function cacheResponse(options: ServerCacheOptions = {}): Middleware { await next(); // Store in cache on success - if (capturedStatus >= 200 && capturedStatus < 300) { + if ( + capturedStatus >= 200 && + capturedStatus < 300 && + isCurrent(snapshot) + ) { const body = Buffer.concat(chunks); const ttl = tags.reduce((max, tag) => { const t = @@ -250,7 +214,8 @@ export function cacheResponse(options: ServerCacheOptions = {}): Middleware { status: capturedStatus, headers: capturedHeaders, body, - expiresAt: Date.now() + ttl + expiresAt: Date.now() + ttl, + generations: snapshot }); } } diff --git a/libs/server/tests/CacheTag.test.ts b/libs/server/tests/CacheTag.test.ts index edf3fc1f..2ca394ec 100644 --- a/libs/server/tests/CacheTag.test.ts +++ b/libs/server/tests/CacheTag.test.ts @@ -210,16 +210,20 @@ describe('computeCacheKey', () => { headers: {} }; - it('returns tag name for simple tags with no properties', () => { + it('uses the versioned format for simple tags with no properties', () => { const tag = makeTag('invalidate-all', {}); - expect(computeCacheKey(tag, emptyRoot)).toBe('invalidate-all'); + expect(computeCacheKey(tag, emptyRoot)).toBe( + 'ct2:["invalidate-all",[]]' + ); }); it('builds key with single property', () => { const tag = makeTag('todo', { id: makeConstAccessor(42) }); - expect(computeCacheKey(tag, emptyRoot)).toBe('todo:id=42'); + expect(computeCacheKey(tag, emptyRoot)).toBe( + 'ct2:["todo",[["id",["number","42"]]]]' + ); }); it('builds key with multiple properties sorted alphabetically', () => { @@ -229,7 +233,7 @@ describe('computeCacheKey', () => { m: makeConstAccessor('middle') }); expect(computeCacheKey(tag, emptyRoot)).toBe( - 'todo:a=first,m=middle,z=last' + 'ct2:["todo",[["a",["string","first"]],["m",["string","middle"]],["z",["string","last"]]]]' ); }); @@ -238,22 +242,24 @@ describe('computeCacheKey', () => { id: makeConstAccessor(42), optional: makeFailingAccessor() }); - expect(computeCacheKey(tag, emptyRoot)).toBe('todo:id=42'); + expect(computeCacheKey(tag, emptyRoot)).toBe( + 'ct2:["todo",[["id",["number","42"]]]]' + ); }); - it('returns tag name when all properties fail', () => { + it('uses the property-free versioned key when all properties fail', () => { const tag = makeTag('todo', { a: makeFailingAccessor(), b: makeFailingAccessor() }); - expect(computeCacheKey(tag, emptyRoot)).toBe('todo'); + expect(computeCacheKey(tag, emptyRoot)).toBe('ct2:["todo",[]]'); }); it('skips properties with undefined value', () => { const tag = makeTag('todo', { id: makeConstAccessor(undefined) }); - expect(computeCacheKey(tag, emptyRoot)).toBe('todo'); + expect(computeCacheKey(tag, emptyRoot)).toBe('ct2:["todo",[]]'); }); it('produces stable output for the same inputs', () => { @@ -296,7 +302,9 @@ describe('integration', () => { const key = computeCacheKey(definition, root); // Sorted: filter, orgId, userId - expect(key).toBe('resource:filter=active,orgId=10,userId=u1'); + expect(key).toBe( + 'ct2:["resource",[["filter",["string","active"]],["orgId",["number","10"]],["userId",["string","u1"]]]]' + ); }); it('end-to-end with params and headers', () => { @@ -326,6 +334,8 @@ describe('integration', () => { const key = computeCacheKey(definition, root); // Sorted: orgId, projectId, tenant - expect(key).toBe('project:orgId=42,projectId=p1,tenant=acme'); + expect(key).toBe( + 'ct2:["project",[["orgId",["number","42"]],["projectId",["string","p1"]],["tenant",["string","acme"]]]]' + ); }); }); diff --git a/package-lock.json b/package-lock.json index 5382d67f..128ec903 100644 --- a/package-lock.json +++ b/package-lock.json @@ -149,7 +149,7 @@ }, "libs/async": { "name": "@cleverbrush/async", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "devDependencies": { "@types/node": "^25.4.0" @@ -167,10 +167,10 @@ }, "libs/auth": { "name": "@cleverbrush/auth", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^4.3.2" + "@cleverbrush/schema": "^4.4.2" } }, "libs/benchmarks": { @@ -185,11 +185,11 @@ }, "libs/client": { "name": "@cleverbrush/client", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^4.3.2", - "@cleverbrush/server": "^4.3.2" + "@cleverbrush/schema": "^4.4.2", + "@cleverbrush/server": "^4.4.2" }, "devDependencies": { "@tanstack/react-query": "^5.75.0", @@ -213,23 +213,23 @@ }, "libs/deep": { "name": "@cleverbrush/deep", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause" }, "libs/di": { "name": "@cleverbrush/di", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^4.3.2" + "@cleverbrush/schema": "^4.4.2" } }, "libs/env": { "name": "@cleverbrush/env", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/deep": "^4.3.2" + "@cleverbrush/deep": "^4.4.2" }, "devDependencies": { "@types/node": "^25.4.0" @@ -250,11 +250,11 @@ }, "libs/knex-clickhouse": { "name": "@cleverbrush/knex-clickhouse", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/async": "^4.3.2", - "@cleverbrush/deep": "^4.3.2", + "@cleverbrush/async": "^4.4.2", + "@cleverbrush/deep": "^4.4.2", "@clickhouse/client": "^1.18.2" }, "peerDependencies": { @@ -263,10 +263,10 @@ }, "libs/knex-schema": { "name": "@cleverbrush/knex-schema", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^4.3.2" + "@cleverbrush/schema": "^4.4.2" }, "peerDependencies": { "knex": ">=3.1.0" @@ -274,11 +274,11 @@ }, "libs/log": { "name": "@cleverbrush/log", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/async": "^4.3.2", - "@cleverbrush/schema": "^4.3.2" + "@cleverbrush/async": "^4.4.2", + "@cleverbrush/schema": "^4.4.2" }, "devDependencies": { "@types/node": "^25.4.0" @@ -312,19 +312,19 @@ }, "libs/mapper": { "name": "@cleverbrush/mapper", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^4.3.2" + "@cleverbrush/schema": "^4.4.2" } }, "libs/orm": { "name": "@cleverbrush/orm", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/knex-schema": "^4.3.2", - "@cleverbrush/schema": "^4.3.2" + "@cleverbrush/knex-schema": "^4.4.2", + "@cleverbrush/schema": "^4.4.2" }, "peerDependencies": { "knex": ">=3.1.0" @@ -332,7 +332,7 @@ }, "libs/orm-cli": { "name": "@cleverbrush/orm-cli", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { "@cleverbrush/knex-schema": "*", @@ -347,7 +347,7 @@ }, "libs/otel": { "name": "@cleverbrush/otel", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { "@opentelemetry/api": "^1.9.0", @@ -419,10 +419,11 @@ }, "libs/react-form": { "name": "@cleverbrush/react-form", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^4.3.2" + "@cleverbrush/deep": "^4.4.2", + "@cleverbrush/schema": "^4.4.2" }, "devDependencies": { "@types/react": "^19.0.0", @@ -434,10 +435,10 @@ }, "libs/scheduler": { "name": "@cleverbrush/scheduler", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^4.3.2" + "@cleverbrush/schema": "^4.4.2" }, "devDependencies": { "@types/node": "^25.4.0" @@ -455,10 +456,10 @@ }, "libs/schema": { "name": "@cleverbrush/schema", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "devDependencies": { - "@cleverbrush/deep": "^4.3.2" + "@cleverbrush/deep": "^4.4.2" }, "peerDependencies": { "@standard-schema/spec": "^1.1.0" @@ -466,7 +467,7 @@ }, "libs/schema-json": { "name": "@cleverbrush/schema-json", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "peerDependencies": { "@cleverbrush/schema": "^4.0.0", @@ -475,12 +476,12 @@ }, "libs/server": { "name": "@cleverbrush/server", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/auth": "^4.3.2", - "@cleverbrush/di": "^4.3.2", - "@cleverbrush/schema": "^4.3.2", + "@cleverbrush/auth": "^4.4.2", + "@cleverbrush/di": "^4.4.2", + "@cleverbrush/schema": "^4.4.2", "@fastify/busboy": "^3.2.0", "ws": "^8.20.0" }, @@ -501,7 +502,7 @@ }, "libs/server-openapi": { "name": "@cleverbrush/server-openapi", - "version": "4.3.2", + "version": "4.4.2", "license": "BSD 3-Clause", "peerDependencies": { "@cleverbrush/auth": "^4.0.0", diff --git a/websites/docs/app/client/sections/cacheTags.tsx b/websites/docs/app/client/sections/cacheTags.tsx index fdf9cdc8..73993976 100644 --- a/websites/docs/app/client/sections/cacheTags.tsx +++ b/websites/docs/app/client/sections/cacheTags.tsx @@ -108,8 +108,11 @@ externalCacheTags({ invalidateTag: revalidateTag });
  • On mutation (POST/PUT/PATCH/DELETE):{' '} - Invalidates all entries whose key starts with any of the - endpoint's tag names — no manual callbacks needed. + After a successful response, invalidates entries whose + tag names start with any of the endpoint's tag + names. Failed writes preserve entries; older in-flight + reads cannot refill an invalidated response or its + aliases.
  • Property-based keys: Tags with @@ -126,6 +129,33 @@ externalCacheTags({ invalidateTag: revalidateTag }); +
    +

    Versioned keys: breaking migration

    +

    + Server and client helpers, response caches and external + invalidation share the deterministic ct2:{' '} + format. Property and object key order is normalized, values + retain their types, and dates retain milliseconds. Even a + property-free tag computes to{' '} + {'ct2:["records",[]]'}. Unsupported selected + values throw TypeError. +

    +

    + Upgrade external cache writers and invalidators together and + flush or expire old entries. There is no legacy fallback. + Base invalidation labels remain literal names; TTLs are + unchanged. External invalidators send both base labels and + computed keys by default, including property-free tags. +

    +

    + Response shape and auth/tenant isolation remain + consumer-owned: use distinct list/detail names and select + every value that affects a response. URLs and identities are + not automatically included in keys. External caches need + their own protection against stale concurrent writers. +

    +
    +

    Options

    diff --git a/websites/docs/app/react-form/LifecycleDemo.module.css b/websites/docs/app/react-form/LifecycleDemo.module.css new file mode 100644 index 00000000..da377e79 --- /dev/null +++ b/websites/docs/app/react-form/LifecycleDemo.module.css @@ -0,0 +1,23 @@ +.form { + background: var(--bg-secondary); +} + +.form select { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--bg-card); + color: var(--text-primary); + font: inherit; +} + +.form input[type="checkbox"] { + width: 1rem; + height: 1rem; +} + +.form button:disabled { + opacity: 0.65; + cursor: wait; +} diff --git a/websites/docs/app/react-form/LifecycleDemo.tsx b/websites/docs/app/react-form/LifecycleDemo.tsx new file mode 100644 index 00000000..b5e320a5 --- /dev/null +++ b/websites/docs/app/react-form/LifecycleDemo.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { + createFormSystem, + defineFieldRenderer, + useSchemaForm +} from '@cleverbrush/react-form'; +import { boolean, object, string } from '@cleverbrush/schema'; +import { useState } from 'react'; +import styles from './LifecycleDemo.module.css'; + +const ProfileSchema = object({ + name: string() + .required('Name is required') + .minLength(2, 'Use at least two characters'), + role: string().required('Choose a role'), + active: boolean() +}); +const text = defineFieldRenderer(props => ( +
    + props.onChange(e.target.value)} + onBlur={props.onBlur} + aria-invalid={props.touched && !!props.error} + aria-describedby={ + props.error ? `${props.fieldProps?.id}-error` : undefined + } + /> + {props.touched && props.error && ( + + {props.error} + + )} +
    +)); +const basic = createFormSystem({ renderers: { string: text } }); +const ui = createFormSystem({ + renderers: { + ...basic.renderers, + 'string:select': defineFieldRenderer< + string, + { id: string; options: string[] } + >(props => ( +
    + + {props.touched && props.error && ( + {props.error} + )} +
    + )), + boolean: defineFieldRenderer(props => ( + props.onChange(e.target.checked)} + onBlur={props.onBlur} + /> + )) + } +}); + +/** Local-only demonstration: no user values are sent to a server. */ +export default function LifecycleDemo() { + const form = useSchemaForm(ProfileSchema); + const [fail, setFail] = useState(false); + const [confirmation, setConfirmation] = useState(''); + const submit = form.handleSubmit( + async values => { + setConfirmation(''); + await new Promise(resolve => setTimeout(resolve, 600)); + if (fail) + return { + ok: false, + error: 'Demo failure: your values are preserved. Try again.' + }; + return { ok: true, data: values }; + }, + { + onSuccess: values => { + setConfirmation( + `Saved ${values?.name}. The form was cleared without remounting.` + ); + form.reset(); + } + } + ); + + return ( +
    +
    + + t.name} + fieldProps={{ id: 'profile-name' }} + /> +
    +
    + + t.role} + variant="select" + fieldProps={{ + id: 'profile-role', + options: ['reader', 'editor'] + }} + /> +
    +
    + + t.active} + fieldProps={{ id: 'profile-active' }} + /> +
    +

    + +

    + {' '} + {' '} + + {form.error && ( +

    + {form.error} +

    + )} +

    {confirmation}

    +
    + ); +} diff --git a/websites/docs/app/react-form/page.tsx b/websites/docs/app/react-form/page.tsx index 24d6725f..22c9514a 100644 --- a/websites/docs/app/react-form/page.tsx +++ b/websites/docs/app/react-form/page.tsx @@ -10,6 +10,7 @@ import { number, object, string } from '@cleverbrush/schema'; import { InstallBanner } from '@cleverbrush/website-shared/components/InstallBanner'; import { highlightTS } from '@cleverbrush/website-shared/lib/highlight'; import { type InputHTMLAttributes, type ReactNode, useState } from 'react'; +import LifecycleDemo from './LifecycleDemo'; /* ── Live Quick-Start form ──────────────────────────────────────── */ @@ -298,6 +299,75 @@ function App() {
    +
    +

    Typed renderers and submission lifecycle

    +

    + Create a UI-agnostic typed registry once with{' '} + defineFieldRenderer<Value, Props> and{' '} + createFormSystem. Its Field{' '} + checks the selected value, variant and custom props. + Extend it by spreading system.renderers. + Its optional Provider also configures + legacy fields. +

    +
    +                        (props => (
    +  
    +));
    +const ui = createFormSystem({ renderers: { 'string:select': select } });
    +
    +function ProfileForm() {
    +  const form = useSchemaForm(ProfileSchema);
    +  const submit = form.handleSubmit(saveProfile, {
    +    onSuccess: saved => { showConfirmation('Saved'); form.reset(); },
    +    onError: error => {
    +      if (isExpectedNetworkError(error)) return 'Please try again';
    +      throw error;
    +    }
    +  });
    +  return 
    + t.role} variant="select" + fieldProps={{ options: ['reader', 'editor'] }} /> + {form.error &&

    {form.error}

    } + + ; +}`) + }} + /> +
    +

    + The submit callback returns void,{' '} + {'{ ok: true, data? }'}, or{' '} + {'{ ok: false, error: string }'}. Duplicate + submissions are locked from validation onward. Errors + propagate unless onError translates them; + exceptions in onSuccess always propagate. + Notifications and navigation remain application-owned. +

    +

    + reset(values) updates mounted text, select + and checkbox fields and establishes a clean baseline; + reset() clears them. The form controller + is stable. Value changes invalidate old async + validation. Reset/unmount suppresses old submission + callbacks without cancelling a network operation or + prematurely unlocking it. +

    +

    Try the lifecycle

    +

    + Load sample values, edit or clear them, and try a failed + save followed by a retry. A successful save clears the + mounted fields. This demo waits 600 ms locally and sends + no data to a server. +

    + +
    + {/* ── Why ──────────────────────────────────────────── */}

    💡 Why @cleverbrush/react-form?

    @@ -775,9 +845,10 @@ function App() { form.reset(values?) - Resets the form to initial values (or - provided values). Clears dirty/touched - state. + Clears values, or establishes supplied + values as a new clean baseline. Updates + mounted fields and clears errors, + dirty/touched/validating state. @@ -794,8 +865,9 @@ function App() { form.setValue(values) - Sets form values programmatically - (partial update). + Shallow-merges values and updates + mounted fields without marking them + touched. @@ -808,6 +880,29 @@ function App() { selector. + + + + form.handleSubmit(onValid, options?) + + + + Validates, locks duplicate submissions + and handles explicit results and + callbacks. + + + + + + form.submitting / form.error + + + + Reactive, read-only submission state on + a stable form controller. + +
    @@ -919,10 +1014,7 @@ function App() { () => void - - Mark the field as touched (triggers - validation) - + Mark the field as touched diff --git a/websites/docs/public/api-docs/index.html b/websites/docs/public/api-docs/index.html index bf5b3f38..0700e1c6 100644 --- a/websites/docs/public/api-docs/index.html +++ b/websites/docs/public/api-docs/index.html @@ -40,6 +40,8 @@

    Previous Versions

    + + @@ -65,6 +67,7 @@

    Previous Versions