Эх сурвалжийг харах

vue improvements (#163)

* vue improvements

* fixes

* fixes

* fixes

* fix ci

* fix ci
Chris Tate 4 сар өмнө
parent
commit
3c11f19be4

+ 2 - 2
apps/web/app/(main)/docs/api/vue/page.mdx

@@ -241,6 +241,6 @@ const {
 | `useFieldValidation().isValid` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
 | `VisibilityContextValue.ctx` | `CoreVisibilityContext` | `ComputedRef<CoreVisibilityContext>` | Vue reactivity; use `ctx.value` |
 | `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
-| `useBoundProp` | exported | not available | React-specific; use `bindings` directly in Vue |
+| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
 | `VisibilityProviderProps` | exported | not exported (no props) | Vue uses slot, no prop needed |
-| Streaming hooks | `useUIStream`, `useChatUI` | not available | Vue package is UI-only for now |
+| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |

+ 1 - 1
examples/vite-renderers/src/spec.ts

@@ -96,7 +96,7 @@ export const demoSpec: Spec = {
     // ---- Milestone badge (visible only when count >= 10) ----
     "milestone-badge": {
       type: "Badge",
-      props: { label: "🎉 Milestone reached: 10!", color: "#10b981" },
+      props: { label: "Milestone reached: 10!", color: "#10b981" },
       visible: { $state: "/count", gte: 10 },
     },
 

+ 2 - 2
examples/vue/src/lib/spec.ts

@@ -1,4 +1,4 @@
-import type { Spec } from "@json-render/vue";
+import type { Spec } from "@json-render/core";
 
 export const demoSpec: Spec = {
   root: "root",
@@ -78,7 +78,7 @@ export const demoSpec: Spec = {
     // ---- Milestone badge (visible only when count >= 10) ----
     "milestone-badge": {
       type: "Badge",
-      props: { label: "🎉 Milestone reached: 10!", color: "#10b981" },
+      props: { label: "Milestone reached: 10!", color: "#10b981" },
       visible: { $state: "/count", gte: 10 },
     },
 

+ 7 - 1
packages/react/src/contexts/actions.tsx

@@ -251,9 +251,15 @@ export function ActionProvider({
           return;
         }
         const valid = validateAll();
+        const errors: Record<string, string[]> = {};
+        for (const [path, fs] of Object.entries(validation.fieldStates)) {
+          if (fs.result && !fs.result.valid) {
+            errors[path] = fs.result.errors;
+          }
+        }
         const statePath =
           (resolved.params?.statePath as string) || "/formValidation";
-        set(statePath, { valid });
+        set(statePath, { valid, errors });
         return;
       }
 

+ 31 - 19
packages/react/src/contexts/validation.tsx

@@ -3,6 +3,7 @@
 import React, {
   createContext,
   useContext,
+  useRef,
   useState,
   useCallback,
   useMemo,
@@ -134,6 +135,9 @@ export function ValidationProvider({
   const [fieldStates, setFieldStates] = useState<
     Record<string, FieldValidationState>
   >({});
+  // 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>>({});
   const [fieldConfigs, setFieldConfigs] = useState<
     Record<string, ValidationConfig>
   >({});
@@ -175,14 +179,16 @@ export function ValidationProvider({
         customFunctions,
       });
 
-      setFieldStates((prev) => ({
-        ...prev,
-        [path]: {
-          touched: prev[path]?.touched ?? true,
-          validated: true,
-          result,
-        },
-      }));
+      const newFieldState: FieldValidationState = {
+        touched: fieldStatesRef.current[path]?.touched ?? true,
+        validated: true,
+        result,
+      };
+      fieldStatesRef.current = {
+        ...fieldStatesRef.current,
+        [path]: newFieldState,
+      };
+      setFieldStates(fieldStatesRef.current);
 
       return result;
     },
@@ -190,22 +196,22 @@ export function ValidationProvider({
   );
 
   const touch = useCallback((path: string) => {
-    setFieldStates((prev) => ({
-      ...prev,
+    fieldStatesRef.current = {
+      ...fieldStatesRef.current,
       [path]: {
-        ...prev[path],
+        ...fieldStatesRef.current[path],
         touched: true,
-        validated: prev[path]?.validated ?? false,
-        result: prev[path]?.result ?? null,
+        validated: fieldStatesRef.current[path]?.validated ?? false,
+        result: fieldStatesRef.current[path]?.result ?? null,
       },
-    }));
+    };
+    setFieldStates(fieldStatesRef.current);
   }, []);
 
   const clear = useCallback((path: string) => {
-    setFieldStates((prev) => {
-      const { [path]: _, ...rest } = prev;
-      return rest;
-    });
+    const { [path]: _, ...rest } = fieldStatesRef.current;
+    fieldStatesRef.current = rest;
+    setFieldStates(rest);
   }, []);
 
   const validateAll = useCallback(() => {
@@ -224,7 +230,11 @@ export function ValidationProvider({
   const value = useMemo<ValidationContextValue>(
     () => ({
       customFunctions,
-      fieldStates,
+      // Getter returns the mutable ref so callers that read fieldStates
+      // synchronously after validateAll() see the latest values.
+      get fieldStates() {
+        return fieldStatesRef.current;
+      },
       validate,
       touch,
       clear,
@@ -233,6 +243,8 @@ export function ValidationProvider({
     }),
     [
       customFunctions,
+      // fieldStates (React state) stays in deps so the context value object
+      // is recreated on re-render, triggering downstream consumers.
       fieldStates,
       validate,
       touch,

+ 6 - 3
packages/react/src/dynamic-forms.test.tsx

@@ -546,7 +546,10 @@ describe("validateForm action", () => {
     });
 
     const state = getState();
-    expect(state.result).toEqual({ valid: false });
+    expect(state.result).toEqual({
+      valid: false,
+      errors: { "/form/email": ["Email is required"] },
+    });
   });
 
   it("writes { valid: true } when all fields pass validation", async () => {
@@ -601,7 +604,7 @@ describe("validateForm action", () => {
     });
 
     const state = getState();
-    expect(state.result).toEqual({ valid: true });
+    expect(state.result).toEqual({ valid: true, errors: {} });
   });
 
   it("defaults to /formValidation when no statePath is provided", async () => {
@@ -651,7 +654,7 @@ describe("validateForm action", () => {
     });
 
     const state = getState();
-    expect(state.formValidation).toEqual({ valid: true });
+    expect(state.formValidation).toEqual({ valid: true, errors: {} });
   });
 });
 

+ 1 - 1
packages/react/src/schema.ts

@@ -70,7 +70,7 @@ export const schema = defineSchema(
       {
         name: "validateForm",
         description:
-          "Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean }.",
+          "Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
       },
     ],
     defaultRules: [

+ 4 - 4
packages/vue/README.md

@@ -91,7 +91,7 @@ export const { registry } = defineRegistry(catalog, {
 });
 ```
 
-> **Note:** Vue does not have `useBoundProp`. For two-way binding, use `{ "$bindState": "/path" }` on the value prop and handle the `bindings` object in your component.
+> **Tip:** Use `useBoundProp(props.value, bindings?.value)` for two-way binding, or handle the `bindings` object directly in your component.
 
 ### 3. Render Specs
 
@@ -345,7 +345,7 @@ See [@json-render/core](../core/README.md) for full expression syntax.
 
 ## Built-in Actions
 
-The `setState`, `pushState`, and `removeState` actions are built into the Vue schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
+The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the Vue schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
 
 ```json
 {
@@ -492,5 +492,5 @@ const spec = {
 |-----|-------|-----|------|
 | `useStateStore().state` | `StateModel` | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
 | `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
-| `useBoundProp` | exported | not available | React-specific; use `bindings` directly in Vue |
-| Streaming hooks | `useUIStream`, `useChatUI` | not available | Vue package is UI-only for now |
+| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
+| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |

+ 25 - 0
packages/vue/src/composables/actions.ts

@@ -18,6 +18,7 @@ import {
   type ResolvedAction,
 } from "@json-render/core";
 import { useStateStore } from "./state";
+import { useOptionalValidation } from "./validation";
 
 /**
  * Generate a unique ID for use with the "$id" token.
@@ -120,6 +121,7 @@ export const ActionProvider = defineComponent({
   },
   setup(props, { slots }) {
     const { get, set, getSnapshot } = useStateStore();
+    const validation = useOptionalValidation();
 
     const handlers = ref<Record<string, ActionHandler>>(props.handlers ?? {});
     const loadingActions = ref<Set<string>>(new Set());
@@ -182,6 +184,29 @@ export const ActionProvider = defineComponent({
         return;
       }
 
+      // Built-in: validateForm — triggers validateAll and writes result to state
+      if (resolved.action === "validateForm") {
+        const validateAll = validation?.validateAll;
+        if (!validateAll) {
+          console.warn(
+            "validateForm action was dispatched but no ValidationProvider is connected. " +
+              "Ensure ValidationProvider is rendered inside the provider tree.",
+          );
+          return;
+        }
+        const valid = validateAll();
+        const errors: Record<string, string[]> = {};
+        for (const [path, fs] of Object.entries(validation.fieldStates)) {
+          if (fs.result && !fs.result.valid) {
+            errors[path] = fs.result.errors;
+          }
+        }
+        const statePath =
+          (resolved.params?.statePath as string) || "/formValidation";
+        set(statePath, { valid, errors });
+        return;
+      }
+
       // Built-in: push (navigation)
       if (resolved.action === "push" && resolved.params) {
         const screen = resolved.params.screen as string;

+ 482 - 0
packages/vue/src/dynamic-forms.test.ts

@@ -0,0 +1,482 @@
+import { describe, it, expect, vi } from "vitest";
+import { defineComponent, h, nextTick, type Component } from "vue";
+import { mount } from "@vue/test-utils";
+import type { Spec } from "@json-render/core";
+import { StateProvider, useStateStore } from "./composables/state";
+import { VisibilityProvider } from "./composables/visibility";
+import { ActionProvider } from "./composables/actions";
+import { ValidationProvider } from "./composables/validation";
+import { useFieldValidation } from "./composables/validation";
+import { useBoundProp } from "./hooks";
+import {
+  Renderer,
+  JSONUIProvider,
+  type ComponentRegistry,
+  type ComponentRenderProps,
+} from "./renderer";
+
+// =============================================================================
+// Stub components
+// =============================================================================
+
+const Button = defineComponent({
+  name: "Button",
+  props: {
+    element: { type: Object, required: true },
+    emit: { type: Function, required: true },
+    on: { type: Function, required: true },
+    bindings: { type: Object, default: undefined },
+    loading: { type: Boolean, default: undefined },
+  },
+  setup(props) {
+    return () =>
+      h(
+        "button",
+        { "data-testid": "btn", onClick: () => props.emit("press") },
+        String((props.element as any).props?.label ?? ""),
+      );
+  },
+});
+
+const Text = defineComponent({
+  name: "Text",
+  props: {
+    element: { type: Object, required: true },
+    emit: { type: Function, required: true },
+    on: { type: Function, required: true },
+    bindings: { type: Object, default: undefined },
+    loading: { type: Boolean, default: undefined },
+  },
+  setup(props) {
+    return () => {
+      const value = (props.element as any).props?.text;
+      return h(
+        "span",
+        { "data-testid": "text" },
+        value == null
+          ? ""
+          : typeof value === "string"
+            ? value
+            : JSON.stringify(value),
+      );
+    };
+  },
+});
+
+const Stack = defineComponent({
+  name: "Stack",
+  props: {
+    element: { type: Object, required: true },
+    emit: { type: Function, required: true },
+    on: { type: Function, required: true },
+    bindings: { type: Object, default: undefined },
+    loading: { type: Boolean, default: undefined },
+  },
+  setup(_props, { slots }) {
+    return () => h("div", { "data-testid": "stack" }, slots.default?.());
+  },
+});
+
+const InputField = defineComponent({
+  name: "InputField",
+  props: {
+    element: { type: Object, required: true },
+    emit: { type: Function, required: true },
+    on: { type: Function, required: true },
+    bindings: { type: Object, default: undefined },
+    loading: { type: Boolean, default: undefined },
+  },
+  setup(props) {
+    const elProps = (props.element as any).props ?? {};
+    const bindingPath = (props.bindings as Record<string, string>)?.value;
+    const [boundValue, setBoundValue] = useBoundProp<string>(
+      elProps.value as string | undefined,
+      bindingPath,
+    );
+
+    const hasValidation = !!(bindingPath && elProps.checks?.length);
+    const config = hasValidation ? { checks: elProps.checks ?? [] } : undefined;
+    const { errors } = useFieldValidation(bindingPath ?? "", config);
+
+    return () =>
+      h("div", null, [
+        elProps.label ? h("label", null, elProps.label) : null,
+        h("input", {
+          "data-testid": "input",
+          value: boundValue ?? "",
+          onInput: (e: Event) =>
+            setBoundValue((e.target as HTMLInputElement).value),
+        }),
+        errors.value.length > 0
+          ? h("span", { "data-testid": "input-error" }, errors.value[0])
+          : null,
+      ]);
+  },
+});
+
+const Select = defineComponent({
+  name: "Select",
+  props: {
+    element: { type: Object, required: true },
+    emit: { type: Function, required: true },
+    on: { type: Function, required: true },
+    bindings: { type: Object, default: undefined },
+    loading: { type: Boolean, default: undefined },
+  },
+  setup(props) {
+    const elProps = (props.element as any).props ?? {};
+    const bindingPath = (props.bindings as Record<string, string>)?.value;
+    const [boundValue] = useBoundProp<string>(
+      elProps.value as string | undefined,
+      bindingPath,
+    );
+    return () => h("span", { "data-testid": "select-value" }, boundValue ?? "");
+  },
+});
+
+const registry: ComponentRegistry = {
+  Button: Button as unknown as Component,
+  Text: Text as unknown as Component,
+  Input: InputField as unknown as Component,
+  Select: Select as unknown as Component,
+  Stack: Stack as unknown as Component,
+};
+
+// State probe to read state from tests
+const StateProbe = defineComponent({
+  name: "StateProbe",
+  setup() {
+    const { state } = useStateStore();
+    return () =>
+      h("pre", { "data-testid": "state-probe" }, JSON.stringify(state.value));
+  },
+});
+
+function getState(wrapper: ReturnType<typeof mount>): Record<string, unknown> {
+  return JSON.parse(wrapper.find("[data-testid='state-probe']").text());
+}
+
+// =============================================================================
+// Mount helper
+// =============================================================================
+
+function mountWithProviders(
+  spec: Spec,
+  opts: {
+    functions?: Record<string, (args: Record<string, unknown>) => unknown>;
+    handlers?: Record<
+      string,
+      (params: Record<string, unknown>) => Promise<void> | void
+    >;
+    initialState?: Record<string, unknown>;
+  } = {},
+) {
+  return mount(JSONUIProvider as Component, {
+    props: {
+      registry,
+      initialState: opts.initialState ?? spec.state ?? {},
+      functions: opts.functions,
+      handlers: opts.handlers,
+    } as any,
+    slots: {
+      default: () => [h(Renderer, { spec, registry }), h(StateProbe)],
+    },
+  });
+}
+
+// =============================================================================
+// $computed expressions in rendering
+// =============================================================================
+
+describe("$computed expressions in rendering", () => {
+  it("resolves a $computed prop using provided functions", () => {
+    const spec: Spec = {
+      state: { first: "Jane", last: "Doe" },
+      root: "main",
+      elements: {
+        main: {
+          type: "Text",
+          props: {
+            text: {
+              $computed: "fullName",
+              args: {
+                first: { $state: "/first" },
+                last: { $state: "/last" },
+              },
+            },
+          },
+          children: [],
+        },
+      },
+    };
+
+    const functions = {
+      fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
+    };
+
+    const wrapper = mountWithProviders(spec, { functions });
+    expect(wrapper.find("[data-testid='text']").text()).toBe("Jane Doe");
+  });
+
+  it("renders gracefully when functions prop is omitted", () => {
+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+    const spec: Spec = {
+      state: {},
+      root: "main",
+      elements: {
+        main: {
+          type: "Text",
+          props: {
+            text: { $computed: "missing" },
+          },
+          children: [],
+        },
+      },
+    };
+
+    const wrapper = mountWithProviders(spec);
+    expect(wrapper.find("[data-testid='text']").text()).toBe("");
+    warnSpy.mockRestore();
+  });
+});
+
+// =============================================================================
+// Watchers
+// =============================================================================
+
+describe("watchers (watch field)", () => {
+  it("does not fire on initial render, fires when watched state changes", async () => {
+    const loadCities = vi.fn();
+
+    const spec: Spec = {
+      state: { form: { country: "" }, citiesLoaded: false },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["btn", "watcher"],
+        },
+        btn: {
+          type: "Button",
+          props: { label: "Set Country" },
+          on: {
+            press: [
+              {
+                action: "setState",
+                params: { statePath: "/form/country", value: "US" },
+              },
+            ],
+          },
+          children: [],
+        },
+        watcher: {
+          type: "Select",
+          props: { value: { $state: "/form/country" } },
+          watch: {
+            "/form/country": {
+              action: "loadCities",
+              params: { country: { $state: "/form/country" } },
+            },
+          },
+          children: [],
+        },
+      },
+    };
+
+    const wrapper = mountWithProviders(spec, { handlers: { loadCities } });
+
+    expect(loadCities).not.toHaveBeenCalled();
+
+    await wrapper.find("[data-testid='btn']").trigger("click");
+    await nextTick();
+    await nextTick();
+
+    expect(loadCities).toHaveBeenCalledTimes(1);
+    expect(loadCities).toHaveBeenCalledWith(
+      expect.objectContaining({ country: "US" }),
+    );
+  });
+
+  it("fires multiple action bindings on the same watch path", async () => {
+    const action1 = vi.fn();
+    const action2 = vi.fn();
+
+    const spec: Spec = {
+      state: { value: "a" },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["btn", "watcher"],
+        },
+        btn: {
+          type: "Button",
+          props: { label: "Change" },
+          on: {
+            press: [
+              {
+                action: "setState",
+                params: { statePath: "/value", value: "b" },
+              },
+            ],
+          },
+          children: [],
+        },
+        watcher: {
+          type: "Text",
+          props: { text: { $state: "/value" } },
+          watch: {
+            "/value": [{ action: "action1" }, { action: "action2" }],
+          },
+          children: [],
+        },
+      },
+    };
+
+    const wrapper = mountWithProviders(spec, {
+      handlers: { action1, action2 },
+    });
+
+    await wrapper.find("[data-testid='btn']").trigger("click");
+    await nextTick();
+    await nextTick();
+
+    expect(action1).toHaveBeenCalledTimes(1);
+    expect(action2).toHaveBeenCalledTimes(1);
+  });
+});
+
+// =============================================================================
+// validateForm action
+// =============================================================================
+
+describe("validateForm action", () => {
+  it("writes { valid: false, errors } when a required field is empty", async () => {
+    const spec: Spec = {
+      state: { form: { email: "" }, result: null },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["emailInput", "submitBtn"],
+        },
+        emailInput: {
+          type: "Input",
+          props: {
+            label: "Email",
+            value: { $bindState: "/form/email" },
+            checks: [{ type: "required", message: "Email is required" }],
+          },
+          children: [],
+        },
+        submitBtn: {
+          type: "Button",
+          props: { label: "Submit" },
+          on: {
+            press: [
+              {
+                action: "validateForm",
+                params: { statePath: "/result" },
+              },
+            ],
+          },
+          children: [],
+        },
+      },
+    };
+
+    const wrapper = mountWithProviders(spec);
+    await wrapper.find("[data-testid='btn']").trigger("click");
+    await nextTick();
+
+    const state = getState(wrapper);
+    expect(state.result).toEqual({
+      valid: false,
+      errors: { "/form/email": ["Email is required"] },
+    });
+  });
+
+  it("writes { valid: true, errors: {} } when all fields pass validation", async () => {
+    const spec: Spec = {
+      state: { form: { email: "test@example.com" }, result: null },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["emailInput", "submitBtn"],
+        },
+        emailInput: {
+          type: "Input",
+          props: {
+            label: "Email",
+            value: { $bindState: "/form/email" },
+            checks: [{ type: "required", message: "Email is required" }],
+          },
+          children: [],
+        },
+        submitBtn: {
+          type: "Button",
+          props: { label: "Submit" },
+          on: {
+            press: [
+              {
+                action: "validateForm",
+                params: { statePath: "/result" },
+              },
+            ],
+          },
+          children: [],
+        },
+      },
+    };
+
+    const wrapper = mountWithProviders(spec);
+    await wrapper.find("[data-testid='btn']").trigger("click");
+    await nextTick();
+
+    const state = getState(wrapper);
+    expect(state.result).toEqual({ valid: true, errors: {} });
+  });
+
+  it("defaults to /formValidation when no statePath is provided", async () => {
+    const spec: Spec = {
+      state: { form: { name: "filled" } },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["nameInput", "submitBtn"],
+        },
+        nameInput: {
+          type: "Input",
+          props: {
+            label: "Name",
+            value: { $bindState: "/form/name" },
+            checks: [{ type: "required", message: "Required" }],
+          },
+          children: [],
+        },
+        submitBtn: {
+          type: "Button",
+          props: { label: "Submit" },
+          on: {
+            press: [{ action: "validateForm" }],
+          },
+          children: [],
+        },
+      },
+    };
+
+    const wrapper = mountWithProviders(spec);
+    await wrapper.find("[data-testid='btn']").trigger("click");
+    await nextTick();
+
+    const state = getState(wrapper);
+    expect(state.formValidation).toEqual({ valid: true, errors: {} });
+  });
+});

+ 2 - 6
packages/vue/src/hooks.ts

@@ -256,17 +256,16 @@ export function useUIStream({
   const usage = ref<TokenUsage | null>(null);
   const rawLines = ref<string[]>([]);
 
-  // Keep latest callbacks (Vue refs are always current — no stale closure issues)
   const onCompleteRef = ref(onComplete);
-  onCompleteRef.value = onComplete;
   const onErrorRef = ref(onError);
-  onErrorRef.value = onError;
 
   let abortController: AbortController | null = null;
 
   const clear = () => {
     spec.value = null;
     error.value = null;
+    usage.value = null;
+    rawLines.value = [];
   };
 
   const send = async (
@@ -670,11 +669,8 @@ export function useChatUI({
   const isStreaming = ref(false);
   const error = ref<Error | null>(null);
 
-  // Keep latest callbacks (Vue refs are always current — no stale closure issues)
   const onCompleteRef = ref(onComplete);
-  onCompleteRef.value = onComplete;
   const onErrorRef = ref(onError);
-  onErrorRef.value = onError;
 
   let abortController: AbortController | null = null;
 

+ 1 - 1
packages/vue/src/index.ts

@@ -46,7 +46,7 @@ export {
 export { schema, type VueSchema, type VueSpec } from "./schema";
 
 // Core types (re-exported for convenience)
-export type { Spec, StateStore } from "@json-render/core";
+export type { Spec, StateStore, ComputedFunction } from "@json-render/core";
 export { createStateStore } from "@json-render/core";
 
 // Catalog-aware types for Vue

+ 2 - 2
packages/vue/src/renderer.test.ts

@@ -56,9 +56,9 @@ function mountRenderer(
       default: () =>
         h(VisibilityProvider as Component, null, {
           default: () =>
-            h(ActionProvider as Component, { handlers } as any, {
+            h(ValidationProvider as Component, null, {
               default: () =>
-                h(ValidationProvider as Component, null, {
+                h(ActionProvider as Component, { handlers } as any, {
                   default: () =>
                     h(Renderer, { spec, registry: reg, ...extraProps }),
                 }),

+ 182 - 55
packages/vue/src/renderer.ts

@@ -2,9 +2,13 @@ import {
   computed,
   defineComponent,
   h,
+  inject,
   onErrorCaptured,
+  provide,
   ref,
+  watch,
   type Component,
+  type ComputedRef,
   type PropType,
   type VNode,
 } from "vue";
@@ -13,6 +17,7 @@ import type {
   Spec,
   ActionBinding,
   Catalog,
+  ComputedFunction,
   SchemaDefinition,
   StateStore,
 } from "@json-render/core";
@@ -80,6 +85,35 @@ export interface RendererProps {
   fallback?: Component;
 }
 
+// ---------------------------------------------------------------------------
+// FunctionsContext — provides $computed functions to the element tree
+// ---------------------------------------------------------------------------
+
+const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
+const FUNCTIONS_KEY = Symbol("json-render:functions");
+
+const FunctionsProvider = defineComponent({
+  name: "FunctionsProvider",
+  props: {
+    functions: {
+      type: Object as PropType<Record<string, ComputedFunction>>,
+      default: undefined,
+    },
+  },
+  setup(props, { slots }) {
+    const fns = computed(() => props.functions ?? EMPTY_FUNCTIONS);
+    provide(FUNCTIONS_KEY, fns);
+    return () => slots.default?.();
+  },
+});
+
+function useFunctions(): ComputedRef<Record<string, ComputedFunction>> {
+  return inject<ComputedRef<Record<string, ComputedFunction>>>(
+    FUNCTIONS_KEY,
+    computed(() => EMPTY_FUNCTIONS),
+  );
+}
+
 // ---------------------------------------------------------------------------
 // ElementErrorBoundary — catches rendering errors in individual elements
 // ---------------------------------------------------------------------------
@@ -111,6 +145,37 @@ const ElementErrorBoundary = defineComponent({
   },
 });
 
+// ---------------------------------------------------------------------------
+// resolveAndExecuteBindings — shared helper for emitEvent / watch handlers
+// ---------------------------------------------------------------------------
+
+async function resolveAndExecuteBindings(
+  actionBindings: ActionBinding[],
+  ctx: PropResolutionContext,
+  getSnapshot: () => Record<string, unknown>,
+  execute: (binding: ActionBinding) => Promise<void>,
+  cancelled?: () => boolean,
+): Promise<void> {
+  for (const b of actionBindings) {
+    if (cancelled?.()) break;
+    if (!b.params) {
+      await execute(b);
+      if (cancelled?.()) break;
+      continue;
+    }
+    const liveCtx: PropResolutionContext = {
+      ...ctx,
+      stateModel: getSnapshot(),
+    };
+    const resolved: Record<string, unknown> = {};
+    for (const [key, val] of Object.entries(b.params)) {
+      resolved[key] = resolveActionParam(val, liveCtx);
+    }
+    await execute({ ...b, params: resolved });
+    if (cancelled?.()) break;
+  }
+}
+
 // ---------------------------------------------------------------------------
 // ElementRenderer — renders a single element from the spec
 // ---------------------------------------------------------------------------
@@ -151,19 +216,20 @@ const ElementRenderer = defineComponent({
     const repeatScope = useRepeatScope();
     const { ctx: visibilityCtx } = useVisibility();
     const { execute } = useActions();
-    const { getSnapshot } = useStateStore();
+    const { getSnapshot, state: watchState } = useStateStore();
+    const functions = useFunctions();
 
-    // Build context with repeat scope
+    // Build context with repeat scope and $computed functions
     const fullCtx = computed<PropResolutionContext>(() => {
-      const base = visibilityCtx.value;
-      if (repeatScope) {
-        return {
-          ...base,
-          repeatItem: repeatScope.item,
-          repeatIndex: repeatScope.index,
-          repeatBasePath: repeatScope.basePath,
-        };
-      }
+      const base: PropResolutionContext = repeatScope
+        ? {
+            ...visibilityCtx.value,
+            repeatItem: repeatScope.item,
+            repeatIndex: repeatScope.index,
+            repeatBasePath: repeatScope.basePath,
+          }
+        : { ...visibilityCtx.value };
+      base.functions = functions.value;
       return base;
     });
 
@@ -172,21 +238,12 @@ const ElementRenderer = defineComponent({
       const binding = props.element.on?.[eventName];
       if (!binding) return;
       const actionBindings = Array.isArray(binding) ? binding : [binding];
-      for (const b of actionBindings) {
-        if (!b.params) {
-          await execute(b);
-          continue;
-        }
-        const liveCtx: PropResolutionContext = {
-          ...fullCtx.value,
-          stateModel: getSnapshot(),
-        };
-        const resolved: Record<string, unknown> = {};
-        for (const [key, val] of Object.entries(b.params)) {
-          resolved[key] = resolveActionParam(val, liveCtx);
-        }
-        await execute({ ...b, params: resolved });
-      }
+      await resolveAndExecuteBindings(
+        actionBindings,
+        fullCtx.value,
+        getSnapshot,
+        execute,
+      );
     };
 
     // Create on() function
@@ -206,6 +263,49 @@ const ElementRenderer = defineComponent({
       };
     };
 
+    // Watch effect: fire actions when watched state paths change.
+    const watchedValues = computed(() => {
+      const cfg = props.element.watch;
+      if (!cfg) return undefined;
+      const values: Record<string, unknown> = {};
+      for (const path of Object.keys(cfg)) {
+        values[path] = getByPath(watchState.value, path);
+      }
+      return values;
+    });
+
+    watch(
+      watchedValues,
+      (current, prev, onCleanup) => {
+        const cfg = props.element.watch;
+        if (!cfg || !current) return;
+
+        let cancelled = false;
+        onCleanup(() => {
+          cancelled = true;
+        });
+
+        const paths = Object.keys(cfg);
+        void (async () => {
+          for (const path of paths) {
+            if (cancelled) break;
+            if (prev && current[path] === prev[path]) continue;
+            const binding = cfg[path];
+            if (!binding) continue;
+            const bindings = Array.isArray(binding) ? binding : [binding];
+            await resolveAndExecuteBindings(
+              bindings,
+              fullCtx.value,
+              getSnapshot,
+              execute,
+              () => cancelled,
+            );
+          }
+        })().catch(console.error);
+      },
+      { deep: true },
+    );
+
     return () => {
       const ctx = fullCtx.value;
 
@@ -322,10 +422,11 @@ const RepeatChildren = defineComponent({
     const { state } = useStateStore();
 
     return () => {
-      const repeat = props.element.repeat!;
+      const repeat = props.element.repeat;
+      if (!repeat?.statePath) return null;
       const statePath = repeat.statePath;
-      const items =
-        (getByPath(state.value, statePath) as unknown[] | undefined) ?? [];
+      const raw = getByPath(state.value, statePath);
+      const items = Array.isArray(raw) ? (raw as unknown[]) : [];
 
       return items.map((itemValue, index) => {
         const key =
@@ -454,6 +555,8 @@ export interface JSONUIProviderProps {
     string,
     (value: unknown, args?: Record<string, unknown>) => boolean
   >;
+  /** Named functions for `$computed` expressions in props */
+  functions?: Record<string, ComputedFunction>;
   onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
 }
 
@@ -497,6 +600,10 @@ export const JSONUIProvider = defineComponent({
       >,
       default: undefined,
     },
+    functions: {
+      type: Object as PropType<Record<string, ComputedFunction>>,
+      default: undefined,
+    },
     onStateChange: {
       type: Function as PropType<
         (changes: Array<{ path: string; value: unknown }>) => void
@@ -518,18 +625,25 @@ export const JSONUIProvider = defineComponent({
             h(VisibilityProvider, null, {
               default: () =>
                 h(
-                  ActionProvider,
-                  { handlers: props.handlers, navigate: props.navigate },
+                  ValidationProvider,
+                  { customFunctions: props.validationFunctions },
                   {
                     default: () =>
                       h(
-                        ValidationProvider,
-                        { customFunctions: props.validationFunctions },
+                        ActionProvider,
+                        { handlers: props.handlers, navigate: props.navigate },
                         {
-                          default: () => [
-                            slots.default?.(),
-                            h(ConfirmationDialogManager),
-                          ],
+                          default: () =>
+                            h(
+                              FunctionsProvider,
+                              { functions: props.functions },
+                              {
+                                default: () => [
+                                  slots.default?.(),
+                                  h(ConfirmationDialogManager),
+                                ],
+                              },
+                            ),
                         },
                       ),
                   },
@@ -704,6 +818,8 @@ export interface CreateRendererProps {
   state?: Record<string, unknown>;
   onAction?: (actionName: string, params?: Record<string, unknown>) => void;
   onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
+  /** Named functions for `$computed` expressions in props */
+  functions?: Record<string, ComputedFunction>;
   loading?: boolean;
   fallback?: Component;
 }
@@ -768,6 +884,10 @@ export function createRenderer<
         >,
         default: undefined,
       },
+      functions: {
+        type: Object as PropType<Record<string, ComputedFunction>>,
+        default: undefined,
+      },
       loading: {
         type: Boolean,
         default: undefined,
@@ -807,24 +927,31 @@ export function createRenderer<
             default: () =>
               h(VisibilityProvider, null, {
                 default: () =>
-                  h(
-                    ActionProvider,
-                    { handlers: actionHandlers },
-                    {
-                      default: () =>
-                        h(ValidationProvider, null, {
-                          default: () => [
-                            h(Renderer, {
-                              spec: rendererProps.spec,
-                              registry,
-                              loading: rendererProps.loading,
-                              fallback: rendererProps.fallback,
-                            }),
-                            h(ConfirmationDialogManager),
-                          ],
-                        }),
-                    },
-                  ),
+                  h(ValidationProvider, null, {
+                    default: () =>
+                      h(
+                        ActionProvider,
+                        { handlers: actionHandlers },
+                        {
+                          default: () =>
+                            h(
+                              FunctionsProvider,
+                              { functions: rendererProps.functions },
+                              {
+                                default: () => [
+                                  h(Renderer, {
+                                    spec: rendererProps.spec,
+                                    registry,
+                                    loading: rendererProps.loading,
+                                    fallback: rendererProps.fallback,
+                                  }),
+                                  h(ConfirmationDialogManager),
+                                ],
+                              },
+                            ),
+                        },
+                      ),
+                  }),
               }),
           },
         );

+ 5 - 0
packages/vue/src/schema.ts

@@ -67,6 +67,11 @@ export const schema = defineSchema(
         description:
           "Remove an item from an array in state by index. Params: { statePath: string, index: number }",
       },
+      {
+        name: "validateForm",
+        description:
+          "Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
+      },
     ],
     defaultRules: [
       // Element integrity

+ 3 - 0
pnpm-lock.yaml

@@ -1212,6 +1212,9 @@ importers:
       '@json-render/core':
         specifier: workspace:*
         version: link:../core
+      zod:
+        specifier: ^4.0.0
+        version: 4.3.6
     devDependencies:
       '@internal/typescript-config':
         specifier: workspace:*