Siddhant Deval
Siddhant Deval
frontend5 min read

Generative UI & Interactive Canvas Synchronization

Enterprise users need actionable diagrams and interactive widgets, not plain Markdown text. But streaming partial JSON to render live canvas nodes while a human user simultaneously drags that same node creates silent race conditions. This article implements speculative AST parsing and optimistic concurrency control to enable safe human-AI collaborative editing.

Series·Part 7 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Generative UI & Interactive Canvas Synchronization

Streaming tokens into a chat window is solved. The harder problem is streaming structured data that renders as interactive components — diagram nodes, code widgets, task cards — while the same canvas is being edited by a human user in real time. These two mutation streams (AI generation and human interaction) collide silently if not explicitly governed.

This article implements speculative AST parsing of partial JSON streams, progressive canvas node materialisation, and a version-vector concurrency protocol that prevents AI streaming updates from overwriting simultaneous human edits on the shared Workspace canvas.

Architectural Note

Who this is for: Frontend engineers building collaborative tools where AI generates structured interactive content (diagrams, forms, task lists) rather than plain text. The patterns here are the foundation for tools like Notion AI Canvas and Figma AI features.


1. Generative UI: Streaming JSON into Components

1.1 From Text Stream to Live Component Tree

Generative UI renders interactive React components directly from streaming JSON — each partial object from the model progressively enhances a live component rather than waiting for the complete payload.

1.2 Partial JSON Parser

TYPESCRIPT
// ✅ Streaming partial JSON accumulator
// file: apps/web/src/lib/PartialJsonParser.ts

import { repairTruncatedJson } from '../../../gateway/src/gateway/JsonAstRepair'

export class PartialJsonParser<T> {
  private buffer = ''
  private lastValidSnapshot: Partial<T> | null = null

  feed(chunk: string): Partial<T> | null {
    this.buffer += chunk

    // Attempt to parse the current buffer as-is
    try {
      const result = JSON.parse(this.buffer) as T
      this.lastValidSnapshot = result
      return result
    } catch {
      // Buffer is incomplete — try repair
      const repaired = repairTruncatedJson(this.buffer)
      try {
        const partial = JSON.parse(repaired) as Partial<T>
        this.lastValidSnapshot = partial
        return partial
      } catch {
        // Not yet parseable at all — return last valid snapshot
        return this.lastValidSnapshot
      }
    }
  }

  reset(): void {
    this.buffer = ''
    this.lastValidSnapshot = null
  }
}

2. The Canvas Concurrency Problem

2.1 Race Condition: AI Writes vs Human Edits

Without versioning, the last writer wins. Because the AI stream arrives in bursts of SSE events and human edits arrive via user interaction, the ordering is non-deterministic. Human work gets silently discarded.

2.2 Version Vector Conflict Resolution

TYPESCRIPT
// ✅ Version-vector canvas store with conflict resolution
// file: apps/web/src/stores/CanvasStore.ts

export interface CanvasNode {
  id: string
  label: string
  nodeType: 'component' | 'service' | 'database' | 'queue'
  position: { x: number; y: number }
  version: number          // Monotonic counter
  lockedBy: 'human' | 'ai' | null  // Current lock holder
  lockedAt: number | null  // Lock timestamp (ms)
}

const LOCK_TIMEOUT_MS = 5_000  // AI lock expires after 5s of silence

export class CanvasStore {
  private nodes = new Map<string, CanvasNode>()
  private listeners = new Set<() => void>()

  // Human edit — always wins if node is not AI-locked, or lock has expired
  humanUpdate(id: string, patch: Partial<CanvasNode>): boolean {
    const node = this.nodes.get(id)
    if (!node) return false

    // Check if AI holds an active lock on this node
    if (node.lockedBy === 'ai' && node.lockedAt !== null) {
      const lockAge = Date.now() - node.lockedAt
      if (lockAge < LOCK_TIMEOUT_MS) {
        // AI is actively writing this node — queue human edit for after lock release
        return false
      }
    }

    this.nodes.set(id, {
      ...node,
      ...patch,
      version: node.version + 1,
      lockedBy: null,
      lockedAt: null,
    })
    this.notify()
    return true
  }

