Skip to content

Form avanzati con JSON Patch ​

Quando si implementano form di grandi dimensioni e editing collaborativo in tempo reale, l'approccio tradizionale "invia l'intero form" crea problemi di prestazioni e di esperienza utente.

Questo articolo descrive pattern avanzati per form utilizzando JSON Patch (RFC 6902). Inviando solo le differenze, è possibile ridurre la larghezza di banda della rete e implementare in modo efficiente Undo/Redo e editing collaborativo.

Cosa imparerai in questo articolo ​

  • Fondamenti di JSON Patch/Pointer (RFC 6902/6901)
  • Salvataggio automatico di form di grandi dimensioni (basato sulle differenze)
  • Implementazione di Undo/Redo (patch inversa)
  • Sincronizzazione in tempo reale dell'editing collaborativo
  • Fondamenti di Operational Transform (OT) / CRDT
  • Pattern di integrazione WebSocket e RxJS
  • Risoluzione dei conflitti e controllo di versione

Prerequisiti

Questo articolo si basa sulla conoscenza di Capitolo 4: Operatori, Pattern di gestione form, e Elaborazione dati in tempo reale.

Quando è necessario questo pattern

  • Form di grandi dimensioni (>100 campi) che richiedono salvataggio automatico
  • Funzionalità Undo/Redo richiesta
  • Editing collaborativo in tempo reale (come Google Docs)
  • Supporto offline con accodamento delle differenze richiesto

Per form di piccole dimensioni (~20 campi), il pattern normale di gestione form è sufficiente.

Fondamenti di JSON Patch/Pointer ​

Cos'è JSON Patch? ​

JSON Patch (RFC 6902) è un formato standard per rappresentare le modifiche nei documenti JSON. È possibile inviare solo le modifiche, non l'intero form.

typescript
// Dati del form prima delle modifiche
const before = {
  profile: {
    name: "Taro Tanaka",
    email: "tanaka@example.com",
    age: 30
  }
};

// Dati del form dopo le modifiche
const after = {
  profile: {
    name: "Taro Tanaka",
    email: "tanaka.updated@example.com", // Modificato
    age: 31 // Modificato
  }
};

// JSON Patch (differenza)
const patch = [
  { op: "replace", path: "/profile/email", value: "tanaka.updated@example.com" },
  { op: "replace", path: "/profile/age", value: 31 }
];

Le 6 operazioni di JSON Patch

  • add - Aggiunge un valore
  • remove - Rimuove un valore
  • replace - Sostituisce un valore
  • move - Sposta un valore
  • copy - Copia un valore
  • test - Testa un valore (validazione)

Cos'è JSON Pointer? ​

JSON Pointer (RFC 6901) è una notazione di percorso che punta a un valore specifico in un documento JSON.

typescript
const formData = {
  user: {
    profile: {
      name: "Taro Tanaka"
    },
    settings: {
      notifications: true
    }
  }
};

// Esempi di JSON Pointer
"/user/profile/name"           // → "Taro Tanaka"
"/user/settings/notifications" // → true
"/user/profile"                // → { name: "Taro Tanaka" }

Rilevamento delle differenze in RxJS ​

Le librerie pairwise() e fast-json-patch sono combinate per rilevare automaticamente le modifiche nei form.

typescript
import { BehaviorSubject, pairwise, map } from 'rxjs';
import { compare } from 'fast-json-patch';

interface FormData {
  profile: {
    name: string;
    email: string;
    age: number;
  };
}

const initialData: FormData = {
  profile: {
    name: "",
    email: "",
    age: 0
  }
};

const formData$ = new BehaviorSubject<FormData>(initialData);

// Rilevare le differenze
const patches$ = formData$.pipe(
  pairwise(), // Ottiene coppie [valore precedente, valore corrente]
  map(([previous, current]) => compare(previous, current))
);

patches$.subscribe(patches => {
  console.log('Modifiche rilevate:', patches);
  // Esempio: [{ op: "replace", path: "/profile/name", value: "Taro Tanaka" }]
});

