Skip to content

Commit 34e9fb4

Browse files
authored
Merge pull request #41 from atomantic/better/dry
refactor: deduplicate SSE managers and localStorage sync patterns
2 parents 760ffda + 4a487f0 commit 34e9fb4

7 files changed

Lines changed: 132 additions & 130 deletions

File tree

client/src/context/SidebarContext.tsx

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
22
import type { DatabaseInfo } from '@fsf/shared';
33
import { api } from '../services/api';
4+
import { useLocalStorage } from '../hooks/useLocalStorage';
45

56
interface SidebarContextType {
67
isCollapsed: boolean;
@@ -18,23 +19,23 @@ interface SidebarContextType {
1819
const SidebarContext = createContext<SidebarContextType | null>(null);
1920

2021
export function SidebarProvider({ children }: { children: ReactNode }) {
21-
const [isCollapsed, setIsCollapsed] = useState(() => {
22-
const stored = localStorage.getItem('sidebar-collapsed');
23-
return stored === 'true';
24-
});
22+
const [isCollapsed, setIsCollapsed] = useLocalStorage(
23+
'sidebar-collapsed',
24+
false,
25+
String,
26+
(raw) => raw === 'true'
27+
);
2528
const [isMobileOpen, setIsMobileOpen] = useState(false);
26-
const [expandedDatabases, setExpandedDatabases] = useState<Set<string>>(() => {
27-
const stored = localStorage.getItem('sidebar-expanded-databases');
28-
return stored ? new Set(JSON.parse(stored)) : new Set();
29-
});
29+
const [expandedDatabases, setExpandedDatabases] = useLocalStorage<Set<string>>(
30+
'sidebar-expanded-databases',
31+
() => new Set(),
32+
(s) => JSON.stringify([...s]),
33+
(raw) => new Set(JSON.parse(raw))
34+
);
3035

3136
const toggleCollapsed = useCallback(() => {
32-
setIsCollapsed(prev => {
33-
const newValue = !prev;
34-
localStorage.setItem('sidebar-collapsed', String(newValue));
35-
return newValue;
36-
});
37-
}, []);
37+
setIsCollapsed(prev => !prev);
38+
}, [setIsCollapsed]);
3839

3940
const toggleMobile = useCallback(() => {
4041
setIsMobileOpen(prev => !prev);
@@ -52,20 +53,18 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
5253
} else {
5354
next.add(dbId);
5455
}
55-
localStorage.setItem('sidebar-expanded-databases', JSON.stringify([...next]));
5656
return next;
5757
});
58-
}, []);
58+
}, [setExpandedDatabases]);
5959

6060
const expandDatabase = useCallback((dbId: string) => {
6161
setExpandedDatabases(prev => {
6262
if (prev.has(dbId)) return prev;
6363
const next = new Set(prev);
6464
next.add(dbId);
65-
localStorage.setItem('sidebar-expanded-databases', JSON.stringify([...next]));
6665
return next;
6766
});
68-
}, []);
67+
}, [setExpandedDatabases]);
6968

7069
// Database state management
7170
const [databases, setDatabases] = useState<DatabaseInfo[]>([]);

client/src/context/ThemeContext.tsx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
1+
import { createContext, useContext, useCallback, useEffect, ReactNode } from 'react';
2+
import { useLocalStorage } from '../hooks/useLocalStorage';
23

34
type Theme = 'light' | 'dark';
45

@@ -11,15 +12,12 @@ interface ThemeContextType {
1112
const ThemeContext = createContext<ThemeContextType | null>(null);
1213

1314
export function ThemeProvider({ children }: { children: ReactNode }) {
14-
const [theme, setThemeState] = useState<Theme>(() => {
15-
const stored = localStorage.getItem('theme') as Theme | null;
16-
if (stored) return stored;
17-
// Default to dark, or use system preference
18-
if (window.matchMedia('(prefers-color-scheme: light)').matches) {
19-
return 'light';
20-
}
21-
return 'dark';
22-
});
15+
const [theme, setTheme] = useLocalStorage<Theme>(
16+
'theme',
17+
() => window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark',
18+
String,
19+
(raw) => raw as Theme
20+
);
2321

2422
// Apply theme class to document
2523
useEffect(() => {
@@ -31,14 +29,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
3129
}
3230
}, [theme]);
3331

34-
const setTheme = useCallback((newTheme: Theme) => {
35-
setThemeState(newTheme);
36-
localStorage.setItem('theme', newTheme);
37-
}, []);
38-
3932
const toggleTheme = useCallback(() => {
40-
setTheme(theme === 'dark' ? 'light' : 'dark');
41-
}, [theme, setTheme]);
33+
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
34+
}, [setTheme]);
4235

