From 9c837cf2854162429a6aca8e574073a39fbdbb2f Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Tue, 7 Apr 2026 12:30:10 -0400 Subject: [PATCH] =?UTF-8?q?Fix=20H3,=20H6,=20H11,=20H13,=20M11,=20M18=20?= =?UTF-8?q?=E2=80=94=20quick=20wins=20from=20expert=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H3: mizanFetch retries 2x on server errors (5xx) and network failures. 200ms/400ms backoff. Mutations NOT retried (not idempotent). H6: refreshContext now uses GET /ctx// instead of POST /call/. Context reads go to the context endpoint, not the mutation endpoint. H11: Python cache key derivation normalizes True→"true", False→"false", None→"null" for cross-language HMAC consistency with JavaScript's String() behavior. H13: Forms isValid now checks that all required fields have been touched, not just that touched fields have no errors. M11: execute_function return type updated to include HttpResponseBase for view-path functions. M18: registerContext cleanup uses ?. instead of ! to prevent crash if Map was cleared (already fixed in H2 commit but documenting). 373 Django + 33 React tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/mizan-django/src/mizan/cache/keys.py | 13 +++++++++++- .../mizan-django/src/mizan/client/executor.py | 4 ++-- packages/mizan-react/src/context.tsx | 5 +++-- packages/mizan-react/src/forms.ts | 12 +++++++++-- packages/mizan-runtime/src/index.ts | 21 ++++++++++++++++++- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/mizan-django/src/mizan/cache/keys.py b/packages/mizan-django/src/mizan/cache/keys.py index ce7632c..c163556 100644 --- a/packages/mizan-django/src/mizan/cache/keys.py +++ b/packages/mizan-django/src/mizan/cache/keys.py @@ -32,7 +32,18 @@ def derive_cache_key( Returns a prefixed key: "ctx:{context}:{hmac_hex}" so that broad purge can SCAN by prefix "ctx:{context}:*". """ - sorted_params = {k: str(v) for k, v in sorted(params.items())} + def _normalize(v: Any) -> str: + """Normalize values for cross-language HMAC consistency. + Python str(True)="True" but JS String(true)="true". Use JSON-native forms.""" + if v is True: + return "true" + if v is False: + return "false" + if v is None: + return "null" + return str(v) + + sorted_params = {k: _normalize(v) for k, v in sorted(params.items())} key_data: dict[str, Any] = {"c": context, "p": sorted_params, "r": rev} if user_id is not None: diff --git a/packages/mizan-django/src/mizan/client/executor.py b/packages/mizan-django/src/mizan/client/executor.py index b4a30ea..f5bd72f 100644 --- a/packages/mizan-django/src/mizan/client/executor.py +++ b/packages/mizan-django/src/mizan/client/executor.py @@ -23,7 +23,7 @@ from enum import Enum from functools import wraps from typing import TYPE_CHECKING, Any, Callable -from django.http import HttpRequest, HttpResponse, JsonResponse +from django.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse from django.views.decorators.csrf import csrf_protect from pydantic import BaseModel, ValidationError @@ -346,7 +346,7 @@ def execute_function( request: HttpRequest, fn_name: str, input_data: dict[str, Any] | None = None, -) -> FunctionResult | FunctionError: +) -> "FunctionResult | FunctionError | HttpResponseBase": """ Execute a registered server function. diff --git a/packages/mizan-react/src/context.tsx b/packages/mizan-react/src/context.tsx index af19cb6..769358a 100644 --- a/packages/mizan-react/src/context.tsx +++ b/packages/mizan-react/src/context.tsx @@ -367,11 +367,12 @@ export function MizanProvider({ [contextStore] ) - // Refresh a specific context + // Refresh a specific context via GET /ctx// const refreshContext = useCallback( async (name: string): Promise => { try { - const data = await call(name, {}) + const response = await httpClient.request('GET', `${baseUrl}/ctx/${name}/`) + const data = await response.json() setContextStore(prev => { const next = { ...prev, [name]: data } // Notify listeners diff --git a/packages/mizan-react/src/forms.ts b/packages/mizan-react/src/forms.ts index 6af940a..ba22a17 100644 --- a/packages/mizan-react/src/forms.ts +++ b/packages/mizan-react/src/forms.ts @@ -811,8 +811,16 @@ export function useMizanFormCore>( }, [errors, touchedFields]) const isValid = useMemo(() => { - return errors !== null && !hasErrors - }, [errors, hasErrors]) + if (errors === null || hasErrors) return false + // Also check that all required fields have been touched + if (schema) { + for (const fieldName of schema.fieldOrder) { + const field = schema.fields[fieldName] + if (field.required && !touchedFields.has(fieldName)) return false + } + } + return true + }, [errors, hasErrors, schema, touchedFields]) return { data, diff --git a/packages/mizan-runtime/src/index.ts b/packages/mizan-runtime/src/index.ts index 24e877e..8d27eb4 100644 --- a/packages/mizan-runtime/src/index.ts +++ b/packages/mizan-runtime/src/index.ts @@ -151,6 +151,24 @@ function flush(): void { // === Fetch === +async function fetchWithRetry( + input: RequestInfo | URL, + init?: RequestInit, + retries = 2, +): Promise { + for (let attempt = 0; ; attempt++) { + try { + const res = await fetch(input, init) + // Don't retry client errors (4xx) — only server/network errors + if (res.ok || (res.status >= 400 && res.status < 500)) return res + if (attempt >= retries) return res + } catch (e) { + if (attempt >= retries) throw e + } + await new Promise(r => setTimeout(r, (attempt + 1) * 200)) + } +} + async function resolveHeaders(): Promise> { await initSession() @@ -179,7 +197,7 @@ export async function mizanFetch( } const headers = await resolveHeaders() - const res = await fetch(url.toString(), { headers, credentials: 'same-origin' }) + const res = await fetchWithRetry(url.toString(), { headers, credentials: 'same-origin' }) if (!res.ok) throw new MizanError(res.status, await res.text()) return res.json() } @@ -191,6 +209,7 @@ export async function mizanCall( const headers = await resolveHeaders() headers['Content-Type'] = 'application/json' + // Mutations are not retried — they are not idempotent const res = await fetch(`${config.baseUrl}/call/`, { method: 'POST', headers,