// Simula aggiornamento del form
formData$.next({
  profile: {
    name: "Taro Tanaka",
    email: "tanaka@example.com",
    age: 30
  }
});

Libreria fast-json-patch

bash
npm install fast-json-patch
  • Completamente conforme a RFC 6902
  • Generazione differenze (compare) e applicazione (applyPatch)
  • Generazione patch inversa (per Undo)
  • Supporto TypeScript

Salvataggio automatico e Undo/Redo per form di grandi dimensioni ​

Implementare funzionalità di salvataggio automatico e Undo/Redo in form di grandi dimensioni (es. schermate di registrazione membri e gestione prodotti con 100 campi).

Politica di implementazione ​

Responsabilità frontend:

  • Generazione e allineamento delle operazioni (modifiche)
  • Riflessione UI ottimistica (applicazione immediata tramite scan)
  • Gestione stack Undo/Redo (cronologia tramite patch inversa)
  • Gestione coda di invio (ordine garantito con concatMap)
  • Batching (bufferTime + compressione)

Responsabilità backend:

  • Controllo versione (Vector Clock / timestamp)
  • Garanzia idempotenza (rilevamento duplicati tramite Request ID)
  • Persistenza e log di audit

Pattern 1: Salvataggio automatico di base ​

Le modifiche al form vengono rilevate e inviate al server in batch a intervalli regolari.

typescript
import {
  BehaviorSubject,
  Subject,
  pairwise,
  map,
  bufferTime,
  filter,
  concatMap,
  catchError,
  of
} from 'rxjs';
import { compare, Operation } from 'fast-json-patch';

interface LargeFormData {
  personalInfo: {
    firstName: string;
    lastName: string;
    email: string;
    phone: string;
    dateOfBirth: string;
  };
  address: {
    street: string;
    city: string;
    state: string;
    zipCode: string;
    country: string;
  };
  preferences: {
    newsletter: boolean;
    notifications: boolean;
    theme: 'light' | 'dark';
  };
  // ... assumendo oltre 100 campi
}

const initialFormData: LargeFormData = {
  personalInfo: {
    firstName: "",
    lastName: "",
    email: "",
    phone: "",
    dateOfBirth: ""
  },
  address: {
    street: "",
    city: "",
    state: "",
    zipCode: "",
    country: "Japan"
  },
  preferences: {
    newsletter: false,
    notifications: true,
    theme: 'light'
  }
};

// Stream dei dati del form
const formData$ = new BehaviorSubject<LargeFormData>(initialFormData);

// Stream dei risultati di salvataggio
const saveResult$ = new Subject<{ success: boolean; message: string }>();

// Elemento di visualizzazione stato per demo
const statusDiv = document.createElement('div');
statusDiv.style.padding = '10px';
statusDiv.style.margin = '10px';
statusDiv.style.border = '2px solid #ccc';
statusDiv.style.borderRadius = '4px';
statusDiv.style.fontFamily = 'monospace';
statusDiv.style.fontSize = '14px';
document.body.appendChild(statusDiv);

function updateStatus(message: string, color: string = '#333') {
  statusDiv.innerHTML = `<span style="color: ${color}">${message}</span>`;
}

// Pipeline di salvataggio automatico
formData$.pipe(
  pairwise(),
  map(([previous, current]) => ({
    patches: compare(previous, current),
    timestamp: Date.now()
  })),
  filter(({ patches }) => patches.length > 0), // Salta se non ci sono modifiche
  bufferTime(2000), // Buffer delle modifiche per 2 secondi
  filter(buffer => buffer.length > 0), // Salta buffer vuoti
  map(buffer => {
    // Unisce tutte le patch nel buffer in un unico array
    const allPatches = buffer.flatMap(item => item.patches);
    updateStatus(`📦 Elaborazione batch di ${allPatches.length} modifiche...`, '#FF9800');
    return allPatches;
  }),
  concatMap(patches => saveToServer(patches)), // Invia con ordine garantito
  catchError((error: unknown) => {
    console.error('Errore salvataggio automatico:', error);
    updateStatus(`❌ Salvataggio fallito: ${(error instanceof Error ? error.message : String(error))}`, '#f44336');
    return of({ success: false, message: (error instanceof Error ? error.message : String(error)) });
  })
).subscribe(result => {
  if (result.success) {
    updateStatus(`✅ Salvataggio automatico completato (${new Date().toLocaleTimeString()})`, '#4CAF50');
  }
  saveResult$.next(result);
});