4336
return (
4437
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useState, useCallback } from 'react';
2+
3+
/**
4+
* Like useState, but persists to localStorage.
5+
* @param key localStorage key
6+
* @param fallback value when nothing is stored (or a factory function)
7+
* @param serialize convert value to string for storage (default: String)
8+
* @param deserialize convert stored string to value (default: identity cast)
9+
*/
10+
export function useLocalStorage<T>(
11+
key: string,
12+
fallback: T | (() => T),
13+
serialize: (v: T) => string = String as (v: T) => string,
14+
deserialize?: (raw: string) => T
15+
): [T, (value: T | ((prev: T) => T)) => void] {
16+
const [state, setState] = useState<T>(() => {
17+
const stored = localStorage.getItem(key);
18+
if (stored !== null && deserialize) {
19+
return deserialize(stored);
20+
}
21+
if (stored !== null) {
22+
return stored as unknown as T;
23+
}
24+
return typeof fallback === 'function' ? (fallback as () => T)() : fallback;
25+
});
26+
27+
const setValue = useCallback((value: T | ((prev: T) => T)) => {
28+
setState(prev => {
29+
const next = typeof value === 'function' ? (value as (prev: T) => T)(prev) : value;
30+
localStorage.setItem(key, serialize(next));
31+
return next;
32+
});
33+
}, [key, serialize]);
34+
35+
return [state, setValue];
36+
}

server/src/services/test-runner.service.ts

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { spawn, ChildProcess } from 'child_process';
22
import { Response } from 'express';
3-
import crypto from 'crypto';
43
import fs from 'fs';
54
import path from 'path';
65
import { fileURLToPath } from 'url';
6+
import { createSseManager } from '../utils/createSseManager.js';
77

88
const __filename = fileURLToPath(import.meta.url);
99
const __dirname = path.dirname(__filename);
@@ -20,23 +20,14 @@ interface TestRun {
2020
exitCode?: number;
2121
}
2222

23-
interface SSEClient {
24-
id: string;
25-
response: Response;
26-
}
27-
28-
// SSE clients for test events
29-
const clients: SSEClient[] = [];
23+
const sse = createSseManager('test-runner');
3024

3125
// Current test run state
3226
let currentRun: TestRun | null = null;
3327
let currentProcess: ChildProcess | null = null;
3428

3529
function broadcast(event: string, data: object) {
36-
const message = `event: ${event}\ndata: ${JSON.stringify({ data })}\n\n`;
37-
for (const { response } of clients) {
38-
response.write(message);
39-
}
30+
sse.broadcast(event, { data });
4031
}
4132

