diff --git a/packages/mizan-django/generate/generator/lib/adapters/react.mjs b/packages/mizan-django/generate/generator/lib/adapters/react.mjs
index ca7bda1..f58739b 100644
--- a/packages/mizan-django/generate/generator/lib/adapters/react.mjs
+++ b/packages/mizan-django/generate/generator/lib/adapters/react.mjs
@@ -106,7 +106,7 @@ export function generateReactAdapter(schema) {
const deps = paramEntries.map(([pName]) => `params.${pName}`)
lines.push(` }, [${deps.join(', ')}])`)
lines.push('')
- lines.push(` useEffect(() => { refetch() }, [refetch])`)
+ lines.push(` useEffect(() => { if (!data) refetch() }, [data, refetch])`)
lines.push(` useEffect(() => registerContext('${ctxName}', params, refetch), [${deps.join(', ')}, refetch])`)
lines.push('')
lines.push(` return <${p}Ctx.Provider value={data}>{children}${p}Ctx.Provider>`)
diff --git a/packages/mizan-django/src/mizan/client/executor.py b/packages/mizan-django/src/mizan/client/executor.py
index 6f31185..b4a30ea 100644
--- a/packages/mizan-django/src/mizan/client/executor.py
+++ b/packages/mizan-django/src/mizan/client/executor.py
@@ -161,6 +161,42 @@ def _check_auth_requirement(
return None
+_cache_log = logging.getLogger("mizan.cache")
+
+
+def _purge_cache_for_invalidation(
+ invalidate: list,
+ request: HttpRequest | None = None,
+) -> None:
+ """Purge origin-side cache for invalidation targets. Includes user_id if available."""
+ cache = get_cache()
+ if cache is None:
+ return
+
+ settings = get_settings()
+ if not settings.cache_secret:
+ return
+
+ user_id = None
+ if request and hasattr(request, 'user') and hasattr(request.user, 'pk'):
+ uid = getattr(request.user, 'pk', None)
+ if uid is not None:
+ user_id = str(uid)
+
+ try:
+ for entry in invalidate:
+ if isinstance(entry, str):
+ cache_purge(cache, entry)
+ elif isinstance(entry, dict):
+ cache_purge(
+ cache, entry["context"], entry.get("params"),
+ secret=settings.cache_secret,
+ user_id=user_id,
+ )
+ except Exception:
+ _cache_log.warning("Cache purge failed", exc_info=True)
+
+
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
"""
Determine whether an affects target is a context name or function name.
@@ -444,10 +480,11 @@ def execute_function(
from django.http import HttpResponseBase
if isinstance(output, HttpResponseBase):
- # View path — add invalidation header, pass through the response
+ # View path — add invalidation header + purge origin cache
invalidate = _resolve_invalidation(view_class, input_data)
if invalidate:
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
+ _purge_cache_for_invalidation(invalidate, request)
output["Cache-Control"] = "no-store"
return output
@@ -701,28 +738,9 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
response = JsonResponse(response_data)
response["Cache-Control"] = "no-store"
- # Always set the header transport too (Edge reads this)
if invalidate_contexts:
response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
-
- # Purge origin-side cache for invalidated contexts
- _cache_log = logging.getLogger("mizan.cache")
- cache = get_cache()
- cache_settings = get_settings()
- if cache is not None:
- try:
- for entry in invalidate_contexts:
- if isinstance(entry, str):
- # Broad purge (no params) — prefix scan
- cache_purge(cache, entry)
- elif isinstance(entry, dict):
- # Scoped purge — recompute key and delete directly
- cache_purge(
- cache, entry["context"], entry.get("params"),
- secret=cache_settings.cache_secret,
- )
- except Exception:
- _cache_log.warning("Cache purge failed", exc_info=True)
+ _purge_cache_for_invalidation(invalidate_contexts, request)
return response
diff --git a/packages/mizan-django/src/mizan/client/function.py b/packages/mizan-django/src/mizan/client/function.py
index a0bf691..1d049f9 100644
--- a/packages/mizan-django/src/mizan/client/function.py
+++ b/packages/mizan-django/src/mizan/client/function.py
@@ -536,8 +536,8 @@ def _create_server_function(
if cache is not True:
meta["cache"] = cache
- if meta:
- FunctionWrapper._meta = {**FunctionWrapper._meta, **meta}
+ # Always assign a fresh dict to prevent shared-dict mutation across classes
+ FunctionWrapper._meta = {**meta}
# Note: Registration happens via discovery (mizan_clients), not here.
# This allows the decorator to be used without import-time side effects.
diff --git a/packages/mizan-django/src/mizan/ssr/backend.py b/packages/mizan-django/src/mizan/ssr/backend.py
index 7f87384..f9f3ac3 100644
--- a/packages/mizan-django/src/mizan/ssr/backend.py
+++ b/packages/mizan-django/src/mizan/ssr/backend.py
@@ -35,13 +35,21 @@ class MizanTemplate:
self._bridge = bridge
def render(self, context: dict[str, Any] | None = None, request: Any = None) -> str:
+ import json as _json
+
props = dict(context) if context else {}
props.pop("request", None)
props.pop("csrf_token", None)
result = self._bridge.render(self.file_path, props)
- return mark_safe(f'
{result.html}
')
+ # Serialize props as hydration data for client-side React
+ hydration_json = _json.dumps(props, sort_keys=True, default=str)
+
+ return mark_safe(
+ f'{result.html}
'
+ f''
+ )
class MizanTemplates(BaseEngine):
diff --git a/packages/mizan-django/src/mizan/ssr/bridge.py b/packages/mizan-django/src/mizan/ssr/bridge.py
index 0fb28e1..78d1084 100644
--- a/packages/mizan-django/src/mizan/ssr/bridge.py
+++ b/packages/mizan-django/src/mizan/ssr/bridge.py
@@ -3,7 +3,7 @@ SSR Bridge — Manages a persistent Bun subprocess for React rendering.
Protocol: newline-delimited JSON-RPC over stdin/stdout.
-Request: {"id": 1, "method": "render", "params": {"component": "ProfilePage", "props": {...}}}
+Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
Response: {"id": 1, "html": "...
"}
The subprocess stays alive across requests. It is started on first use
@@ -12,6 +12,7 @@ and restarted automatically if it crashes.
from __future__ import annotations
+import atexit
import json
import logging
import subprocess
@@ -41,12 +42,16 @@ class SSRBridge:
self._timeout = timeout
self._proc: subprocess.Popen | None = None
self._lock = threading.Lock()
+ self._write_lock = threading.Lock() # Serializes stdin writes
self._counter = 0
self._pending: dict[int, threading.Event] = {}
self._results: dict[int, dict] = {}
self._reader_thread: threading.Thread | None = None
self._ready = threading.Event()
+ # Ensure cleanup on process exit
+ atexit.register(self.shutdown)
+
def _ensure_running(self) -> None:
"""Start the Bun subprocess if it's not running."""
if self._proc is not None and self._proc.poll() is None:
@@ -134,12 +139,14 @@ class SSRBridge:
"params": {"file": file, "props": props or {}},
}) + "\n"
- try:
- self._proc.stdin.write(request.encode("utf-8"))
- self._proc.stdin.flush()
- except (BrokenPipeError, OSError) as e:
- del self._pending[msg_id]
- raise RuntimeError(f"SSR worker pipe broken: {e}")
+ # Serialize stdin writes to prevent interleaving from concurrent threads
+ with self._write_lock:
+ try:
+ self._proc.stdin.write(request.encode("utf-8"))
+ self._proc.stdin.flush()
+ except (BrokenPipeError, OSError) as e:
+ self._pending.pop(msg_id, None)
+ raise RuntimeError(f"SSR worker pipe broken: {e}")
if not event.wait(self._timeout):
self._pending.pop(msg_id, None)
@@ -155,33 +162,6 @@ class SSRBridge:
return RenderResult(html=result["html"])
- def ping(self) -> bool:
- """Health check. Returns True if the worker responds."""
- with self._lock:
- self._ensure_running()
- self._counter += 1
- msg_id = self._counter
-
- event = threading.Event()
- self._pending[msg_id] = event
-
- request = json.dumps({"id": msg_id, "method": "ping"}) + "\n"
-
- try:
- self._proc.stdin.write(request.encode("utf-8"))
- self._proc.stdin.flush()
- except (BrokenPipeError, OSError):
- del self._pending[msg_id]
- return False
-
- if not event.wait(self._timeout):
- self._pending.pop(msg_id, None)
- return False
-
- self._pending.pop(msg_id, None)
- result = self._results.pop(msg_id)
- return result.get("pong", False)
-
def shutdown(self) -> None:
"""Stop the Bun subprocess."""
if self._proc is not None:
diff --git a/packages/mizan-runtime/src/index.ts b/packages/mizan-runtime/src/index.ts
index 7fdf99d..24e877e 100644
--- a/packages/mizan-runtime/src/index.ts
+++ b/packages/mizan-runtime/src/index.ts
@@ -58,19 +58,26 @@ let _sessionReady: Promise | null = null
* Initialize a session (fetches CSRF cookie from GET /session/).
* Called automatically on first fetch if not called explicitly.
* No-op if a CSRF cookie already exists.
+ * Retries on failure — resets so next call tries again.
*/
export function initSession(): Promise {
if (_sessionReady) return _sessionReady
_sessionReady = (async () => {
- // If we already have a CSRF token, skip
if (getCSRFToken()) return
- try {
- await fetch(`${config.baseUrl}/session/`, { credentials: 'include' })
- } catch (e) {
- console.error('[mizan] Session init failed:', e)
+ for (let attempt = 0; attempt < 3; attempt++) {
+ try {
+ await fetch(`${config.baseUrl}/session/`, { credentials: 'include' })
+ if (getCSRFToken()) return
+ } catch (e) {
+ console.warn(`[mizan] Session init attempt ${attempt + 1} failed:`, e)
+ }
+ if (attempt < 2) await new Promise(r => setTimeout(r, (attempt + 1) * 100))
}
+
+ // All retries failed — reset so next call tries again
+ _sessionReady = null
})()
return _sessionReady
@@ -88,26 +95,31 @@ interface ContextEntry {
const contexts: Map> = new Map()
+/** Deterministic JSON key for params — sorted to avoid order-dependency */
+function stableKey(params: Record): string {
+ return JSON.stringify(params, Object.keys(params).sort())
+}
+
export function registerContext(
name: string,
params: Record,
refetch: RefetchFn,
): () => void {
if (!contexts.has(name)) contexts.set(name, new Map())
- const key = JSON.stringify(params)
+ const key = stableKey(params)
contexts.get(name)!.set(key, { params, refetch })
- return () => contexts.get(name)!.delete(key)
+ return () => contexts.get(name)?.delete(key)
}
// === Invalidation ===
const pending: Set = new Set()
-const pendingScoped: Map> = new Map()
+const pendingScoped: Array<{ context: string; params: Record }> = []
let scheduled = false
export function invalidate(context: string, params?: Record): void {
if (params) {
- pendingScoped.set(context, params)
+ pendingScoped.push({ context, params })
} else {
pending.add(context)
}
@@ -123,17 +135,17 @@ function flush(): void {
if (entries) entries.forEach(entry => entry.refetch())
}
- for (const [name, params] of pendingScoped) {
+ for (const { context: name, params } of pendingScoped) {
if (pending.has(name)) continue
const entries = contexts.get(name)
if (!entries) continue
- const key = JSON.stringify(params)
+ const key = stableKey(params)
const entry = entries.get(key)
if (entry) entry.refetch()
}
pending.clear()
- pendingScoped.clear()
+ pendingScoped.length = 0
scheduled = false
}