// Salva sul server (implementazione mock)
function saveToServer(patches: Operation[]): Promise<{ success: boolean; message: string }> {
  console.log('Invio al server:', patches);

  // Esempio di implementazione reale:
  // return fetch('/api/forms/12345/patches', {
  //   method: 'PATCH',
  //   headers: { 'Content-Type': 'application/json-patch+json' },
  //   body: JSON.stringify(patches)
  // }).then(res => res.json());

  // Mock: restituisce successo dopo 500ms
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        success: true,
        message: `${patches.length} modifiche salvate`
      });
    }, 500);
  });
}

// Demo: simula modifiche al form
const demoButton = document.createElement('button');
demoButton.textContent = 'Modifica form (Demo)';
demoButton.style.padding = '10px 20px';
demoButton.style.margin = '10px';
demoButton.style.fontSize = '16px';
demoButton.style.cursor = 'pointer';
document.body.appendChild(demoButton);

demoButton.addEventListener('click', () => {
  // Modifica casuale dei campi
  const currentData = formData$.getValue();
  const updatedData = {
    ...currentData,
    personalInfo: {
      ...currentData.personalInfo,
      firstName: `Taro_${Math.floor(Math.random() * 100)}`,
      email: `taro${Math.floor(Math.random() * 100)}@example.com`
    },
    preferences: {
      ...currentData.preferences,
      newsletter: !currentData.preferences.newsletter
    }
  };
  formData$.next(updatedData);
  updateStatus('📝 Form modificato...', '#2196F3');
});

Punti chiave del salvataggio automatico

  • bufferTime(2000) - Invia 2 secondi di modifiche insieme (efficienza di rete)
  • concatMap - Garantisce l'ordine delle patch (mergeMap può rompere l'ordine)
  • filter - Salta se non ci sono modifiche (riduce richieste inutili)
  • Idempotenza - Sicuro inviare la stessa patch più volte (assegna Request ID)

Pattern 2: Implementazione Undo/Redo ​

Implementare la funzionalità Undo/Redo usando la patch inversa.

typescript
import { Subject, scan } from 'rxjs';
import { applyPatch, Operation, deepClone } from 'fast-json-patch';

interface HistoryState<T> {
  current: T;
  undoStack: Operation[][];
  redoStack: Operation[][];
}

interface HistoryAction {
  type: 'APPLY_PATCH' | 'UNDO' | 'REDO';
  patches?: Operation[];
}

// Stream di gestione della cronologia
const historyAction$ = new Subject<HistoryAction>();

const initialState: HistoryState<LargeFormData> = {
  current: initialFormData,
  undoStack: [],
  redoStack: []
};

