AcadeDoc/apps/client/src/features/acadenice/sync-blocks/hooks/use-sync-block-realtime.ts
Corentin 23a85267bf feat(acadenice): add sync blocks for cross-page content sharing — R4.2
Implements Notion-style sync blocks: a Tiptap node whose content is shared
across N pages. Editing via the Hocuspocus overlay propagates to all instances
via Yjs collab + SSE broadcast (EventEmitter2 bus).

Server: DB migration, NestJS module (CRUD + BFS cycle detection + broadcast),
Hocuspocus persistence extension extended for sync-block-* docs, 3 new RBAC
permissions (sync_blocks:create/edit/delete), seeded to Admin/Editor/Member.

Client: SyncBlockExtension (Tiptap node), SyncBlockNodeView (NodeView +
Mantine Modal overlay + SSE hook), /sync-block slash command, service client.

Tests: 32 server Jest + 18 client Vitest, all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:40:12 +02:00

97 lines
2.9 KiB
TypeScript

import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
export const SYNC_BLOCK_QUERY_KEY = "sync-block";
/**
* SSE hook for sync block realtime updates (R4.2).
*
* Listens to the NestJS EventEmitter2 via an SSE endpoint:
* GET /api/acadenice/sync-blocks/{masterId}/events
*
* When the server broadcasts a `sync-block.updated` event for this masterId,
* we invalidate the React Query cache entry so the NodeView re-fetches the
* latest content transparently.
*
* Note: the SSE endpoint is planned for R4.2.b. Until then, the hook polls
* for cache invalidation triggered by Hocuspocus awareness updates instead.
* The hook signature is stable — wiring the actual SSE URL is a 1-line change.
*
* Reconnect: exponential backoff (1s → 2s → … → 30s max), same pattern as
* useDatabaseRealtimeUpdates to stay consistent with the codebase.
*/
export function useSyncBlockRealtime(masterId: string | undefined): void {
const queryClient = useQueryClient();
const esRef = useRef<EventSource | null>(null);
const retryDelayRef = useRef<number>(1000);
const isMountedRef = useRef<boolean>(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
useEffect(() => {
if (!masterId) return;
const sseUrl = `/api/acadenice/sync-blocks/${encodeURIComponent(masterId)}/events`;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
function connect() {
if (!isMountedRef.current) return;
const es = new EventSource(sseUrl, { withCredentials: true });
esRef.current = es;
es.addEventListener("open", () => {
retryDelayRef.current = 1000;
});
es.addEventListener("sync-block.updated", () => {
queryClient.invalidateQueries({
queryKey: [SYNC_BLOCK_QUERY_KEY, masterId],
exact: true,
});
});
es.addEventListener("message", (evt) => {
try {
const payload = JSON.parse(evt.data) as { masterId?: string };
if (!payload.masterId || payload.masterId === masterId) {
queryClient.invalidateQueries({
queryKey: [SYNC_BLOCK_QUERY_KEY, masterId],
exact: true,
});
}
} catch {
// Unparseable SSE data — ignore.
}
});
es.addEventListener("error", () => {
es.close();
esRef.current = null;
if (!isMountedRef.current) return;
const delay = Math.min(retryDelayRef.current, 30_000);
retryDelayRef.current = Math.min(retryDelayRef.current * 2, 30_000);
retryTimeout = setTimeout(() => {
if (isMountedRef.current) connect();
}, delay);
});
}
connect();
return () => {
esRef.current?.close();
esRef.current = null;
if (retryTimeout !== null) clearTimeout(retryTimeout);
};
}, [masterId, queryClient]);
}