Fix protocol mismatch + add SSR hydration to codegen

Three bugs fixed:

1. MizanProvider.call() read data.data but server returns data.result.
   Now reads data.result and processes data.invalidate for server-driven
   invalidation (triggering refetch on mounted context providers).

2. GlobalContextLoader expected {error, data} wrapper but context GET
   returns raw bundled data. Fixed to iterate response directly.

3. Named context providers had same wrapper assumption. Fixed to
   setData(result) directly.

Two features added:

1. SSR hydration: GlobalContextLoader checks window.__MIZAN_SSR_DATA__
   on mount. If present, populates contexts from it and skips fetch.

2. SSR hydration: Named context providers check __MIZAN_SSR_DATA__ in
   useState initializer. If SSR data exists for their functions, they
   render immediately without fetching.

3. Server-driven invalidation in MizanProvider.call(): reads the
   invalidate array from mutation responses and triggers refetch on
   mounted providers. Generated mutation hooks' hardcoded invalidation
   is now redundant but idempotent — both paths coexist safely.

Also fixed FunctionSuccessResponse type to match new protocol:
  { result: T, invalidate?: [...] }

373 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 03:08:32 -04:00
parent c237a6379b
commit 711e92ac4d
3 changed files with 49 additions and 15 deletions

View File

@@ -268,16 +268,23 @@ export function generateMizanProvider(schema, options = {}) {
lines.push(' if (loaded.current) return')
lines.push(' loaded.current = true')
lines.push('')
lines.push(' // Check for SSR hydration data first')
lines.push(" const ssr = typeof window !== 'undefined' && (window as any).__MIZAN_SSR_DATA__")
lines.push(' if (ssr) {')
lines.push(' for (const [name, data] of Object.entries(ssr)) {')
lines.push(' mizan.setContextData(name, data)')
lines.push(' }')
lines.push(' return')
lines.push(' }')
lines.push('')
lines.push(' ;(async () => {')
lines.push(' await mizan.whenReady')
lines.push(' try {')
lines.push(" const response = await mizan.request('GET', `${mizan.baseUrl}/ctx/global/`)")
lines.push(' const result = await response.json()')
lines.push(' if (!result.error) {')
lines.push(' for (const [name, data] of Object.entries(result.data)) {')
lines.push(' for (const [name, data] of Object.entries(result)) {')
lines.push(' mizan.setContextData(name, data)')
lines.push(' }')
lines.push(' }')
lines.push(' } catch (e) {')
lines.push(" console.error('[MizanContext] Global context fetch failed:', e)")
lines.push(' }')
@@ -456,11 +463,25 @@ export function generateMizanProvider(schema, options = {}) {
// Provider component
lines.push(`export function ${ctxPascal}Context({ children, ...params }: ${ctxPascal}ContextProps) {`)
lines.push(` const mizan = useMizan()`)
// SSR hydration check — initialize from __MIZAN_SSR_DATA__ if available
lines.push(` const [data, setData] = useState<{`)
for (const fn of ctxFunctions) {
lines.push(` ${fn.name}: ${fn.outputType}`)
}
lines.push(` } | null>(null)`)
lines.push(` } | null>(() => {`)
lines.push(` if (typeof window === 'undefined') return null`)
lines.push(` const ssr = (window as any).__MIZAN_SSR_DATA__`)
lines.push(` if (!ssr) return null`)
// Check if all functions for this context have SSR data
const firstFn = ctxFunctions[0]
lines.push(` if (ssr.${firstFn.name} === undefined) return null`)
lines.push(` return {`)
for (const fn of ctxFunctions) {
lines.push(` ${fn.name}: ssr.${fn.name},`)
}
lines.push(` }`)
lines.push(` })`)
lines.push('')
lines.push(` const refetch = useCallback(async () => {`)
lines.push(` await mizan.whenReady`)
@@ -470,7 +491,7 @@ export function generateMizanProvider(schema, options = {}) {
}
lines.push(` const resp = await mizan.request('GET', \`\${mizan.baseUrl}/ctx/${ctxName}/?\${qs}\`)`)
lines.push(` const result = await resp.json()`)
lines.push(` if (!result.error) setData(result.data)`)
lines.push(` setData(result)`)
// Dependency array: mizan + each param
const deps = ['mizan', ...paramEntries.map(([pName]) => `params.${pName}`)]
@@ -650,13 +671,13 @@ export function generateMizanServer(schema) {
lines.push('')
lines.push(' try {')
lines.push(" const response = await client.request('GET', '/api/mizan/ctx/global/')")
lines.push(' if (response.ok) {')
lines.push(' const result = await response.json()')
lines.push(' if (!result.error) {')
for (const ctx of globalContexts) {
lines.push(` if (result.data?.${ctx.name} !== undefined) hydration.${ctx.camelName} = result.data.${ctx.name}`)
lines.push(` if (result?.${ctx.name} !== undefined) hydration.${ctx.camelName} = result.${ctx.name}`)
}
lines.push(' } else {')
lines.push(" console.error('[getMizanHydration] Global context fetch failed:', result.code, result.message)")
lines.push(" console.error('[getMizanHydration] Global context fetch failed:', response.status)")
lines.push(' }')
lines.push(' } catch (e) {')
lines.push(" console.error('[getMizanHydration] Request failed:', e)")

View File

@@ -528,8 +528,8 @@ import { DjangoError, type FunctionErrorResponse } from '../errors'
* Success response from a server function
*/
export interface FunctionSuccessResponse<T> {
error: false
data: T
result: T
invalidate?: Array<string | { context: string; params: Record<string, any> }>
}
/**
@@ -580,6 +580,6 @@ export async function httpFunctionCall<TInput = unknown, TOutput = unknown>(
throw new DjangoError(data)
}
return data.data
return (data as FunctionSuccessResponse<TOutput>).result
}

View File

@@ -343,13 +343,26 @@ export function MizanProvider({
{ fn: functionName, args: input }
)
const data: FunctionResponse<TOutput> = await response.json()
const data = await response.json()
if (data.error) {
throw new DjangoError(data as FunctionErrorResponse)
}
return data.data
// Server-driven invalidation: process the invalidate array
if (data.invalidate && Array.isArray(data.invalidate)) {
for (const entry of data.invalidate) {
if (typeof entry === 'string') {
const provider = contextProvidersRef.current.get(entry)
if (provider) provider.refetch()
} else if (entry.context) {
const provider = contextProvidersRef.current.get(entry.context)
if (provider) provider.refetch()
}
}
}
return data.result as TOutput
},
[connection, baseUrl, httpClient]
)