// Reducer per gestire la cronologia
const history$ = historyAction$.pipe(
  scan((state, action) => {
    switch (action.type) {
      case 'APPLY_PATCH':
        if (!action.patches || action.patches.length === 0) return state;

        // Applica la patch
        const cloned = deepClone(state.current);
        const result = applyPatch(cloned, action.patches, true, false);

        return {
          current: result.newDocument,
          undoStack: [...state.undoStack, action.patches],
          redoStack: [] // Cancella lo stack Redo con una nuova operazione
        };

      case 'UNDO':
        if (state.undoStack.length === 0) return state;

        const patchesToUndo = state.undoStack[state.undoStack.length - 1];
        const beforeUndo = deepClone(state.current);

        // Genera e applica la patch inversa
        const inversePatch = generateInversePatch(state.current, patchesToUndo);
        const undoResult = applyPatch(beforeUndo, inversePatch, true, false);

        return {
          current: undoResult.newDocument,
          undoStack: state.undoStack.slice(0, -1),
          redoStack: [...state.redoStack, patchesToUndo]
        };

      case 'REDO':
        if (state.redoStack.length === 0) return state;

        const patchesToRedo = state.redoStack[state.redoStack.length - 1];
        const beforeRedo = deepClone(state.current);
        const redoResult = applyPatch(beforeRedo, patchesToRedo, true, false);

        return {
          current: redoResult.newDocument,
          undoStack: [...state.undoStack, patchesToRedo],
          redoStack: state.redoStack.slice(0, -1)
        };

      default:
        return state;
    }
  }, initialState)
);

// Generazione patch inversa (implementazione semplice)
function generateInversePatch(document: any, patches: Operation[]): Operation[] {
  // applyPatch di fast-json-patch restituisce patch inversa se il 4° argomento è true
  const cloned = deepClone(document);
  const result = applyPatch(cloned, patches, true, true);
  return result[1] || []; // Ottiene la patch inversa
}

// Elementi UI
const historyControlDiv = document.createElement('div');
historyControlDiv.style.padding = '10px';
historyControlDiv.style.margin = '10px';
document.body.appendChild(historyControlDiv);

const undoButton = document.createElement('button');
undoButton.textContent = '↶ Undo';
undoButton.style.padding = '10px 20px';
undoButton.style.marginRight = '10px';
undoButton.style.fontSize = '16px';
undoButton.style.cursor = 'pointer';
historyControlDiv.appendChild(undoButton);

const redoButton = document.createElement('button');
redoButton.textContent = '↷ Redo';
redoButton.style.padding = '10px 20px';
redoButton.style.fontSize = '16px';
redoButton.style.cursor = 'pointer';
historyControlDiv.appendChild(redoButton);

const historyInfo = document.createElement('div');
historyInfo.style.marginTop = '10px';
historyInfo.style.fontFamily = 'monospace';
historyInfo.style.fontSize = '14px';
historyControlDiv.appendChild(historyInfo);

// Mostra lo stato della cronologia
history$.subscribe(state => {
  undoButton.disabled = state.undoStack.length === 0;
  redoButton.disabled = state.redoStack.length === 0;

  historyInfo.innerHTML = `
    📚 Undo disponibili: ${state.undoStack.length}<br>
    📚 Redo disponibili: ${state.redoStack.length}<br>
    📝 Valore attuale: ${JSON.stringify(state.current.personalInfo.firstName)}
  `;

  // Sincronizza i dati del form
  formData$.next(state.current);
});

// Eventi dei pulsanti
undoButton.addEventListener('click', () => {
  historyAction$.next({ type: 'UNDO' });
});

redoButton.addEventListener('click', () => {
  historyAction$.next({ type: 'REDO' });
});

// Demo: pulsante applica patch
const applyPatchButton = document.createElement('button');
applyPatchButton.textContent = 'Applica modifiche (test Undo/Redo)';
applyPatchButton.style.padding = '10px 20px';
applyPatchButton.style.margin = '10px';
applyPatchButton.style.fontSize = '16px';
applyPatchButton.style.cursor = 'pointer';
document.body.appendChild(applyPatchButton);

applyPatchButton.addEventListener('click', () => {
  const patches: Operation[] = [
    { op: 'replace', path: '/personalInfo/firstName', value: `Taro_${Date.now()}` }
  ];
  historyAction$.next({ type: 'APPLY_PATCH', patches });
});

Punti chiave di Undo/Redo

  • Patch inversa - Imposta il quarto argomento di applyPatch su true per ottenere la patch inversa
  • Gestione stack - Stack Undo (operazioni passate) e stack Redo (operazioni annullate)
  • Cancella stack Redo su nuova operazione - Resetta lo stack Redo su nuova modifica
  • Pattern Reducer con scan - Implementa la gestione dello stato come useReducer in React

