fix(frontend): use hostname as stable server ID for favorites persistence
Favorites were reset on every page refresh because server IDs were generated from array index (index + 1), making them unstable across reloads. Additionally, a cleanup effect wiped favorites when the servers array was empty before data loaded. Changes: - Use Hostname (already unique and public) as server ID instead of index - Change BaseServer.id and favorites from number to string - Add dataLoaded guard to prevent cleanup effect from running before initial data arrives - Silently migrate old numeric favorites data to empty array Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Yechan Kim committed
Apr 7, 2026 at 22:10 UTC
85f54ce19ac252a547aaca2046486a5c1bb7b773
5 files changed
+25
-19
frontend/src/components/ServerCard.tsx
+2
-2
@@ -12,7 +12,7 @@ import {
12
import { Button } from "@/components/ui/button";
13
14
interface ServerCardProps {
15
- serverId: number;
15
+ serverId: string;
16
name: string;
17
description: string;
18
tags: string[];
@@ -24,7 +24,7 @@ interface ServerCardProps {
24
navigationPath: string;
25
navigationState: any;
26
isFavorite?: boolean;
27
- onToggleFavorite?: (serverId: number) => void;
27
+ onToggleFavorite?: (serverId: string) => void;
28
showAdminControls?: boolean;
29
identityKey?: string;
30
address?: string;
frontend/src/components/ServerListView.tsx
+2
-2
@@ -139,12 +139,12 @@ interface ServerListViewProps {
139
selectedTags: string[];
140
availableTags: string[];
141
filteredServers: ClientServer[] | AdminServer[];
142
- favorites: number[];
142
+ favorites: string[];
143
onSearchChange: (value: string) => void;
144
onStatusChange: (value: StatusFilter) => void;
145
onSortByChange: (value: SortOption) => void;
146
onTagToggle: (tag: string) => void;
147
- onToggleFavorite: (serverId: number) => void;
147
+ onToggleFavorite: (serverId: string) => void;
148
isAdmin?: boolean;
149
banFilter?: BanFilter;
150
approvalMode?: ApprovalMode;
frontend/src/hooks/useAdmin.ts
+2
-3
@@ -107,7 +107,6 @@ function resolveLeaseIdentity(
107
108
function toAdminServer(
109
row: AdminLeaseData,
110
- index: number
110
): AdminServer {
111
const metadata = parseLeaseMetadata(row.Metadata);
112
const hostname = row.Hostname || "";
@@ -115,7 +114,7 @@ function toAdminServer(
114
const address = row.address.trim();
115
116
return {
118
- id: index + 1,
117
+ id: hostname,
118
name: serviceName || hostname || "(unnamed)",
119
description: metadata.description,
120
tags: metadata.tags,
@@ -228,7 +227,7 @@ export function useAdmin() {
227
}, []);
228
229
const servers: AdminServer[] = useMemo(() => {
231
- return serverData.map((row, index) => toAdminServer(row, index));
230
+ return serverData.map((row) => toAdminServer(row));
231
}, [serverData]);
232
233
const additionalFilter = (server: AdminServer) => {
frontend/src/hooks/useList.ts
+17
-10
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
2
import type { SortOption, StatusFilter } from "@/types/filters";
3
4
export interface BaseServer {
5
- id: number;
5
+ id: string;
6
name: string;
7
description: string;
8
tags: string[];
@@ -26,17 +26,17 @@ export interface UseListReturn<T extends BaseServer> {
26
status: StatusFilter;
27
sortBy: SortOption;
28
selectedTags: string[];
29
- favorites: number[];
29
+ favorites: string[];
30
availableTags: string[];
31
filteredServers: T[];
32
handleSearchChange: (value: string) => void;
33
handleStatusChange: (value: StatusFilter) => void;
34
handleSortByChange: (value: SortOption) => void;
35
handleTagToggle: (tag: string) => void;
36
- handleToggleFavorite: (serverId: number) => void;
36
+ handleToggleFavorite: (serverId: string) => void;
37
}
38
39
-function readStoredFavorites(storageKey: string): number[] {
39
+function readStoredFavorites(storageKey: string): string[] {
40
let raw: string | null = null;
41
try {
42
raw = localStorage.getItem(storageKey);
@@ -55,7 +55,7 @@ function readStoredFavorites(storageKey: string): number[] {
55
}
56
return [...new Set(
57
parsed.filter(
58
- (value): value is number => Number.isInteger(value) && value > 0
58
+ (value): value is string => typeof value === "string" && value.length > 0
59
)
60
)];
61
} catch {
@@ -92,9 +92,10 @@ export function useList<T extends BaseServer>({
92
const [status, setStatus] = useState<StatusFilter>("all");
93
const [sortBy, setSortBy] = useState<SortOption>("duration");
94
const [selectedTags, setSelectedTags] = useState<string[]>([]);
95
- const [favorites, setFavorites] = useState<number[]>(() =>
95
+ const [favorites, setFavorites] = useState<string[]>(() =>
96
readStoredFavorites(storageKey)
97
);
98
+ const [dataLoaded, setDataLoaded] = useState(false);
99
100
useEffect(() => {
101
setFavorites(readStoredFavorites(storageKey));
@@ -126,12 +127,18 @@ export function useList<T extends BaseServer>({
127
}, [servers]);
128
129
useEffect(() => {
130
+ if (servers.length > 0) {
131
+ setDataLoaded(true);
132
+ }
133
+ if (!dataLoaded && servers.length === 0) {
134
+ return;
135
+ }
136
const validIDs = new Set(servers.map((server) => server.id));
137
setFavorites((prev) => {
138
const next = prev.filter((id) => validIDs.has(id));
139
return next.length === prev.length ? prev : next;
140
});
134
- }, [servers]);
141
+ }, [servers, dataLoaded]);
142
143
useEffect(() => {
144
const availableTagSet = new Set(availableTags);
@@ -197,9 +204,9 @@ export function useList<T extends BaseServer>({
204
case "owner":
205
return (a: T, b: T) => a.owner.localeCompare(b.owner);
206
case "default":
200
- return (a: T, b: T) => a.id - b.id;
207
+ return (a: T, b: T) => a.id.localeCompare(b.id);
208
default:
202
- return (a: T, b: T) => a.id - b.id;
209
+ return (a: T, b: T) => a.id.localeCompare(b.id);
210
}
211
};
212
@@ -240,7 +247,7 @@ export function useList<T extends BaseServer>({
247
);
248
}, []);
249
243
- const handleToggleFavorite = useCallback((serverId: number) => {
250
+ const handleToggleFavorite = useCallback((serverId: string) => {
251
setFavorites((prev) =>
252
prev.includes(serverId)
253
? prev.filter((id) => id !== serverId)
frontend/src/hooks/useServerList.ts
+2
-2
@@ -7,13 +7,13 @@ import { parseLeaseMetadata } from "@/lib/metadata";
7
export type ClientServer = BaseServer;
8
9
function convertSSRDataToServers(ssrData: PublicLeaseData[]): ClientServer[] {
10
- return ssrData.map((row, index) => {
10
+ return ssrData.map((row) => {
11
const metadata = parseLeaseMetadata(row.Metadata);
12
const hostname = row.Hostname || "";
13
const serviceName = row.name || "";
14
15
return {
16
- id: index + 1,
16
+ id: hostname,
17
name: serviceName || hostname || "(unnamed)",
18
description: metadata.description || "",
19
tags: metadata.tags,