  // AI streaming update — respects human locks
  aiStreamUpdate(id: string, partial: Partial<CanvasNode>): void {
    const existing = this.nodes.get(id)

    if (!existing) {
      // New node from AI — materialise with streaming lock
      this.nodes.set(id, {
        id,
        label: partial.label ?? '',
        nodeType: partial.nodeType ?? 'component',
        position: partial.position ?? { x: 0, y: 0 },
        version: 1,
        lockedBy: 'ai',
        lockedAt: Date.now(),
        ...partial,
      })
    } else {
      // Check if human holds lock
      if (existing.lockedBy === 'human') {
        // Human is actively editing — AI cannot overwrite
        return
      }

      this.nodes.set(id, {
        ...existing,
        ...partial,
        version: existing.version + 1,
        lockedBy: 'ai',
        lockedAt: Date.now(),
      })
    }

    this.notify()
  }

  // Called when AI finishes streaming a node — release lock
  aiCommitNode(id: string, finalNode: CanvasNode): void {
    const existing = this.nodes.get(id)
    if (!existing || existing.lockedBy !== 'ai') return

    this.nodes.set(id, {
      ...finalNode,
      version: (existing.version ?? 0) + 1,
      lockedBy: null,
      lockedAt: null,
    })
    this.notify()
  }

  subscribe(listener: () => void): () => void {
    this.listeners.add(listener)
    return () => this.listeners.delete(listener)
  }

  getSnapshot(): ReadonlyMap<string, CanvasNode> {
    return this.nodes
  }

  private notify(): void {
    this.listeners.forEach(l => l())
  }
}

3. React Canvas Component with Streaming Awareness

TYPESCRIPT
// ✅ Canvas node that renders progressive AI streaming state
// file: apps/web/src/features/canvas/CanvasNodeCard.tsx

import { useSyncExternalStore } from 'react'

interface Props {
  nodeId: string
  store: CanvasStore
}

export function CanvasNodeCard({ nodeId, store }: Props) {
  const snapshot = useSyncExternalStore(
    store.subscribe.bind(store),
    store.getSnapshot.bind(store),
    store.getSnapshot.bind(store),
  )
  const node = snapshot.get(nodeId)

  if (!node) return null

  const isStreaming = node.lockedBy === 'ai'
  const isHumanEditing = node.lockedBy === 'human'

  return (
    <div
      className={[
        'canvas-node',
        isStreaming ? 'canvas-node--streaming' : '',
        isHumanEditing ? 'canvas-node--human-editing' : '',
      ].join(' ')}
      data-node-type={node.nodeType}
      data-version={node.version}
    >
      {isStreaming && <span className="streaming-indicator" aria-label="AI is writing" />}
      {isHumanEditing && <span className="human-indicator" aria-label="You are editing" />}

      <NodeLabel
        value={node.label}
        isEditable={!isStreaming}
        onEdit={(label) => store.humanUpdate(nodeId, { label, lockedBy: 'human', lockedAt: Date.now() })}
        onBlur={() => store.humanUpdate(nodeId, { lockedBy: null, lockedAt: null })}
      />
      <NodeTypeBadge type={node.nodeType} />
      <span className="node-version" title="Version">v{node.version}</span>
    </div>
  )
}

4. Streaming Generative UI Action Dispatcher

TYPESCRIPT
// ✅ SSE event dispatcher that routes AI actions to canvas vs chat
// file: apps/web/src/features/canvas/AiActionDispatcher.ts

import { GatewayActionSchema } from '@workspace/ai-schemas'
import { CanvasStore } from '../stores/CanvasStore'
import { StreamingMessageStore } from '../stores/StreamingMessageStore'

export class AiActionDispatcher {
  private partialNodeParsers = new Map<string, PartialJsonParser<CanvasNode>>()