4233
function getTestCommand(type: TestType): { command: string; args: string[] } {
@@ -56,14 +47,7 @@ function getTestCommand(type: TestType): { command: string; args: string[] } {
5647

5748
export const testRunnerService = {
5849
addClient(response: Response): string {
59-
const id = crypto.randomUUID();
60-
response.writeHead(200, {
61-
'Content-Type': 'text/event-stream',
62-
'Cache-Control': 'no-cache',
63-
'Connection': 'keep-alive',
64-
});
65-
response.write(`data: ${JSON.stringify({ type: 'connected', clientId: id })}\n\n`);
66-
clients.push({ id, response });
50+
const id = sse.addClient(response);
6751

6852
// Send current status immediately
6953
if (currentRun) {
@@ -73,10 +57,7 @@ export const testRunnerService = {
7357
return id;
7458
},
7559

76-
removeClient(id: string) {
77-
const index = clients.findIndex(c => c.id === id);
78-
if (index !== -1) clients.splice(index, 1);
79-
},
60+
removeClient: sse.removeClient,
8061

8162
getStatus(): TestRun | null {
8263
return currentRun;
Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,14 @@
11
import { Response } from 'express';
2-
import crypto from 'crypto';
32
import type { BrowserStatus } from '../services/browser.service';
43
import { emitBrowserEvent } from '../services/socket.service.js';
4+
import { createSseManager } from './createSseManager.js';
55

6-
interface SSEClient {
7-
id: string;
8-
response: Response;
9-
}
10-
11-
const clients: SSEClient[] = [];
6+
const base = createSseManager('browser');
127
let lastStatus: BrowserStatus | null = null;
138

149
export const browserSseManager = {
1510
addClient(response: Response): string {
16-
const id = crypto.randomUUID();
17-
response.writeHead(200, {
18-
'Content-Type': 'text/event-stream',
19-
'Cache-Control': 'no-cache',
20-
'Connection': 'keep-alive'
21-
});
22-
response.write(`data: ${JSON.stringify({ type: 'connected', clientId: id })}\n\n`);
23-
clients.push({ id, response });
11+
const id = base.addClient(response);
2412

2513
// Send current status immediately if available
2614
if (lastStatus) {
@@ -31,34 +19,21 @@ export const browserSseManager = {
3119
return id;
3220
},
3321

34-
removeClient(id: string) {
35-
const index = clients.findIndex(c => c.id === id);
36-
if (index !== -1) clients.splice(index, 1);
37-
},
22+
removeClient: base.removeClient,
3823

3924
broadcast(event: string, data: object) {
4025
// Emit via Socket.IO (always, even if no SSE clients)
4126
emitBrowserEvent(event, data);
4227

43-
// Also send via SSE for backwards compatibility
44-
if (clients.length === 0) return;
45-
46-
const message = `event: ${event}\ndata: ${JSON.stringify({ data })}\n\n`;
47-
clients.forEach(({ response }) => {
48-
response.write(message);
49-
});
28+
// Also send via SSE for backwards compatibility — wrap in { data } envelope
29+
base.broadcast(event, { data });
5030
},
5131

5232
broadcastStatus(status: BrowserStatus) {
5333
lastStatus = status;
5434
this.broadcast('status', status);
5535
},
5636

57-
getClientCount(): number {
58-
return clients.length;
59-
},
60-
61-
hasClients(): boolean {
62-
return clients.length > 0;
63-
}
37+
getClientCount: base.getClientCount,
38+
hasClients: base.hasClients,
6439
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Response } from 'express';
2+
import crypto from 'crypto';
3+
4+
interface SSEClient {
5+
id: string;
6+
response: Response;
7+
}
8+
9+
export interface SseManager {
10+
addClient(response: Response): string;
11+
removeClient(id: string): void;
12+
broadcast(event: string, data: object): void;
13+
getClientCount(): number;
14+
hasClients(): boolean;
15+
}
16+
17+
export function createSseManager(name?: string): SseManager {
18+
const clients: SSEClient[] = [];
19+
20+
return {
21+
addClient(response: Response): string {
22+
const id = crypto.randomUUID();
23+
response.writeHead(200, {
24+
'Content-Type': 'text/event-stream',
25+
'Cache-Control': 'no-cache',
26+
'Connection': 'keep-alive',
27+
});
28+
response.write(`data: ${JSON.stringify({ type: 'connected', clientId: id })}\n\n`);
29+
clients.push({ id, response });
30+
return id;
31+
},
32+
33+
removeClient(id: string) {
34+
const index = clients.findIndex(c => c.id === id);
35+
if (index !== -1) clients.splice(index, 1);
36+
},
37+
38+
broadcast(event: string, data: object) {
39+
if (clients.length === 0) return;
40+
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
41+
for (const { response } of clients) {
42+
response.write(message);
43+
}
44+
},
45+
46+
getClientCount(): number {
47+
return clients.length;
48+
},
49+
50+
hasClients(): boolean {
51+
return clients.length > 0;
52+
},
53+
};
54+
}

server/src/utils/sseManager.ts

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,3 @@
1-
import { Response } from 'express';
2-
import crypto from 'crypto';
1+
import { createSseManager } from './createSseManager.js';
32

4-
interface SSEClient {
5-
id: string;
6-
response: Response;
7-
}
8-
9-
const clients: SSEClient[] = [];
10-
11-
export const sseManager = {
12-
addClient(response: Response): string {
13-
const id = crypto.randomUUID();
14-
response.writeHead(200, {
15-
'Content-Type': 'text/event-stream',
16-
'Cache-Control': 'no-cache',
17-
'Connection': 'keep-alive'
18-
});
19-
response.write(`data: ${JSON.stringify({ type: 'connected', clientId: id })}\n\n`);
20-
clients.push({ id, response });
21-
return id;
22-
},
23-
24-
removeClient(id: string) {
25-
const index = clients.findIndex(c => c.id === id);
26-
if (index !== -1) clients.splice(index, 1);
27-
},
28-
29-
broadcast(event: string, data: object) {
30-
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
31-
clients.forEach(({ response }) => {
32-
response.write(message);
33-
});
34-
},
35-
36-
getClientCount(): number {
37-
return clients.length;
38-
}
39-
};
3+
export const sseManager = createSseManager('sse');

0 commit comments

Comments
 (0)