Pattern 3: Supporto offline (coda IndexedDB) ​

Quando offline, le modifiche vengono accodate in IndexedDB e sincronizzate al ritorno online.

typescript
import { fromEvent, merge, map, filter, concatMap, catchError, of } from 'rxjs';

// Stato online/offline
const online$ = merge(
  fromEvent(window, 'online').pipe(map(() => true)),
  fromEvent(window, 'offline').pipe(map(() => false))
);

// Operazioni IndexedDB (implementazione semplice)
class PatchQueue {
  private dbName = 'form-patches';
  private storeName = 'patches';
  private db: IDBDatabase | null = null;

  async init(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        if (!db.objectStoreNames.contains(this.storeName)) {
          db.createObjectStore(this.storeName, {
            keyPath: 'id',
            autoIncrement: true
          });
        }
      };
    });
  }

  async enqueue(patches: Operation[]): Promise<void> {
    if (!this.db) throw new Error('DB not initialized');

    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      const request = store.add({
        patches,
        timestamp: Date.now()
      });

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async dequeueAll(): Promise<Operation[][]> {
    if (!this.db) throw new Error('DB not initialized');

    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      const request = store.getAll();

      request.onsuccess = () => {
        const items = request.result;
        // Cancella dopo il recupero
        store.clear();
        resolve(items.map((item: any) => item.patches));
      };
      request.onerror = () => reject(request.error);
    });
  }
}

const patchQueue = new PatchQueue();
patchQueue.init().then(() => {
  console.log('IndexedDB inizializzato');
});

// Salvataggio automatico con supporto offline
formData$.pipe(
  pairwise(),
  map(([previous, current]) => compare(previous, current)),
  filter(patches => patches.length > 0),
  bufferTime(2000),
  filter(buffer => buffer.length > 0),
  map(buffer => buffer.flatMap(patches => patches)),
  concatMap(async (patches) => {
    const isOnline = navigator.onLine;

    if (isOnline) {
      // Online: invia al server
      try {
        return await saveToServer(patches);
      } catch (error) {
        // Invio fallito: aggiungi alla coda
        await patchQueue.enqueue(patches);
        return { success: false, message: 'Aggiunto alla coda offline' };
      }
    } else {
      // Offline: aggiungi alla coda
      await patchQueue.enqueue(patches);
      console.log('📴 Offline: aggiunto alla coda');
      return { success: false, message: 'Offline' };
    }
  })
).subscribe();

// Sincronizza al ritorno online
online$.pipe(
  filter(isOnline => isOnline),
  concatMap(async () => {
    console.log('🌐 Ritorno online: sincronizzazione coda...');
    const queuedPatches = await patchQueue.dequeueAll();

    for (const patches of queuedPatches) {
      await saveToServer(patches);
    }

    return { synced: queuedPatches.length };
  })
).subscribe(result => {
  console.log(`✅ ${result.synced} patch sincronizzate`);
});

Punti chiave del supporto offline

  • IndexedDB - Storage persistente lato browser (più grande di LocalStorage)
  • Accodamento - Offline, memorizzato nella coda in uscita
  • Rilevamento ritorno online - Monitoraggio eventi con window.addEventListener('online')
  • Garanzia ordine - Invia le patch in coda in ordine con concatMap

Sincronizzazione in tempo reale dell'editing collaborativo ​

Implementare "editing collaborativo in tempo reale", dove più utenti modificano lo stesso form contemporaneamente (funzionalità come Google Docs, Notion, Figma).

Fondamenti di Operational Transform (OT) e CRDT ​

Nell'editing collaborativo in tempo reale, la risoluzione dei conflitti è la sfida maggiore. Esistono due approcci principali.

Operational Transform (OT) ​

Trasforma le operazioni di modifica per risolvere i conflitti.

