Ver Fonte

fix max update depth on homepage demo (#111)

When form inputs lack `$bindState` bindings (like in the homepage contact form simulation), `useFieldValidation` was called with `bindings?.value ?? ""` as the path. All three inputs registered at the same `""` path but with different validation configs. Each `registerField` call overwrote the previous one, triggering a re-render where the other inputs would see a mismatched config and re-register -- creating an infinite loop.

- Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at path `""` with different `checks` configs, causing them to overwrite each other endlessly
- Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update
Chris Tate há 4 meses atrás
pai
commit
ea97aff

+ 12 - 9
apps/web/lib/render/registry.tsx

@@ -781,9 +781,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       const setValue = isBound ? setBoundValue : setLocalValue;
 
       const hasValidation = !!(bindings?.value && props.checks?.length);
-      const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
-        checks: props.checks ?? [],
-      });
+      const { errors, validate } = useFieldValidation(
+        bindings?.value ?? "",
+        hasValidation ? { checks: props.checks ?? [] } : undefined,
+      );
 
       return (
         <div className="space-y-2">
@@ -822,9 +823,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       const setValue = isBound ? setBoundValue : setLocalValue;
 
       const hasValidation = !!(bindings?.value && props.checks?.length);
-      const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
-        checks: props.checks ?? [],
-      });
+      const { errors, validate } = useFieldValidation(
+        bindings?.value ?? "",
+        hasValidation ? { checks: props.checks ?? [] } : undefined,
+      );
 
       return (
         <div className="space-y-2">
@@ -864,9 +866,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       );
 
       const hasValidation = !!(bindings?.value && props.checks?.length);
-      const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
-        checks: props.checks ?? [],
-      });
+      const { errors, validate } = useFieldValidation(
+        bindings?.value ?? "",
+        hasValidation ? { checks: props.checks ?? [] } : undefined,
+      );
 
       return (
         <div className="space-y-2">

+ 8 - 2
packages/react/src/contexts/actions.tsx

@@ -6,6 +6,7 @@ import React, {
   useState,
   useCallback,
   useMemo,
+  useRef,
   type ReactNode,
 } from "react";
 import {
@@ -137,6 +138,11 @@ export function ActionProvider({
   children,
 }: ActionProviderProps) {
   const { state, get, set } = useStateStore();
+  // Keep a ref to the latest state so `execute` doesn't change on every
+  // state update — preventing the entire action context from churning.
+  const stateRef = useRef(state);
+  stateRef.current = state;
+
   const [handlers, setHandlers] =
     useState<Record<string, ActionHandler>>(initialHandlers);
   const [loadingActions, setLoadingActions] = useState<Set<string>>(new Set());
@@ -152,7 +158,7 @@ export function ActionProvider({
 
   const execute = useCallback(
     async (binding: ActionBinding) => {
-      const resolved = resolveAction(binding, state);
+      const resolved = resolveAction(binding, stateRef.current);
 
       // Built-in: setState updates the StateProvider state directly
       if (resolved.action === "setState" && resolved.params) {
@@ -299,7 +305,7 @@ export function ActionProvider({
         });
       }
     },
-    [state, handlers, get, set, navigate],
+    [handlers, get, set, navigate],
   );
 
   const confirm = useCallback(() => {

+ 11 - 4
packages/react/src/contexts/validation.tsx

@@ -6,6 +6,7 @@ import React, {
   useState,
   useCallback,
   useMemo,
+  useRef,
   type ReactNode,
 } from "react";
 import {
@@ -130,6 +131,11 @@ export function ValidationProvider({
   children,
 }: ValidationProviderProps) {
   const { state } = useStateStore();
+  // Keep a ref to the latest state so `validate` doesn't change on every
+  // state update — preventing the entire validation context from churning.
+  const stateRef = useRef(state);
+  stateRef.current = state;
+
   const [fieldStates, setFieldStates] = useState<
     Record<string, FieldValidationState>
   >({});
@@ -155,8 +161,9 @@ export function ValidationProvider({
   const validate = useCallback(
     (path: string, config: ValidationConfig): ValidationResult => {
       // Walk the nested state object using JSON Pointer segments
+      const currentState = stateRef.current;
       const segments = path.split("/").filter(Boolean);
-      let value: unknown = state;
+      let value: unknown = currentState;
       for (const seg of segments) {
         if (value != null && typeof value === "object") {
           value = (value as Record<string, unknown>)[seg];
@@ -167,7 +174,7 @@ export function ValidationProvider({
       }
       const result = runValidation(config, {
         value,
-        stateModel: state,
+        stateModel: currentState,
         customFunctions,
       });
 
@@ -182,7 +189,7 @@ export function ValidationProvider({
 
       return result;
     },
-    [state, customFunctions],
+    [customFunctions],
   );
 
   const touch = useCallback((path: string) => {
@@ -280,7 +287,7 @@ export function useFieldValidation(
 
   // Register field on mount
   React.useEffect(() => {
-    if (config) {
+    if (path && config) {
       registerField(path, config);
     }
   }, [path, config, registerField]);

+ 10 - 3
packages/react/src/hooks.ts

@@ -250,6 +250,13 @@ export function useUIStream({
   const [rawLines, setRawLines] = useState<string[]>([]);
   const abortControllerRef = useRef<AbortController | null>(null);
 
+  // Keep refs to callbacks so `send` doesn't recreate when consumers
+  // pass inline arrow functions.
+  const onCompleteRef = useRef(onComplete);
+  onCompleteRef.current = onComplete;
+  const onErrorRef = useRef(onError);
+  onErrorRef.current = onError;
+
   const clear = useCallback(() => {
     setSpec(null);
     setError(null);
@@ -350,19 +357,19 @@ export function useUIStream({
           }
         }
 
-        onComplete?.(currentSpec);
+        onCompleteRef.current?.(currentSpec);
       } catch (err) {
         if ((err as Error).name === "AbortError") {
           return;
         }
         const error = err instanceof Error ? err : new Error(String(err));
         setError(error);
-        onError?.(error);
+        onErrorRef.current?.(error);
       } finally {
         setIsStreaming(false);
       }
     },
-    [api, onComplete, onError],
+    [api],
   );
 
   // Cleanup on unmount