live-refresh.tsx97 lines · main
1'use client';
2
3import { useEffect } from 'react';
4import { useRouter } from 'next/navigation';
5
6const CHANNEL_NAME = 'briven:dashboard';
7const POLL_MS = 30_000;
8
9/**
10 * Real-time refresh plumbing for the dashboard. Mounted once in the
11 * dashboard layout so every page under /dashboard inherits the same
12 * revalidation cadence:
13 *
14 * 1. router.refresh() on window focus + visibilitychange (visible).
15 * 2. router.refresh() every 30s while the tab is visible.
16 * 3. BroadcastChannel listener — any form in any tab can call
17 * `notifyDashboardChange()` after a successful mutation; every open
18 * dashboard tab refreshes immediately.
19 *
20 * Control-plane reads stay server-rendered (RSC) — this layer just keeps
21 * them honest without forcing a manual reload. When the api grows an SSE
22 * endpoint we'll add that as a fourth trigger.
23 */
24export function LiveRefresh() {
25 const router = useRouter();
26
27 useEffect(() => {
28 let pollTimer: ReturnType<typeof setInterval> | null = null;
29
30 function refresh() {
31 router.refresh();
32 }
33
34 function startPolling() {
35 if (pollTimer) return;
36 pollTimer = setInterval(() => {
37 if (document.visibilityState === 'visible') refresh();
38 }, POLL_MS);
39 }
40
41 function stopPolling() {
42 if (pollTimer) {
43 clearInterval(pollTimer);
44 pollTimer = null;
45 }
46 }
47
48 function onVisibilityChange() {
49 if (document.visibilityState === 'visible') {
50 refresh();
51 startPolling();
52 } else {
53 stopPolling();
54 }
55 }
56
57 window.addEventListener('focus', refresh);
58 document.addEventListener('visibilitychange', onVisibilityChange);
59 if (document.visibilityState === 'visible') startPolling();
60
61 let channel: BroadcastChannel | null = null;
62 try {
63 channel = new BroadcastChannel(CHANNEL_NAME);
64 channel.onmessage = () => refresh();
65 } catch {
66 // older browsers without BroadcastChannel — silently skip cross-tab
67 }
68
69 return () => {
70 window.removeEventListener('focus', refresh);
71 document.removeEventListener('visibilitychange', onVisibilityChange);
72 stopPolling();
73 channel?.close();
74 };
75 }, [router]);
76
77 return null;
78}
79
80/**
81 * Call after a successful mutation that other dashboard pages may want
82 * to see. Posts on the BroadcastChannel; the `<LiveRefresh>` mounted on
83 * every dashboard route picks it up and triggers router.refresh().
84 *
85 * Safe to call from any client component — no-op on browsers without
86 * BroadcastChannel.
87 */
88export function notifyDashboardChange(): void {
89 if (typeof window === 'undefined') return;
90 try {
91 const channel = new BroadcastChannel(CHANNEL_NAME);
92 channel.postMessage({ at: Date.now() });
93 channel.close();
94 } catch {
95 // ignore
96 }
97}