Fumabase architecture

State Management and Synchronization

How state is managed with Zustand and synchronized between website and docs-website via iframe postMessage

Holocron uses Zustand for state management with a sophisticated iframe-based synchronization system to keep the editing interface and preview in sync.

State Architecture

Website State (Parent Frame)

Located in website/src/lib/state.tsx:

export type State = {
    currentSlug: string                      // Current page being edited
    filesInDraft: Record<string, FileUpdate>  // In-memory file changes
    lastPushedFiles: Record<string, FileUpdate> // Last synced to GitHub
}

Docs State (Preview Frame)

Located in docs-website/src/lib/docs-state.tsx:

export type DocsState = {
    toc?: TOCItemType[]              // Table of contents
    currentSlug?: string             // Current page slug
    filesInDraft: FilesInDraft       // Same draft files
    isMarkdownStreaming?: boolean    // AI is generating content
    deletedPages: Array<{ slug: string }>
    previewMode?: 'preview' | 'editor'
    highlightedLines?: {             // Text selection from AI
        slug: string
        startLine: number
        endLine: number
    }
}

export type PersistentDocsState = {
    chatId: string                   // Current chat session
    drawerState: DrawerState         // UI drawer state
    chatHistory: Record<string, ChatHistory>  // Saved messages
}

Zustand Store Setup

Website Store

export const [WebsiteStateProvider, useWebsiteState] = 
    createZustandContext<State>((initial) => 
        create((set) => ({ ...initial }))
    )

// Global access for debugging
if (typeof window !== 'undefined') {
    window['useWebsiteState'] = useWebsiteState
}

Docs Store

export const useDocsState = create<DocsState>(() => defaultState)

export const usePersistentDocsState = create<PersistentDocsState>(
    () => defaultPersistentState
)

// Global access
if (typeof window !== 'undefined') {
    window['useDocsState'] = useDocsState
}

IFrame Communication

Message Protocol

export type IframeRpcMessage = {
    id: string                    // Unique message ID
    state?: Partial<DocsState>    // State updates to apply
    revalidate?: boolean          // Trigger re-render
    idempotenceKey?: string       // Deduplication key
    error?: string                // Error message
}

Sending Updates (Docs → Website)

export function updateFileInDocsEditor(githubPath: string, content: string) {
    const updatedFile = {
        content,
        githubPath,
    }
    
    // 1. Update local state
    useDocsState.setState((state) => ({
        ...state,
        filesInDraft: {
            ...state.filesInDraft,
            [githubPath]: updatedFile,
        },
    }))
    
    // 2. Send to parent frame
    if (typeof window !== 'undefined' && window.parent !== window) {
        const message: IframeRpcMessage = {
            id: generateChatId(),
            state: {
                filesInDraft: {
                    [githubPath]: updatedFile,
                },
            },
        }
        window.parent.postMessage(message, '*')
    }
}

Receiving Updates (Website)

// In website component
useEffect(() => {
    function handleMessage(event: MessageEvent) {
        const message = event.data as IframeRpcMessage
        
        if (message.state?.filesInDraft) {
            // Merge filesInDraft updates
            setState((prev) => ({
                ...prev,
                filesInDraft: {
                    ...prev.filesInDraft,
                    ...message.state.filesInDraft,
                },
            }))
        }
        
        if (message.revalidate) {
            // Trigger re-render or data refresh
            revalidateData()
        }
    }
    
    window.addEventListener('message', handleMessage)
    return () => window.removeEventListener('message', handleMessage)
}, [])

State Flow Patterns

1. AI Tool → filesInDraft → Preview

graph LR
    A[AI Tool] -->|Modifies| B[filesInDraft]
    B -->|Zustand Update| C[Website State]
    C -->|postMessage| D[Docs State]
    D -->|Re-render| E[Preview]

2. Monaco Editor → filesInDraft → AI Context

graph LR
    A[Monaco Editor] -->|User Edit| B[Docs State]
    B -->|postMessage| C[Website State]
    C -->|filesInDraft| D[AI Context]
    D -->|Tool Access| E[FileSystemEmulator]

3. Chat Completion → Database → Persistence

graph LR
    A[Chat Ends] -->|Save| B[filesInDraft to DB]
    B -->|Update| C[lastPushedFiles]
    C -->|Clear| D[filesInDraft]
    D -->|New Session| E[Empty State]

Change Detection

Checking for Unpushed Changes

export function doFilesInDraftNeedPush(
    currentFilesInDraft: Record<string, FileUpdate>,
    lastPushedFiles: Record<string, FileUpdate>,
) {
    const hasNonPushedChanges = Object.keys(currentFilesInDraft).some((key) => {
        const current = currentFilesInDraft[key]
        const initial = lastPushedFiles[key]
        
        // Trim content for comparison (ignore whitespace)
        const currentContent = (current?.content ?? '').trim()
        const initialContent = (initial?.content ?? '').trim()
        
        const different = currentContent !== initialContent
        if (different) {
            const diffLen = Math.abs(
                currentContent.length - initialContent.length
            )
            console.log(
                `File "${key}" changed by ${diffLen} characters`
            )
        }
        return different
    })
    
    return hasNonPushedChanges
}

