mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-10 07:42:32 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ad20d3ede |
@@ -4,13 +4,6 @@
|
||||
|
||||
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||
|
||||
<p>
|
||||
<a href="https://vercel.com/labs#labs-products"><img alt="Vercel Labs Product" src="https://img.shields.io/badge/LABS-PRODUCT-0a0a0a.svg?style=for-the-badge&logo=Vercel&labelColor=000000" height="28"></a>
|
||||
<a href="https://www.npmjs.com/package/@json-render/core"><img alt="npm version: @json-render/core" src="https://img.shields.io/npm/v/%40json-render%2Fcore.svg?style=for-the-badge&labelColor=000000" height="28"></a>
|
||||
<a href="https://github.com/vercel-labs/json-render/blob/main/LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/github/license/vercel-labs/json-render.svg?style=for-the-badge&labelColor=000000" height="28"></a>
|
||||
<a href="https://www.npmjs.com/package/@json-render/core"><img alt="npm downloads per month: @json-render/core" src="https://img.shields.io/npm/dm/%40json-render%2Fcore.svg?style=for-the-badge&labelColor=000000&label=npm%20downloads" height="28"></a>
|
||||
</p>
|
||||
|
||||
```bash
|
||||
# for React
|
||||
npm install @json-render/core @json-render/react
|
||||
|
||||
@@ -451,6 +451,8 @@ const value = getByPath(state, '/user/name'); // "Alice"
|
||||
setByPath(state, '/user/email', 'alice@example.com');
|
||||
```
|
||||
|
||||
For prototype safety, path utilities, state stores, and SpecStream patches reject JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens. Compound patches validate both `path` and `from` before mutating data.
|
||||
|
||||
### resolveDynamicValue
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -254,8 +254,6 @@ Use the built-in `validateForm` action to validate all registered fields at once
|
||||
|
||||
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
|
||||
|
||||
Controls registered through `useFieldValidation` participate in `validateForm` while they are mounted with a binding path and validation configuration. When one of these controls unmounts, changes its binding path, or removes its validation configuration, its stale errors are cleared while its stored state value is preserved. If multiple mounted controls share a path, that path remains registered until the last one unmounts. Direct calls to `useValidation().registerField()` create a durable registration that remains for the lifetime of the `ValidationProvider`.
|
||||
|
||||
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
|
||||
|
||||
## Next
|
||||
|
||||
@@ -126,6 +126,8 @@ SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/ht
|
||||
|
||||
All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`.
|
||||
|
||||
For prototype safety, JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens are rejected by path utilities, state stores, and SpecStream. This applies to both `path` and `from` in compound patches.
|
||||
|
||||
### Low-Level Utilities
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createStateStore, flattenToPointers } from "./state-store";
|
||||
import {
|
||||
createStateStore,
|
||||
flattenToPointers,
|
||||
immutableSetByPath,
|
||||
} from "./state-store";
|
||||
|
||||
describe("createStateStore", () => {
|
||||
it("creates a store with initial state", () => {
|
||||
@@ -135,6 +139,36 @@ describe("createStateStore", () => {
|
||||
store.set("/x", 2);
|
||||
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||
});
|
||||
|
||||
it.each(["__proto__", "constructor", "prototype"])(
|
||||
"rejects %s state paths without publishing a snapshot",
|
||||
(token) => {
|
||||
const store = createStateStore({ safe: true });
|
||||
const listener = vi.fn();
|
||||
const snapshot = store.getSnapshot();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.set(`/${token}/polluted`, "value");
|
||||
store.update({ [`/safe/${token}/polluted`]: "value" });
|
||||
|
||||
expect(store.getSnapshot()).toBe(snapshot);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("immutableSetByPath", () => {
|
||||
it.each(["__proto__", "constructor", "prototype"])(
|
||||
"rejects %s without changing snapshot identity or its prototype",
|
||||
(token) => {
|
||||
const state = { safe: true };
|
||||
const result = immutableSetByPath(state, `/${token}/polluted`, "value");
|
||||
|
||||
expect(result).toBe(state);
|
||||
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
|
||||
expect(result).toEqual({ safe: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("flattenToPointers", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
getByPath,
|
||||
isSafeJsonPointerPath,
|
||||
parseJsonPointer,
|
||||
type StateModel,
|
||||
type StateStore,
|
||||
@@ -15,6 +16,8 @@ export function immutableSetByPath(
|
||||
path: string,
|
||||
value: unknown,
|
||||
): StateModel {
|
||||
if (!isSafeJsonPointerPath(path)) return root;
|
||||
|
||||
const segments = parseJsonPointer(path);
|
||||
if (segments.length === 0) return root;
|
||||
|
||||
@@ -72,6 +75,7 @@ export function createStateStore(initialState: StateModel = {}): StateStore {
|
||||
},
|
||||
|
||||
set(path: string, value: unknown): void {
|
||||
if (!isSafeJsonPointerPath(path)) return;
|
||||
if (getByPath(state, path) === value) return;
|
||||
state = immutableSetByPath(state, path, value);
|
||||
notify();
|
||||
@@ -81,6 +85,7 @@ export function createStateStore(initialState: StateModel = {}): StateStore {
|
||||
let changed = false;
|
||||
let next = state;
|
||||
for (const [path, value] of Object.entries(updates)) {
|
||||
if (!isSafeJsonPointerPath(path)) continue;
|
||||
if (getByPath(next, path) !== value) {
|
||||
next = immutableSetByPath(next, path, value);
|
||||
changed = true;
|
||||
@@ -137,6 +142,7 @@ export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
|
||||
},
|
||||
|
||||
set(path: string, value: unknown): void {
|
||||
if (!isSafeJsonPointerPath(path)) return;
|
||||
const current = config.getSnapshot();
|
||||
if (getByPath(current, path) === value) return;
|
||||
config.setSnapshot(immutableSetByPath(current, path, value));
|
||||
@@ -146,6 +152,7 @@ export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
|
||||
let next = config.getSnapshot();
|
||||
let changed = false;
|
||||
for (const [path, value] of Object.entries(updates)) {
|
||||
if (!isSafeJsonPointerPath(path)) continue;
|
||||
if (getByPath(next, path) !== value) {
|
||||
next = immutableSetByPath(next, path, value);
|
||||
changed = true;
|
||||
|
||||
@@ -215,6 +215,67 @@ describe("JSON Pointer escaping (RFC 6901)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// JSON Pointer prototype safety
|
||||
// =============================================================================
|
||||
|
||||
describe("JSON Pointer prototype safety", () => {
|
||||
const blockedTokens = ["__proto__", "constructor", "prototype"];
|
||||
|
||||
it.each(blockedTokens)("rejects %s in path utility writes", (token) => {
|
||||
const pollutionKey = "__json_render_pollution_probe__";
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
setByPath(data, `/${token}/${pollutionKey}`, "set");
|
||||
addByPath(data, `/safe/${token}/${pollutionKey}`, "add");
|
||||
|
||||
expect(data).toEqual({});
|
||||
expect(Object.prototype).not.toHaveProperty(pollutionKey);
|
||||
} finally {
|
||||
delete (Object.prototype as Record<string, unknown>)[pollutionKey];
|
||||
}
|
||||
});
|
||||
|
||||
it("does not read or remove values through Object.prototype", () => {
|
||||
const pollutionKey = "__json_render_inherited_probe__";
|
||||
(Object.prototype as Record<string, unknown>)[pollutionKey] = "keep";
|
||||
|
||||
try {
|
||||
expect(getByPath({}, `/__proto__/${pollutionKey}`)).toBeUndefined();
|
||||
removeByPath({}, `/__proto__/${pollutionKey}`);
|
||||
expect((Object.prototype as Record<string, unknown>)[pollutionKey]).toBe(
|
||||
"keep",
|
||||
);
|
||||
} finally {
|
||||
delete (Object.prototype as Record<string, unknown>)[pollutionKey];
|
||||
}
|
||||
});
|
||||
|
||||
it.each(blockedTokens)(
|
||||
"rejects compound patches containing %s before mutation",
|
||||
(token) => {
|
||||
const destination: Record<string, unknown> = { source: "one" };
|
||||
applySpecStreamPatch(destination, {
|
||||
op: "move",
|
||||
from: "/source",
|
||||
path: `/${token}/moved`,
|
||||
});
|
||||
|
||||
const source: Record<string, unknown> = {};
|
||||
applySpecStreamPatch(source, {
|
||||
op: "copy",
|
||||
from: `/${token}/value`,
|
||||
path: "/copy",
|
||||
});
|
||||
|
||||
expect(destination).toEqual({ source: "one" });
|
||||
expect(source).toEqual({});
|
||||
expect(Object.hasOwn(source, "copy")).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// addByPath (RFC 6902 "add" semantics)
|
||||
// =============================================================================
|
||||
|
||||
@@ -280,6 +280,26 @@ export function parseJsonPointer(path: string): string[] {
|
||||
return raw.map(unescapeJsonPointer);
|
||||
}
|
||||
|
||||
const blockedJsonPointerTokens = new Set([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Reject tokens that can traverse or modify JavaScript prototype chains.
|
||||
* Validation happens after JSON Pointer unescaping so encoded paths cannot
|
||||
* bypass it.
|
||||
*/
|
||||
function hasBlockedJsonPointerToken(segments: string[]): boolean {
|
||||
return segments.some((segment) => blockedJsonPointerTokens.has(segment));
|
||||
}
|
||||
|
||||
/** @internal Shared by JSON Pointer-based state stores. */
|
||||
export function isSafeJsonPointerPath(path: string): boolean {
|
||||
return !hasBlockedJsonPointerToken(parseJsonPointer(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from an object by JSON Pointer path (RFC 6901)
|
||||
*/
|
||||
@@ -289,6 +309,7 @@ export function getByPath(obj: unknown, path: string): unknown {
|
||||
}
|
||||
|
||||
const segments = parseJsonPointer(path);
|
||||
if (hasBlockedJsonPointerToken(segments)) return undefined;
|
||||
|
||||
let current: unknown = obj;
|
||||
|
||||
@@ -362,7 +383,7 @@ export function setByPath(
|
||||
): void {
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0) return;
|
||||
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
|
||||
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
|
||||
@@ -412,7 +433,7 @@ export function addByPath(
|
||||
): void {
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0) return;
|
||||
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
|
||||
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
|
||||
@@ -458,7 +479,7 @@ export function addByPath(
|
||||
export function removeByPath(obj: Record<string, unknown>, path: string): void {
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0) return;
|
||||
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
|
||||
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
|
||||
@@ -617,6 +638,15 @@ export function applySpecStreamPatch<T extends Record<string, unknown>>(
|
||||
obj: T,
|
||||
patch: SpecStreamLine,
|
||||
): T {
|
||||
if (!isSafeJsonPointerPath(patch.path)) return obj;
|
||||
if (
|
||||
(patch.op === "move" || patch.op === "copy") &&
|
||||
patch.from !== undefined &&
|
||||
!isSafeJsonPointerPath(patch.from)
|
||||
) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
switch (patch.op) {
|
||||
case "add":
|
||||
addByPath(obj, patch.path, patch.value);
|
||||
|
||||
@@ -255,8 +255,6 @@ const { errors, validate } = useFieldValidation("/form/email", {
|
||||
});
|
||||
```
|
||||
|
||||
Controls registered through `useFieldValidation` participate in form-wide validation while they are mounted with a binding path and validation configuration. Unmounting one of these controls, changing its binding, or removing its validation configuration clears that path's validation errors without deleting its stored value. Direct calls to `useValidation().registerField()` create a durable registration that remains for the lifetime of the `ValidationProvider`.
|
||||
|
||||
## Hooks
|
||||
|
||||
| Hook | Purpose |
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ValidationConfig } from "@json-render/core";
|
||||
import { StateProvider } from "./state";
|
||||
import {
|
||||
ValidationProvider,
|
||||
useFieldValidation,
|
||||
useValidation,
|
||||
} from "./validation";
|
||||
|
||||
const requiredConfig: ValidationConfig = {
|
||||
checks: [{ type: "required", message: "Name is required" }],
|
||||
};
|
||||
const emailConfig: ValidationConfig = {
|
||||
checks: [{ type: "email", message: "Invalid email" }],
|
||||
};
|
||||
|
||||
function Field({
|
||||
testId,
|
||||
config,
|
||||
}: {
|
||||
testId: string;
|
||||
config: ValidationConfig;
|
||||
}) {
|
||||
const { errors } = useFieldValidation("/form/name", config);
|
||||
return <span data-testid={testId}>{errors[0] ?? ""}</span>;
|
||||
}
|
||||
|
||||
function ValidatingField({
|
||||
testId,
|
||||
config,
|
||||
}: {
|
||||
testId: string;
|
||||
config: ValidationConfig;
|
||||
}) {
|
||||
const { errors, validate } = useFieldValidation("/form/name", config);
|
||||
return (
|
||||
<>
|
||||
<button onClick={validate}>Validate first</button>
|
||||
<span data-testid={testId}>{errors[0] ?? ""}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidateButton() {
|
||||
const { validateAll } = useValidation();
|
||||
return <button onClick={validateAll}>Validate</button>;
|
||||
}
|
||||
|
||||
function ImperativeRegistration({
|
||||
onRegister,
|
||||
}: {
|
||||
onRegister: (value: void) => void;
|
||||
}) {
|
||||
const { registerField } = useValidation();
|
||||
React.useEffect(() => {
|
||||
onRegister(registerField("/form/name", requiredConfig));
|
||||
}, [onRegister, registerField]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function TestForm({ showFirst }: { showFirst: boolean }) {
|
||||
return (
|
||||
<StateProvider initialState={{ form: { name: "not-an-email" } }}>
|
||||
<ValidationProvider>
|
||||
{showFirst && (
|
||||
<Field key="first" testId="first" config={requiredConfig} />
|
||||
)}
|
||||
<Field key="active" testId="active" config={emailConfig} />
|
||||
<ValidateButton />
|
||||
</ValidationProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LocallyValidatedSharedPathForm({
|
||||
showFirst,
|
||||
showActive = true,
|
||||
}: {
|
||||
showFirst: boolean;
|
||||
showActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<StateProvider initialState={{ form: { name: "" } }}>
|
||||
<ValidationProvider>
|
||||
{showFirst && (
|
||||
<ValidatingField testId="first" config={requiredConfig} />
|
||||
)}
|
||||
{showActive && <Field testId="active" config={emailConfig} />}
|
||||
</ValidationProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ValidationProvider registrations", () => {
|
||||
it("preserves the fire-and-forget registerField contract", () => {
|
||||
let registrationResult: unknown = "not registered";
|
||||
|
||||
render(
|
||||
<StateProvider initialState={{ form: { name: "" } }}>
|
||||
<ValidationProvider>
|
||||
<ImperativeRegistration
|
||||
onRegister={(value) => {
|
||||
registrationResult = value;
|
||||
}}
|
||||
/>
|
||||
</ValidationProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
expect(registrationResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves errors when a non-active shared-path registration unmounts", () => {
|
||||
const view = render(<TestForm showFirst />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Validate" }));
|
||||
expect(screen.getByTestId("first").textContent).toBe("Invalid email");
|
||||
expect(screen.getByTestId("active").textContent).toBe("Invalid email");
|
||||
|
||||
view.rerender(<TestForm showFirst={false} />);
|
||||
|
||||
expect(screen.getByTestId("active").textContent).toBe("Invalid email");
|
||||
});
|
||||
|
||||
it("clears errors produced by a released shared-path registration", () => {
|
||||
const view = render(<LocallyValidatedSharedPathForm showFirst />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Validate first" }));
|
||||
expect(screen.getByTestId("first").textContent).toBe("Name is required");
|
||||
expect(screen.getByTestId("active").textContent).toBe("Name is required");
|
||||
|
||||
view.rerender(<LocallyValidatedSharedPathForm showFirst={false} />);
|
||||
|
||||
expect(screen.getByTestId("active").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("preserves errors owned by the fallback active registration", () => {
|
||||
const view = render(<LocallyValidatedSharedPathForm showFirst />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Validate first" }));
|
||||
expect(screen.getByTestId("first").textContent).toBe("Name is required");
|
||||
expect(screen.getByTestId("active").textContent).toBe("Name is required");
|
||||
|
||||
view.rerender(
|
||||
<LocallyValidatedSharedPathForm showFirst showActive={false} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("first").textContent).toBe("Name is required");
|
||||
});
|
||||
});
|
||||
@@ -50,9 +50,6 @@ export interface ValidationContextValue {
|
||||
}
|
||||
|
||||
const ValidationContext = createContext<ValidationContextValue | null>(null);
|
||||
const MountedFieldRegistrationContext = createContext<
|
||||
((path: string, config: ValidationConfig) => () => void) | null
|
||||
>(null);
|
||||
const EMPTY_VALIDATION_FUNCTIONS: Record<string, ValidationFunction> = {};
|
||||
|
||||
/**
|
||||
@@ -64,28 +61,37 @@ export interface ValidationProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Compare JSON-like validation config values structurally. */
|
||||
function validationValueEqual(a: unknown, b: unknown): boolean {
|
||||
/**
|
||||
* Compare two DynamicValue args records shallowly.
|
||||
* Values are primitives or { $state: string }, so shallow comparison suffices.
|
||||
*/
|
||||
function dynamicArgsEqual(
|
||||
a: Record<string, unknown> | undefined,
|
||||
b: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null) return false;
|
||||
if (typeof a !== "object" || typeof b !== "object") return false;
|
||||
if (!a || !b) return false;
|
||||
|
||||
if (Array.isArray(a)) {
|
||||
if (!Array.isArray(b) || a.length !== b.length) return false;
|
||||
return a.every((value, index) => validationValueEqual(value, b[index]));
|
||||
}
|
||||
|
||||
if (Array.isArray(b)) return false;
|
||||
|
||||
const recordA = a as Record<string, unknown>;
|
||||
const recordB = b as Record<string, unknown>;
|
||||
const keysA = Object.keys(recordA);
|
||||
const keysB = Object.keys(recordB);
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
for (const key of keysA) {
|
||||
if (!(key in recordB)) return false;
|
||||
if (!validationValueEqual(recordA[key], recordB[key])) return false;
|
||||
const va = a[key];
|
||||
const vb = b[key];
|
||||
if (va === vb) continue;
|
||||
// Handle { $state: string } objects
|
||||
if (
|
||||
typeof va === "object" &&
|
||||
va !== null &&
|
||||
typeof vb === "object" &&
|
||||
vb !== null
|
||||
) {
|
||||
const sa = (va as Record<string, unknown>).$state;
|
||||
const sb = (vb as Record<string, unknown>).$state;
|
||||
if (typeof sa === "string" && sa === sb) continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -97,29 +103,25 @@ function validationConfigEqual(
|
||||
a: ValidationConfig,
|
||||
b: ValidationConfig,
|
||||
): boolean {
|
||||
return validationValueEqual(a, b);
|
||||
}
|
||||
if (a === b) return true;
|
||||
|
||||
/** Return the config that currently owns validation for a path. */
|
||||
function getActiveConfig(
|
||||
registrations: Map<symbol, ValidationConfig>,
|
||||
): ValidationConfig | undefined {
|
||||
let active: ValidationConfig | undefined;
|
||||
for (const config of registrations.values()) {
|
||||
active = config;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
// Compare validateOn
|
||||
if (a.validateOn !== b.validateOn) return false;
|
||||
|
||||
/** Whether any live registration uses the given validation config. */
|
||||
function hasRegisteredConfig(
|
||||
registrations: Map<symbol, ValidationConfig>,
|
||||
target: ValidationConfig,
|
||||
): boolean {
|
||||
for (const config of registrations.values()) {
|
||||
if (validationConfigEqual(config, target)) return true;
|
||||
// Compare checks arrays
|
||||
const ac = a.checks ?? [];
|
||||
const bc = b.checks ?? [];
|
||||
if (ac.length !== bc.length) return false;
|
||||
|
||||
for (let i = 0; i < ac.length; i++) {
|
||||
const ca = ac[i]!;
|
||||
const cb = bc[i]!;
|
||||
if (ca.type !== cb.type) return false;
|
||||
if (ca.message !== cb.message) return false;
|
||||
if (!dynamicArgsEqual(ca.args, cb.args)) return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,127 +139,23 @@ export function ValidationProvider({
|
||||
// Mutable mirror of fieldStates for synchronous reads (e.g. reading errors
|
||||
// immediately after validateAll() before React flushes the batched setState).
|
||||
const fieldStatesRef = useRef<Record<string, FieldValidationState>>({});
|
||||
// Tracks the config that produced each stored result so cleanup can tell
|
||||
// whether that result still belongs to any mounted registration.
|
||||
const fieldStateConfigsRef = useRef<Map<string, ValidationConfig>>(new Map());
|
||||
// Each mounted control gets its own registration. This lets one control
|
||||
// unregister without disabling another control bound to the same path.
|
||||
const fieldRegistrationsRef = useRef<
|
||||
Map<string, Map<symbol, ValidationConfig>>
|
||||
>(new Map());
|
||||
// Imperative registerField calls retain one durable registration per path.
|
||||
// Mounted controls use separate identities so they can unregister safely.
|
||||
const imperativeRegistrationIdsRef = useRef<Map<string, symbol>>(new Map());
|
||||
|
||||
const clear = useCallback((path: string) => {
|
||||
fieldStateConfigsRef.current.delete(path);
|
||||
if (!Object.prototype.hasOwnProperty.call(fieldStatesRef.current, path)) {
|
||||
return;
|
||||
}
|
||||
const next = { ...fieldStatesRef.current };
|
||||
delete next[path];
|
||||
fieldStatesRef.current = next;
|
||||
setFieldStates(next);
|
||||
}, []);
|
||||
const [fieldConfigs, setFieldConfigs] = useState<
|
||||
Record<string, ValidationConfig>
|
||||
>({});
|
||||
|
||||
const registerField = useCallback(
|
||||
(path: string, config: ValidationConfig) => {
|
||||
let registrations = fieldRegistrationsRef.current.get(path);
|
||||
if (!registrations) {
|
||||
registrations = new Map();
|
||||
fieldRegistrationsRef.current.set(path, registrations);
|
||||
}
|
||||
|
||||
const previousActiveConfig = getActiveConfig(registrations);
|
||||
let registrationId = imperativeRegistrationIdsRef.current.get(path);
|
||||
if (!registrationId) {
|
||||
registrationId = Symbol(path);
|
||||
imperativeRegistrationIdsRef.current.set(path, registrationId);
|
||||
}
|
||||
|
||||
const existingConfig = registrations.get(registrationId);
|
||||
if (
|
||||
existingConfig &&
|
||||
previousActiveConfig &&
|
||||
validationConfigEqual(existingConfig, config) &&
|
||||
validationConfigEqual(previousActiveConfig, config)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reinsert changed imperative registrations so the latest registration
|
||||
// keeps the same last-writer-wins behavior as the original path store.
|
||||
registrations.delete(registrationId);
|
||||
registrations.set(registrationId, config);
|
||||
if (
|
||||
previousActiveConfig &&
|
||||
!validationConfigEqual(previousActiveConfig, config)
|
||||
) {
|
||||
clear(path);
|
||||
}
|
||||
},
|
||||
[clear],
|
||||
);
|
||||
|
||||
const registerMountedField = useCallback(
|
||||
(path: string, config: ValidationConfig) => {
|
||||
const registrationId = Symbol(path);
|
||||
let registrations = fieldRegistrationsRef.current.get(path);
|
||||
if (!registrations) {
|
||||
registrations = new Map();
|
||||
fieldRegistrationsRef.current.set(path, registrations);
|
||||
}
|
||||
|
||||
const previousActiveConfig = getActiveConfig(registrations);
|
||||
registrations.set(registrationId, config);
|
||||
if (
|
||||
previousActiveConfig &&
|
||||
!validationConfigEqual(previousActiveConfig, config)
|
||||
) {
|
||||
clear(path);
|
||||
}
|
||||
|
||||
let registered = true;
|
||||
return () => {
|
||||
if (!registered) return;
|
||||
registered = false;
|
||||
|
||||
const currentRegistrations = fieldRegistrationsRef.current.get(path);
|
||||
if (!currentRegistrations?.has(registrationId)) return;
|
||||
|
||||
const activeConfigBeforeRemoval = getActiveConfig(currentRegistrations);
|
||||
currentRegistrations.delete(registrationId);
|
||||
const activeConfigAfterRemoval = getActiveConfig(currentRegistrations);
|
||||
const fieldStateConfig = fieldStateConfigsRef.current.get(path);
|
||||
const fieldStateConfigWasReleased =
|
||||
fieldStateConfig !== undefined &&
|
||||
!hasRegisteredConfig(currentRegistrations, fieldStateConfig);
|
||||
const activeConfigChanged =
|
||||
activeConfigBeforeRemoval === undefined ||
|
||||
activeConfigAfterRemoval === undefined ||
|
||||
!validationConfigEqual(
|
||||
activeConfigBeforeRemoval,
|
||||
activeConfigAfterRemoval,
|
||||
);
|
||||
const fieldStateMatchesActiveConfig =
|
||||
fieldStateConfig !== undefined &&
|
||||
activeConfigAfterRemoval !== undefined &&
|
||||
validationConfigEqual(fieldStateConfig, activeConfigAfterRemoval);
|
||||
|
||||
if (currentRegistrations.size === 0) {
|
||||
fieldRegistrationsRef.current.delete(path);
|
||||
setFieldConfigs((prev) => {
|
||||
const existing = prev[path];
|
||||
// Bail out (return same reference) if config is unchanged to avoid
|
||||
// infinite re-render loops when callers pass a fresh object each render.
|
||||
if (existing && validationConfigEqual(existing, config)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (
|
||||
!activeConfigAfterRemoval ||
|
||||
fieldStateConfigWasReleased ||
|
||||
(activeConfigChanged && !fieldStateMatchesActiveConfig)
|
||||
) {
|
||||
clear(path);
|
||||
}
|
||||
};
|
||||
return { ...prev, [path]: config };
|
||||
});
|
||||
},
|
||||
[clear],
|
||||
[],
|
||||
);
|
||||
|
||||
const validate = useCallback(
|
||||
@@ -291,7 +189,6 @@ export function ValidationProvider({
|
||||
...fieldStatesRef.current,
|
||||
[path]: newFieldState,
|
||||
};
|
||||
fieldStateConfigsRef.current.set(path, config);
|
||||
setFieldStates(fieldStatesRef.current);
|
||||
|
||||
return result;
|
||||
@@ -312,32 +209,16 @@ export function ValidationProvider({
|
||||
setFieldStates(fieldStatesRef.current);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback((path: string) => {
|
||||
const { [path]: _, ...rest } = fieldStatesRef.current;
|
||||
fieldStatesRef.current = rest;
|
||||
setFieldStates(rest);
|
||||
}, []);
|
||||
|
||||
const validateAll = useCallback(() => {
|
||||
let allValid = true;
|
||||
|
||||
// Only active registrations belong in a form-wide validation result.
|
||||
// Pruning here is a final safeguard against errors from released fields.
|
||||
const activePaths = new Set(fieldRegistrationsRef.current.keys());
|
||||
const hasStaleStates = Object.keys(fieldStatesRef.current).some(
|
||||
(path) => !activePaths.has(path),
|
||||
);
|
||||
if (hasStaleStates) {
|
||||
fieldStatesRef.current = Object.fromEntries(
|
||||
Object.entries(fieldStatesRef.current).filter(([path]) =>
|
||||
activePaths.has(path),
|
||||
),
|
||||
);
|
||||
setFieldStates(fieldStatesRef.current);
|
||||
}
|
||||
for (const path of fieldStateConfigsRef.current.keys()) {
|
||||
if (!activePaths.has(path)) {
|
||||
fieldStateConfigsRef.current.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [path, registrations] of fieldRegistrationsRef.current) {
|
||||
const config = getActiveConfig(registrations);
|
||||
if (!config) continue;
|
||||
for (const [path, config] of Object.entries(fieldConfigs)) {
|
||||
const result = validate(path, config);
|
||||
if (!result.valid) {
|
||||
allValid = false;
|
||||
@@ -345,7 +226,7 @@ export function ValidationProvider({
|
||||
}
|
||||
|
||||
return allValid;
|
||||
}, [validate]);
|
||||
}, [fieldConfigs, validate]);
|
||||
|
||||
const value = useMemo<ValidationContextValue>(
|
||||
() => ({
|
||||
@@ -376,9 +257,7 @@ export function ValidationProvider({
|
||||
|
||||
return (
|
||||
<ValidationContext.Provider value={value}>
|
||||
<MountedFieldRegistrationContext.Provider value={registerMountedField}>
|
||||
{children}
|
||||
</MountedFieldRegistrationContext.Provider>
|
||||
{children}
|
||||
</ValidationContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -421,33 +300,15 @@ export function useFieldValidation(
|
||||
validate: validateField,
|
||||
touch: touchField,
|
||||
clear: clearField,
|
||||
registerField,
|
||||
} = useValidation();
|
||||
const registerMountedField = useContext(MountedFieldRegistrationContext);
|
||||
if (!registerMountedField) {
|
||||
throw new Error(
|
||||
"useFieldValidation must be used within a ValidationProvider",
|
||||
);
|
||||
}
|
||||
|
||||
// Stabilize structurally equal inline configs so unrelated re-renders do not
|
||||
// tear down and recreate a field registration.
|
||||
const stableConfigRef = useRef<ValidationConfig | undefined>(config);
|
||||
if (
|
||||
(config === undefined && stableConfigRef.current !== undefined) ||
|
||||
(config !== undefined &&
|
||||
(stableConfigRef.current === undefined ||
|
||||
!validationConfigEqual(stableConfigRef.current, config)))
|
||||
) {
|
||||
stableConfigRef.current = config;
|
||||
}
|
||||
const stableConfig = stableConfigRef.current;
|
||||
|
||||
// The returned cleanup releases this control's registration on unmount and
|
||||
// before a binding path or validation config changes.
|
||||
// Register field on mount
|
||||
React.useEffect(() => {
|
||||
if (!path || !stableConfig) return;
|
||||
return registerMountedField(path, stableConfig);
|
||||
}, [path, stableConfig, registerMountedField]);
|
||||
if (path && config) {
|
||||
registerField(path, config);
|
||||
}
|
||||
}, [path, config, registerField]);
|
||||
|
||||
const state = fieldStates[path] ?? {
|
||||
touched: false,
|
||||
@@ -456,8 +317,8 @@ export function useFieldValidation(
|
||||
};
|
||||
|
||||
const validate = useCallback(
|
||||
() => validateField(path, stableConfig ?? { checks: [] }),
|
||||
[path, stableConfig, validateField],
|
||||
() => validateField(path, config ?? { checks: [] }),
|
||||
[path, config, validateField],
|
||||
);
|
||||
|
||||
const touch = useCallback(() => touchField(path), [path, touchField]);
|
||||
|
||||
@@ -656,438 +656,6 @@ describe("validateForm action", () => {
|
||||
const state = getState();
|
||||
expect(state.formValidation).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
|
||||
it("unregisters a conditional field when it is hidden", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
|
||||
const spec: Spec = {
|
||||
state: {
|
||||
showGuest: false,
|
||||
form: { guestName: "" },
|
||||
result: null,
|
||||
},
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["showGuest", "hideGuest", "guestName", "submit"],
|
||||
},
|
||||
showGuest: {
|
||||
type: "Button",
|
||||
props: { label: "Show guest" },
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/showGuest", value: true },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
hideGuest: {
|
||||
type: "Button",
|
||||
props: { label: "Hide guest" },
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/showGuest", value: false },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
guestName: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Guest name",
|
||||
value: { $bindState: "/form/guestName" },
|
||||
checks: [{ type: "required", message: "Guest name is required" }],
|
||||
},
|
||||
visible: { $state: "/showGuest", eq: true },
|
||||
children: [],
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: {
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show guest" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/guestName": ["Guest name is required"] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide guest" }));
|
||||
});
|
||||
expect(screen.queryByText("Guest name")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState()).toMatchObject({
|
||||
form: { guestName: "" },
|
||||
result: { valid: true, errors: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("validates only the fields in the active tab", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
|
||||
const spec: Spec = {
|
||||
state: {
|
||||
activeTab: "guest",
|
||||
form: { guestName: "", accountName: "Ada" },
|
||||
result: null,
|
||||
},
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: [
|
||||
"guestTab",
|
||||
"accountTab",
|
||||
"guestName",
|
||||
"accountName",
|
||||
"submit",
|
||||
],
|
||||
},
|
||||
guestTab: {
|
||||
type: "Button",
|
||||
props: { label: "Guest tab" },
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/activeTab", value: "guest" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
accountTab: {
|
||||
type: "Button",
|
||||
props: { label: "Account tab" },
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/activeTab", value: "account" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
guestName: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Guest name",
|
||||
value: { $bindState: "/form/guestName" },
|
||||
checks: [{ type: "required", message: "Guest name is required" }],
|
||||
},
|
||||
visible: { $state: "/activeTab", eq: "guest" },
|
||||
children: [],
|
||||
},
|
||||
accountName: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Account name",
|
||||
value: { $bindState: "/form/accountName" },
|
||||
checks: [{ type: "required", message: "Account name is required" }],
|
||||
},
|
||||
visible: { $state: "/activeTab", eq: "account" },
|
||||
children: [],
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: {
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/guestName": ["Guest name is required"] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Account tab" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
|
||||
it("unregisters a mounted field when its checks are removed", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
const createSpec = (withChecks: boolean): Spec => ({
|
||||
state: { form: { name: "" }, result: null },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["name", "submit"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Name",
|
||||
value: { $bindState: "/form/name" },
|
||||
...(withChecks
|
||||
? {
|
||||
checks: [{ type: "required", message: "Name is required" }],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: {
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const spec = createSpec(true);
|
||||
const view = render(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(screen.getByTestId("input-error").textContent).toBe(
|
||||
"Name is required",
|
||||
);
|
||||
|
||||
const specWithoutChecks = createSpec(false);
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={specWithoutChecks} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(screen.queryByTestId("input-error")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
|
||||
it("keeps a shared path registered when one control unmounts", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
const required = [{ type: "required", message: "Name is required" }];
|
||||
|
||||
const spec: Spec = {
|
||||
state: { showDuplicate: true, form: { name: "" }, result: null },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["primary", "duplicate", "hideDuplicate", "submit"],
|
||||
},
|
||||
primary: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Primary name",
|
||||
value: { $bindState: "/form/name" },
|
||||
checks: required,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
duplicate: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Duplicate name",
|
||||
value: { $bindState: "/form/name" },
|
||||
checks: required,
|
||||
},
|
||||
visible: { $state: "/showDuplicate", eq: true },
|
||||
children: [],
|
||||
},
|
||||
hideDuplicate: {
|
||||
type: "Button",
|
||||
props: { label: "Hide duplicate" },
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/showDuplicate", value: false },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: {
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(screen.getAllByTestId("input-error")).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide duplicate" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/name": ["Name is required"] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId("input"), {
|
||||
target: { value: "Ada" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
|
||||
it("moves validation when a control changes binding paths", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
const createSpec = (path: string): Spec => ({
|
||||
state: { form: { oldName: "", currentName: "" }, result: null },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["name", "submit"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Name",
|
||||
value: { $bindState: path },
|
||||
checks: [{ type: "required", message: "Name is required" }],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: {
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const oldSpec = createSpec("/form/oldName");
|
||||
const view = render(
|
||||
<JSONUIProvider registry={reg} initialState={oldSpec.state}>
|
||||
<Renderer spec={oldSpec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/oldName": ["Name is required"] },
|
||||
});
|
||||
|
||||
const currentSpec = createSpec("/form/currentName");
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={reg} initialState={oldSpec.state}>
|
||||
<Renderer spec={currentSpec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/currentName": ["Name is required"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -77,6 +77,8 @@ const { result, newPatches } = compiler.push(chunk);
|
||||
const finalSpec = compiler.getResult();
|
||||
```
|
||||
|
||||
JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens are rejected by path utilities, state stores, and SpecStream. For `move` and `copy`, this applies to both `path` and `from`.
|
||||
|
||||
## Dynamic Prop Expressions
|
||||
|
||||
Any prop value can be a dynamic expression resolved at render time:
|
||||
|
||||
@@ -121,7 +121,7 @@ Use `visible` on elements to show/hide based on state. New syntax: `{ "$state":
|
||||
| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. |
|
||||
| `ActionProvider` | Handle actions dispatched via the event system |
|
||||
| `VisibilityProvider` | Enable conditional rendering based on state |
|
||||
| `ValidationProvider` | Form field validation; mounted controls unregister without deleting stored values |
|
||||
| `ValidationProvider` | Form field validation |
|
||||
|
||||
### External Store (Controlled Mode)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user