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,