Chat History Persistence

Saving Chat Messages

export function saveChatMessages(chatId: string, messages: UIMessage[]) {
    const state = usePersistentDocsState.getState()
    
    // Keep only last 10 messages per chat
    const limitedMessages = messages.slice(-10)
    
    const updatedHistory = {
        ...state.chatHistory,
        [chatId]: {
            messages: limitedMessages,
            createdAt: existingHistory?.createdAt || new Date().toISOString(),
        },
    }
    
    // Keep only 10 most recent chats total
    const sortedChats = Object.entries(updatedHistory)
        .sort(([, a], [, b]) => 
            new Date(b.createdAt).getTime() - 
            new Date(a.createdAt).getTime()
        )
        .slice(0, 10)
    
    usePersistentDocsState.setState({
        chatHistory: Object.fromEntries(sortedChats),
    })
}

LocalStorage Persistence

if (typeof window !== 'undefined') {
    const persistentStateKey = 'holocron-docs-persistent-state'
    
    // Rehydrate on load
    const savedState = localStorage.getItem(persistentStateKey)
    if (savedState) {
        try {
            const parsedState = JSON.parse(savedState)
            usePersistentDocsState.setState(parsedState)
        } catch (error) {
            console.warn('Failed to parse saved state:', error)
        }
    }
    
    // Persist on change
    usePersistentDocsState.subscribe((state) => {
        localStorage.setItem(persistentStateKey, JSON.stringify(state))
    })
}

Real-time Updates

Monaco Editor Integration

// In Monaco component
const handleChange = (value: string) => {
    // Update local state and notify parent
    updateFileInDocsEditor(currentFile, value)
}

// Monaco configuration
<MonacoEditor
    value={filesInDraft[currentFile]?.content || originalContent}
    onChange={handleChange}
    language={getLanguageFromPath(currentFile)}
/>

Preview Re-rendering

// In preview component
const { filesInDraft } = useDocsState()

// Derive content from filesInDraft or database
const content = useMemo(() => {
    if (filesInDraft[currentPath]) {
        return filesInDraft[currentPath].content
    }
    return databaseContent
}, [filesInDraft, currentPath, databaseContent])

// Render markdown with draft content
<MarkdownRenderer content={content} />

Text Highlighting

Highlight State Management

// Subscribe to highlight changes
useDocsState.subscribe((state, prevState) => {
    if (
        state.highlightedLines &&
        state.highlightedLines !== prevState.highlightedLines
    ) {
        highlightText(state.highlightedLines)
    }
})

// Apply highlighting
function highlightText({ slug, startLine, endLine }) {
    // Find elements and add highlight classes
    const elements = document.querySelectorAll(`[data-line]`)
    elements.forEach((el) => {
        const line = parseInt(el.dataset.line)
        if (line >= startLine && line <= endLine) {
            el.classList.add('highlight')
        }
    })
}

State Update Patterns

Optimistic Updates

// Update UI immediately
setState({ filesInDraft: newFiles })

// Persist asynchronously
await saveToDatabase(newFiles).catch((error) => {
    // Rollback on error
    setState({ filesInDraft: oldFiles })
})

Batch Updates

// Collect multiple changes
const updates = {}
for (const file of files) {
    updates[file.path] = processFile(file)
}

// Single state update
setState((prev) => ({
    filesInDraft: {
        ...prev.filesInDraft,
        ...updates,
    },
}))

Debounced Persistence

const debouncedSave = useMemo(
    () => debounce(async (files) => {
        await prisma.chat.update({
            where: { chatId },
            data: { filesInDraft: files },
        })
    }, 1000),
    [chatId]
)

// On change
useEffect(() => {
    debouncedSave(filesInDraft)
}, [filesInDraft])

Performance Considerations

State Size Management

  • Chat history limited to 10 messages per chat

  • Maximum 10 chats stored in localStorage

  • Large file content can impact performance

Update Optimization

  • Use partial state updates when possible

  • Batch related changes together

  • Debounce frequent updates

Memory Management

  • Clear filesInDraft after successful push

  • Remove old chat history periodically

  • Use lazy loading for large content

Common Issues and Solutions

State Desync

Problem: Preview doesn't match editor Solution: Force revalidation via message:

window.parent.postMessage({
    id: generateId(),
    revalidate: true,
}, '*')

Lost Changes

Problem: Changes lost on refresh Solution: Persist to database more frequently or use sessionStorage

Performance Degradation

Problem: UI becomes sluggish with many files Solution: Implement virtualization or pagination for file lists

How is this guide?

Last updated on

Powered by Holocron

Documentation