/** * useOraclePage — Page hydration, branch state, component projection * Owns the canonical page state including revision tracking and optimistic updates. */ import { useState, useEffect, useCallback, useRef } from 'react'; import type { CanvasPage, CanvasComponent, OracleWSMessage } from '../types/canvas'; import { fetchCanvasPage, connectPageSocket } from '../lib/oracleApiClient'; export interface OraclePageState { page: CanvasPage | null; isLoading: boolean; error: string | null; isConnected: boolean; // Actions refresh: () => Promise; optimisticallyAppendComponent: (comp: CanvasComponent) => void; applyRevision: (headRevision: number, components: CanvasComponent[]) => void; } export function useOraclePage(pageId: string | null): OraclePageState { const [page, setPage] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isConnected, setIsConnected] = useState(false); const disconnectRef = useRef<(() => void) | null>(null); const load = useCallback(async () => { if (!pageId) { setPage(null); setIsLoading(false); return; } setIsLoading(true); setError(null); try { const data = await fetchCanvasPage(pageId); setPage(data); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load page'); } finally { setIsLoading(false); } }, [pageId]); // Connect WebSocket useEffect(() => { if (!pageId) { setIsConnected(false); return () => undefined; } const disconnect = connectPageSocket(pageId, { onMessage: (msg: OracleWSMessage) => handleWSMessage(msg), onReconnect: () => void load(), onClose: () => setIsConnected(false), }); disconnectRef.current = disconnect; setIsConnected(true); return () => { disconnect(); disconnectRef.current = null; }; }, [pageId, load]); // eslint-disable-line react-hooks/exhaustive-deps // Handle WS messages function handleWSMessage(msg: OracleWSMessage) { if (msg.type === 'oracle.page.revision.committed') { const { headRevision, components } = msg.payload as { headRevision: number; components: CanvasComponent[]; }; applyRevision(headRevision, components); } else if (msg.type === 'oracle.presence.updated') { setPage((prev) => prev ? { ...prev, presence: { activeViewers: (msg.payload.activeViewers as number) ?? prev.presence.activeViewers, activeEditors: (msg.payload.activeEditors as number) ?? prev.presence.activeEditors, lastPresenceAt: msg.timestamp, }, } : prev, ); } } const optimisticallyAppendComponent = useCallback((comp: CanvasComponent) => { setPage((prev) => { if (!prev) return prev; // Prevent duplicate (idempotent insert) if (prev.components.some((c) => c.componentId === comp.componentId)) return prev; return { ...prev, components: [...prev.components, comp] }; }); }, []); const applyRevision = useCallback( (headRevision: number, components: CanvasComponent[]) => { setPage((prev) => prev ? { ...prev, headRevision, components } : prev, ); }, [], ); // Initial load useEffect(() => { void load(); }, [load]); return { page, isLoading, error, isConnected, refresh: load, optimisticallyAppendComponent, applyRevision, }; }