  constructor(
    private readonly canvasStore: CanvasStore,
    private readonly messageStore: StreamingMessageStore,
  ) {}

  dispatch(rawEventData: string): void {
    if (rawEventData === '[DONE]') {
      // Commit all in-flight streaming nodes
      this.partialNodeParsers.forEach((_, id) => {
        const node = this.canvasStore.getSnapshot().get(id)
        if (node && node.lockedBy === 'ai') {
          this.canvasStore.aiCommitNode(id, node)
        }
      })
      this.partialNodeParsers.clear()
      return
    }

    const action = GatewayActionSchema.safeParse(JSON.parse(rawEventData))
    if (!action.success) return

    switch (action.data.type) {
      case 'canvas_node':
        this.handleCanvasNode(action.data.payload)
        break
      case 'text_reply':
        this.messageStore.appendToken(action.data.payload.content)
        break
      case 'tool_call':
        // Handled by the agent layer (Part 15)
        break
    }
  }

  private handleCanvasNode(partial: Partial<CanvasNode>): void {
    if (!partial.id) return
    let parser = this.partialNodeParsers.get(partial.id)
    if (!parser) {
      parser = new PartialJsonParser<CanvasNode>()
      this.partialNodeParsers.set(partial.id, parser)
    }
    this.canvasStore.aiStreamUpdate(partial.id, partial)
  }
}

Summary

Problem Solution
Blocking on full JSON payload PartialJsonParser materialises speculative component state from incomplete stream chunks
AI overwrites human edits Version vector with lockedBy field; AI cannot write to 'human'-locked nodes
Lock stale after AI stream error 5-second LOCK_TIMEOUT_MS auto-expires abandoned AI locks
React tearing in concurrent mode useSyncExternalStore for canvas snapshot — same pattern as Part 6

What's Next

Part 8 descends from the surface into the mathematical core. We build the foundation of vector embeddings — Dot Product, Cosine Similarity, L2 distance — and implement an AST-aware semantic chunking pipeline with SHA-256 deduplication for ingesting documents into the Workspace knowledge vault.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Generative UI#Canvas#React#Streaming#Concurrency#Optimistic UI#Real-time
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.

Series·Part 7 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Curriculum Roadmap
01The Full-Stack AI Paradigm Shift: Beyond Toy Wrappers & Prompt Concatenation
02The Anatomy of an LLM Call: Tokens, Context Windows & Probabilistic Mechanics
03Structured Outputs & Formal Invariants: Taming Non-Determinism with Zod & TypeBox
04Full-Stack Streaming Protocols: SSE, Fetch Streams & WebSockets
05V8 Streams, Server-Side Backpressure & Cancellations in Node.js
06React 18/19 Streaming UI & Generative Components
07Generative UI & Interactive Canvas Synchronization
Reading
08High-Dimensional Vector Embeddings & Similarity Math
09Vector Storage & Indexing at Scale: HNSW vs IVF in PostgreSQL (pgvector)
10Hybrid Search: Dense Vectors Meet Sparse Lexical BM25
11Multi-Stage Retrieval & Cross-Encoder Reranking
12Advanced RAG Architectures: Contextual Retrieval & Parent-Document Splitting
13Query Transformation, Decomposition & Multi-Query Expansion
14Dynamic Context Window Management & Long-Context Packing
15Agent Loops & Deterministic State Machines
16Model Context Protocol (MCP) in Full-Stack Architecture
17Human-in-the-Loop & Durable Agent Workflows
18Multi-Agent Orchestration & Collaborative Swarms
19Semantic Caching & Provider Cost Optimization
20Multi-Provider AI Gateways: Fallbacks, Rate Limiting & Hedged Requests
21Client-Side AI & Edge Hybrid Execution: WebGPU & Transformers.js
22Production Evals & Guardrails: Measuring Retrieval and Generation
23Full-Stack AI Observability & Tracing with OpenTelemetry
24Production Capstone: Assembling The Enterprise Collaborative AI Workspace