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>
97 lines
2.9 KiB
TypeScript
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]);
|
|
}
|