Skip to content

Commit cb3fbc7

Browse files
committed
feat: add metadata HMR reloader for dev console
1 parent df18ae9 commit cb3fbc7

2 files changed

Lines changed: 122 additions & 0 deletions

File tree

apps/console/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { AccountLoginRedirect } from './components/AccountLoginRedirect';
2828
import { CloudAwareRootRedirect } from './components/CloudAwareRootRedirect';
2929
import { FormPage } from './components/FormPage';
30+
import { MetadataHmrReloader } from './components/MetadataHmrReloader';
3031
import {
3132
gotoAccountLogin,
3233
gotoAccountRegister,
@@ -164,6 +165,7 @@ export function App() {
164165
<AuthProvider authUrl={AUTH_URL}>
165166
<UploadProvider adapter={uploadAdapter}>
166167
<Toaster position="bottom-right" />
168+
<MetadataHmrReloader />
167169
<BrowserRouter basename={BASENAME}>
168170
<ConsoleShell>
169171
<Routes>
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* MetadataHmrReloader
5+
*
6+
* Dev-only component. Subscribes to the server's metadata-events SSE
7+
* stream and triggers `location.reload()` (debounced) whenever any
8+
* metadata file changes on disk.
9+
*
10+
* Why a full reload?
11+
* Studio owns its metadata-fetching layer and can invalidate granular
12+
* caches via `useMetadataHmr` + custom `subscribe(...)` listeners.
13+
* The runtime Console, by contrast, leans entirely on
14+
* `@object-ui/app-shell` and the `@object-ui/plugin-*` packs for
15+
* data loading — their caches are not externally invalidatable. A
16+
* debounced page reload is the simplest reliable strategy in dev.
17+
*
18+
* Mount-time gating
19+
* - `enabled` defaults to `import.meta.env.DEV` so production builds
20+
* never run this component.
21+
* - SSR-safe: no-op when `window`/`EventSource` are unavailable.
22+
*/
23+
24+
import { useEffect, useRef } from 'react';
25+
import { toast } from 'sonner';
26+
27+
export interface MetadataHmrReloaderProps {
28+
/** Toggle to force-disable. Defaults to `import.meta.env.DEV`. */
29+
enabled?: boolean;
30+
/** SSE endpoint. Defaults to the standard dev route. */
31+
url?: string;
32+
/** Debounce window in ms — coalesces bursts from one edit. */
33+
debounceMs?: number;
34+
/** Reconnect delay after the connection drops. */
35+
reconnectDelayMs?: number;
36+
}
37+
38+
export function MetadataHmrReloader({
39+
enabled = (import.meta as any).env?.DEV ?? false,
40+
url = '/api/v1/dev/metadata-events',
41+
debounceMs = 400,
42+
reconnectDelayMs = 2000,
43+
}: MetadataHmrReloaderProps) {
44+
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
45+
46+
useEffect(() => {
47+
if (!enabled) return;
48+
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return;
49+
50+
let es: EventSource | null = null;
51+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
52+
let cancelled = false;
53+
54+
const scheduleReload = (reason: string) => {
55+
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
56+
reloadTimerRef.current = setTimeout(() => {
57+
try {
58+
toast.info(`Metadata changed (${reason}) — reloading…`, { duration: 800 });
59+
} catch { /* toaster may be unmounted */ }
60+
// Small extra delay so the toast paints before navigation.
61+
setTimeout(() => {
62+
try { window.location.reload(); } catch { /* noop */ }
63+
}, 150);
64+
}, debounceMs);
65+
};
66+
67+
const onChange = (event: MessageEvent<string>) => {
68+
try {
69+
const data = JSON.parse(event.data) as {
70+
metadataType?: string;
71+
name?: string;
72+
};
73+
const label = data?.name
74+
? `${data.metadataType ?? 'metadata'}:${data.name}`
75+
: 'metadata';
76+
scheduleReload(label);
77+
} catch {
78+
scheduleReload('change');
79+
}
80+
};
81+
82+
const onReload = (event: MessageEvent<string>) => {
83+
let reason = 'rebuild';
84+
try {
85+
const data = JSON.parse(event.data) as { reason?: string };
86+
reason = data?.reason ?? reason;
87+
} catch { /* tolerate */ }
88+
scheduleReload(reason);
89+
};
90+
91+
const connect = () => {
92+
if (cancelled) return;
93+
try {
94+
es = new EventSource(url);
95+
es.addEventListener('metadata-change', onChange as EventListener);
96+
es.addEventListener('reload', onReload as EventListener);
97+
es.addEventListener('error', () => {
98+
if (cancelled) return;
99+
if (es?.readyState === EventSource.CLOSED) {
100+
es = null;
101+
retryTimer = setTimeout(connect, reconnectDelayMs);
102+
}
103+
});
104+
} catch {
105+
retryTimer = setTimeout(connect, reconnectDelayMs);
106+
}
107+
};
108+
109+
connect();
110+
111+
return () => {
112+
cancelled = true;
113+
if (retryTimer) clearTimeout(retryTimer);
114+
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
115+
if (es) { try { es.close(); } catch { /* noop */ } }
116+
};
117+
}, [enabled, url, debounceMs, reconnectDelayMs]);
118+
119+
return null;
120+
}

0 commit comments

Comments
 (0)