Utente A: "hello" → "hello world" (aggiunge " world" alla fine)
Utente B: "hello" → "Hi hello"   (aggiunge "Hi " all'inizio)

【Senza trasformazione】
Risultato: "Hi hello world" o "hello world" (vince l'ultimo)

【Con OT applicato】
- Trasforma l'operazione dell'utente A con l'operazione dell'utente B
- Trasforma l'operazione dell'utente B con l'operazione dell'utente A
→ Risultato: "Hi hello world" (entrambe le modifiche conservate)

Vantaggi:

  • Risultato intuitivo (entrambe le modifiche conservate)
  • Il server determina lo stato finale

Svantaggi:

  • Implementazione complessa
  • Server richiesto

CRDT (Conflict-free Replicated Data Types) ​

Utilizza strutture dati matematicamente prive di conflitti.

Assegna ID univoco a ogni carattere:

Utente A: [h1, e2, l3, l4, o5] → [h1, e2, l3, l4, o5, space6, w7, o8, r9, l10, d11]
Utente B: [h1, e2, l3, l4, o5] → [H12, i13, space14, h1, e2, l3, l4, o5]

Ordina per ID durante il merge:
→ [H12, i13, space14, h1, e2, l3, l4, o5, space6, w7, o8, r9, l10, d11]
→ "Hi hello world"

Vantaggi:

  • Server non richiesto (compatibile P2P)
  • Facile supporto offline

Svantaggi:

  • Elevato uso di memoria
  • Processo di cancellazione complesso (metodo Tombstone)

Selezione libreria

  • Implementazione OT: ShareDB - Operational Transform
  • Implementazione CRDT: Yjs - CRDT ad alte prestazioni (consigliato)
  • Implementazione CRDT: Automerge - Specifico per JSON

Questo articolo presenta un esempio di implementazione usando Yjs (CRDT).

Pattern 4: Editing collaborativo con Yjs ​

Combina Yjs e RxJS per implementare editing collaborativo in tempo reale.

typescript
import { fromEvent, merge, Subject } from 'rxjs';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

// Documento Yjs (stato condiviso)
const ydoc = new Y.Doc();

// Dati form condivisi (tipo Yjs Map)
const yFormData = ydoc.getMap('formData');

// Provider WebSocket (connessione server)
// In produzione, costruisci il tuo server WebSocket
const wsProvider = new WebsocketProvider(
  'wss://demos.yjs.dev', // Server pubblico per demo
  'rxjs-form-demo',      // Nome stanza
  ydoc
);

// Subject RxJS per le modifiche al form
const formChange$ = new Subject<{
  key: string;
  value: any;
  user: string;
}>();

// Converte le modifiche Yjs in stream RxJS
yFormData.observe(event => {
  event.changes.keys.forEach((change, key) => {
    if (change.action === 'add' || change.action === 'update') {
      formChange$.next({
        key: key,
        value: yFormData.get(key),
        user: 'remote'
      });
    }
  });
});

// UI Demo
const collaborativeFormDiv = document.createElement('div');
collaborativeFormDiv.style.padding = '20px';
collaborativeFormDiv.style.margin = '10px';
collaborativeFormDiv.style.border = '2px solid #2196F3';
collaborativeFormDiv.style.borderRadius = '8px';
collaborativeFormDiv.style.backgroundColor = '#f5f5f5';
document.body.appendChild(collaborativeFormDiv);

const title = document.createElement('h3');
title.textContent = '🤝 Demo editing collaborativo in tempo reale';
title.style.margin = '0 0 15px 0';
collaborativeFormDiv.appendChild(title);

const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.placeholder = 'Inserisci nome (sincronizzato con altri utenti)';
nameInput.style.padding = '10px';
nameInput.style.width = '100%';
nameInput.style.fontSize = '16px';
nameInput.style.border = '2px solid #ccc';
nameInput.style.borderRadius = '4px';
nameInput.style.boxSizing = 'border-box';
collaborativeFormDiv.appendChild(nameInput);

const syncStatus = document.createElement('div');
syncStatus.style.marginTop = '10px';
syncStatus.style.fontSize = '14px';
syncStatus.style.color = '#666';
collaborativeFormDiv.appendChild(syncStatus);

const activeUsers = document.createElement('div');
activeUsers.style.marginTop = '10px';
activeUsers.style.fontSize = '14px';
activeUsers.style.color = '#666';
collaborativeFormDiv.appendChild(activeUsers);

// Monitoraggio stato connessione
wsProvider.on('status', (event: { status: string }) => {
  if (event.status === 'connected') {
    syncStatus.innerHTML = '🟢 <strong>Connesso</strong> - sincronizzazione in tempo reale con altri utenti';
    syncStatus.style.color = '#4CAF50';
  } else {
    syncStatus.innerHTML = '🔴 <strong>Disconnesso</strong>';
    syncStatus.style.color = '#f44336';
  }
});

// Mostra numero utenti connessi (Awareness API)
wsProvider.awareness.on('change', () => {
  const users = Array.from(wsProvider.awareness.getStates().keys());
  activeUsers.innerHTML = `👥 Utenti attivi: ${users.length}`;
});

// Riflette modifiche locali in Yjs
let isRemoteChange = false;

fromEvent(nameInput, 'input').subscribe(() => {
  if (!isRemoteChange) {
    yFormData.set('name', nameInput.value);
  }
});

// Riflette modifiche remote nell'UI
formChange$.subscribe(change => {
  if (change.key === 'name') {
    isRemoteChange = true;
    nameInput.value = change.value || '';
    nameInput.style.borderColor = '#4CAF50';

    setTimeout(() => {
      nameInput.style.borderColor = '#ccc';
      isRemoteChange = false;
    }, 500);
  }
});

// Imposta valori iniziali
const initialName = yFormData.get('name');
if (initialName) {
  nameInput.value = initialName;
}

Punti chiave di Yjs

  • Y.Doc - Documento condiviso (CRDT)
  • Y.Map - Tipo Map condiviso ({ key: value })
  • WebsocketProvider - Sincronizzazione via WebSocket
  • Awareness API - Informazioni di presenza (utenti connessi, posizione cursore)
  • observe - Monitora modifiche e converte in stream RxJS

Pattern 5: Gestione presenza (condivisione posizione cursore) ​

Visualizza chi sta modificando cosa.

typescript
import { throttleTime } from 'rxjs';

interface UserPresence {
  userId: string;
  name: string;
  color: string;
  cursorPosition: number;
  timestamp: number;
}

// Genera colore casuale
function generateRandomColor(): string {
  const colors = ['#f44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#00BCD4', '#009688'];
  return colors[Math.floor(Math.random() * colors.length)];
}

// Il tuo ID utente (generato casualmente)
const myUserId = `user_${Math.random().toString(36).substr(2, 9)}`;
const myColor = generateRandomColor();

// Configurazione Awareness (informazioni presenza)
wsProvider.awareness.setLocalState({
  userId: myUserId,
  name: `Utente${myUserId.slice(-4)}`,
  color: myColor
});

// Rileva modifiche posizione cursore (throttle limita frequenza invio)
fromEvent(nameInput, 'selectionchange').pipe(
  throttleTime(200)
).subscribe(() => {
  const cursorPosition = nameInput.selectionStart || 0;

  wsProvider.awareness.setLocalStateField('cursorPosition', cursorPosition);
});

// Mostra posizione cursore altri utenti
const cursorOverlay = document.createElement('div');
cursorOverlay.style.position = 'relative';
cursorOverlay.style.marginTop = '10px';
cursorOverlay.style.fontSize = '12px';
collaborativeFormDiv.appendChild(cursorOverlay);

wsProvider.awareness.on('change', () => {
  const states = wsProvider.awareness.getStates();
  const cursors: string[] = [];

  states.forEach((state: any, clientId: number) => {
    if (state.userId !== myUserId) {
      cursors.push(
        `<span style="color: ${state.color}">● ${state.name}</span> (posizione: ${state.cursorPosition || 0})`
      );
    }
  });

  cursorOverlay.innerHTML = cursors.length > 0
    ? `📍 Altri utenti: ${cursors.join(', ')}`
    : '📍 Nessun altro utente';
});

Punti chiave della gestione presenza

  • awareness.setLocalState - Condividi le tue informazioni
  • awareness.getStates - Ottieni informazioni di tutti gli utenti
  • throttleTime - Limita frequenza invio movimento cursore (200ms)
  • Codifica colori - Assegna un colore a ogni utente per migliorare la visibilità

Pattern 6: Gestione errori e riconnessione ​

Implementa riconnessione e gestione errori alla disconnessione WebSocket.

typescript
import { timer, takeUntil, Subject } from 'rxjs';

const disconnect$ = new Subject<void>();

// Rilevamento disconnessione WebSocket
wsProvider.on('connection-close', () => {
  console.warn('⚠️ Disconnessione WebSocket');
  syncStatus.innerHTML = '🟡 <strong>Riconnessione in corso...</strong>';
  syncStatus.style.color = '#FF9800';

  // Tenta riconnessione dopo 5 secondi
  timer(5000).pipe(
    takeUntil(disconnect$)
  ).subscribe(() => {
    console.log('🔄 Tentativo di riconnessione');
    wsProvider.connect();
  });
});

// Gestione errori WebSocket
wsProvider.on('connection-error', (error: Error) => {
  console.error('❌ Errore WebSocket:', error);
  syncStatus.innerHTML = `❌ <strong>Errore:</strong> ${error.message}`;
  syncStatus.style.color = '#f44336';
});

// Cleanup
window.addEventListener('beforeunload', () => {
  disconnect$.next();
  wsProvider.disconnect();
  ydoc.destroy();
});

Note per l'ambiente di produzione

  • Server WebSocket proprio - wss://demos.yjs.dev è per sviluppo. In produzione, costruisci y-websocket-server
  • Autenticazione - Implementa autenticazione token per connessioni WebSocket
  • Scaling - Condividi stato tra server WebSocket con Redis, ecc.
  • Persistenza - Memorizza documenti Yjs nel database (y-leveldb, y-indexeddb)

Questo articolo ha descritto pattern avanzati per form usando JSON Patch.

Punti chiave ​

Criteri di selezione del pattern JSON Patch

Se la gestione form normale è sufficiente:

  • Numero campi: ~20
  • Salvataggio automatico: non richiesto o invio completo OK
  • Undo/Redo: non richiesto
  • Editing collaborativo: non richiesto → Usa Pattern normale di gestione form

Se JSON Patch è richiesto:

  • Numero campi: > 100
  • Salvataggio automatico: richiesto (invia solo differenze)
  • Undo/Redo: richiesto
  • Editing collaborativo: sincronizzazione in tempo reale richiesta → Usa i pattern di questo articolo

Riepilogo pattern di implementazione ​

PatternCaso d'usoTecnologie chiave
Salvataggio automatico baseInvio differenze per form grandipairwise + bufferTime + concatMap
Undo/RedoGestione cronologia operazionipatch inversa + scan
Supporto offlineSupporto disconnessione reteIndexedDB + accodamento
Editing collaborativo (Yjs)Sincronizzazione in tempo realeYjs (CRDT) + WebSocket
Gestione presenzaCondivisione posizione cursoreAwareness API + throttleTime

Prossimi passi ​

Risorse di riferimento ​

Specifiche standard ​

Librerie ​

  • fast-json-patch - Implementazione JSON Patch (conforme RFC)
  • Yjs - Implementazione CRDT (editing collaborativo)
  • ShareDB - Implementazione Operational Transform
  • Automerge - CRDT specifico per JSON

Risorse di apprendimento ​

Pubblicato sotto licenza CC-BY-4.0.