fix: restore target param in expose-name hashing and fix React hook regressions

Three blocking issues from code review: 1. DefaultExposeName dropped the target parameter when moved to utils/identity.go, breaking frontend-backend name parity. Restore target threading through ResolveListenerIdentity and align the backend to FNV-1a (matching the frontend algorithm). Add normalizeExposeTarget as a Go port of the frontend logic. 2. useTunnelCommand.ts wrapped readTunnelNameSeed (which writes to localStorage) in useMemo — a side-effect in a pure-computation hook. Revert to useState. 3. useList.ts setFavorites/setSelectedTags always returned new arrays from filter(), breaking referential stability and causing unnecessary re-renders. Restore length-based equality guards. Cross-language parity tests added for expose-name generation (5 vectors validated in both Go and TypeScript).

cognitive committed Apr 4, 2026 at 14:37 UTC b28589961e7192c02c0c00f758b58c0b93955c44
6 files changed +133 -9
frontend/src/hooks/useList.ts
+8 -2
@@ -127,12 +127,18 @@ export function useList<T extends BaseServer>({
127
128 useEffect(() => {
129 const validIDs = new Set(servers.map((server) => server.id));
130 - setFavorites((prev) => prev.filter((id) => validIDs.has(id)));
130 + setFavorites((prev) => {
131 + const next = prev.filter((id) => validIDs.has(id));
132 + return next.length === prev.length ? prev : next;
133 + });
134 }, [servers]);
135
136 useEffect(() => {
137 const availableTagSet = new Set(availableTags);
135 - setSelectedTags((prev) => prev.filter((tag) => availableTagSet.has(tag)));
138 + setSelectedTags((prev) => {
139 + const next = prev.filter((tag) => availableTagSet.has(tag));
140 + return next.length === prev.length ? prev : next;
141 + });
142 }, [availableTags]);
143
144 const filteredServers = useMemo(() => {
frontend/src/hooks/useTunnelCommand.ts
+1 -1
@@ -76,7 +76,7 @@ interface TunnelCommandExtras {
76
77 export function useTunnelCommand(extras: TunnelCommandExtras = {}) {
78 const currentOrigin = useMemo(() => readCurrentOrigin(), []);
79 - const nameSeed = useMemo(() => readTunnelNameSeed(), []);
79 + const [nameSeed] = useState(readTunnelNameSeed);
80
81 const [target, setTarget] = useState(DEFAULT_HOST);
82 const [name, setName] = useState("");
frontend/src/lib/exposeName.test.ts new
+20
@@ -0,0 +1,20 @@
1 +import { describe, expect, it } from "vitest";
2 +import { buildDefaultExposeName } from "@/lib/exposeName";
3 +
4 +// Cross-language parity vectors -- keep in sync with utils/identity_test.go
5 +const exposeNameVectors = [
6 + { target: "3000", seed: "test_seed", expected: "bubble-cricket-beacon" },
7 + { target: "", seed: "portal", expected: "zesty-beacon-sketch" },
8 + { target: "http://localhost:8080", seed: "cli_abc", expected: "sprightly-rocket-zap" },
9 + { target: "192.168.1.1:8080", seed: "web_xyz", expected: "velvet-yeti-march" },
10 + { target: "localhost", seed: "cli_", expected: "misty-rocket-ripple" },
11 +] as const;
12 +
13 +describe("buildDefaultExposeName", () => {
14 + it.each(exposeNameVectors)(
15 + "generates $expected for target=$target seed=$seed",
16 + ({ target, seed, expected }) => {
17 + expect(buildDefaultExposeName(target, seed)).toBe(expected);
18 + },
19 + );
20 +});
sdk/expose.go
+1
@@ -69,6 +69,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
69
70 identity, createdIdentity, err := utils.ResolveListenerIdentity(
71 types.Identity{Name: cfg.Name},
72 + cfg.TargetAddr,
73 cfg.IdentityPath,
74 cfg.IdentityJSON,
75 )
utils/identity.go
+67 -6
@@ -4,6 +4,8 @@ import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 + "net"
8 + "net/url"
9 "os"
10 "strings"
11
@@ -198,10 +200,10 @@ func LoadOrCreateIdentity(path string, identity types.Identity) (types.Identity,
200 return loaded, true, nil
201 }
202
201 -func ResolveListenerIdentity(identity types.Identity, identityPath, identityJSON string) (types.Identity, bool, error) {
203 +func ResolveListenerIdentity(identity types.Identity, target, identityPath, identityJSON string) (types.Identity, bool, error) {
204 identityPath = strings.TrimSpace(identityPath)
205 identityJSON = strings.TrimSpace(identityJSON)
204 - resolvedName, err := resolveExposeName(identity.Name, identityPath, identityJSON)
206 + resolvedName, err := resolveExposeName(identity.Name, target, identityPath, identityJSON)
207 if err != nil {
208 return types.Identity{}, false, err
209 }
@@ -336,7 +338,15 @@ var exposeNameClosers = []string{
338 "whirl", "wink", "zap", "zenith", "zip", "zoom", "zest", "zone",
339 }
340
339 -func DefaultExposeName(rawSeed string) (string, error) {
341 +const (
342 + defaultExposeTargetPort = "3000"
343 + defaultExposeTargetHost = "127.0.0.1"
344 +)
345 +
346 +// DefaultExposeName generates a deterministic 3-word DNS label from a target
347 +// address and seed using FNV-1a hashing. The algorithm matches the frontend
348 +// implementation in frontend/src/lib/exposeName.ts:buildDefaultExposeName.
349 +func DefaultExposeName(target, rawSeed string) (string, error) {
350 seed := strings.TrimSpace(rawSeed)
351 if cut, ok := strings.CutPrefix(seed, "cli_"); ok {
352 seed = cut
@@ -345,7 +355,7 @@ func DefaultExposeName(rawSeed string) (string, error) {
355 seed = "portal"
356 }
357
348 - input := []byte(seed)
358 + input := []byte(seed + "|" + normalizeExposeTarget(target))
359 first := fnv1a32(input, 0x811c9dc5)
360 second := fnv1a32(input, 0x9e3779b9)
361 third := fnv1a32(input, 0x85ebca6b)
@@ -359,7 +369,58 @@ func DefaultExposeName(rawSeed string) (string, error) {
369 return NormalizeDNSLabel(label)
370 }
371
362 -func resolveExposeName(name, identityPath, identityJSON string) (string, error) {
372 +// normalizeExposeTarget normalizes a target address for deterministic name
373 +// generation. Must match frontend/src/lib/exposeName.ts:normalizeExposeTarget.
374 +func normalizeExposeTarget(raw string) string {
375 + trimmed := strings.TrimSpace(raw)
376 + candidate := trimmed
377 + if candidate == "" {
378 + candidate = defaultExposeTargetPort
379 + }
380 +
381 + if isAllDigits(candidate) {
382 + return defaultExposeTargetHost + ":" + candidate
383 + }
384 +
385 + if strings.Contains(candidate, "://") {
386 + u, err := url.Parse(candidate)
387 + if err != nil {
388 + return candidate
389 + }
390 + if (u.Scheme == "http" || u.Scheme == "https") &&
391 + u.Host != "" &&
392 + (u.Path == "" || u.Path == "/") &&
393 + u.RawQuery == "" &&
394 + u.Fragment == "" {
395 + return u.Host
396 + }
397 + return candidate
398 + }
399 +
400 + u, err := url.Parse("tcp://" + candidate)
401 + if err != nil || u.Hostname() == "" {
402 + return candidate
403 + }
404 + port := u.Port()
405 + if port == "" {
406 + port = "80"
407 + }
408 + return net.JoinHostPort(u.Hostname(), port)
409 +}
410 +
411 +func isAllDigits(s string) bool {
412 + if s == "" {
413 + return false
414 + }
415 + for _, c := range s {
416 + if c < '0' || c > '9' {
417 + return false
418 + }
419 + }
420 + return true
421 +}
422 +
423 +func resolveExposeName(name, target, identityPath, identityJSON string) (string, error) {
424 if name = strings.TrimSpace(name); name != "" {
425 return name, nil
426 }
@@ -384,7 +445,7 @@ func resolveExposeName(name, identityPath, identityJSON string) (string, error)
445 }
446 }
447
387 - return DefaultExposeName(RandomID("cli_"))
448 + return DefaultExposeName(target, RandomID("cli_"))
449 }
450
451 func fnv1a32(data []byte, seed uint32) uint32 {
utils/identity_test.go new
+36
@@ -0,0 +1,36 @@
1 +package utils
2 +
3 +import "testing"
4 +
5 +// Cross-language parity vectors -- keep in sync with frontend/src/lib/exposeName.test.ts
6 +var exposeNameVectors = []struct {
7 + target, seed, expectedNormalized, expectedName string
8 +}{
9 + {"3000", "test_seed", "127.0.0.1:3000", "bubble-cricket-beacon"},
10 + {"", "portal", "127.0.0.1:3000", "zesty-beacon-sketch"},
11 + {"http://localhost:8080", "cli_abc", "localhost:8080", "sprightly-rocket-zap"},
12 + {"192.168.1.1:8080", "web_xyz", "192.168.1.1:8080", "velvet-yeti-march"},
13 + {"localhost", "cli_", "localhost:80", "misty-rocket-ripple"},
14 +}
15 +
16 +func TestNormalizeExposeTarget(t *testing.T) {
17 + for _, v := range exposeNameVectors {
18 + got := normalizeExposeTarget(v.target)
19 + if got != v.expectedNormalized {
20 + t.Errorf("normalizeExposeTarget(%q) = %q, want %q", v.target, got, v.expectedNormalized)
21 + }
22 + }
23 +}
24 +
25 +func TestDefaultExposeName(t *testing.T) {
26 + for _, v := range exposeNameVectors {
27 + got, err := DefaultExposeName(v.target, v.seed)
28 + if err != nil {
29 + t.Errorf("DefaultExposeName(%q, %q) error: %v", v.target, v.seed, err)
30 + continue
31 + }
32 + if got != v.expectedName {
33 + t.Errorf("DefaultExposeName(%q, %q) = %q, want %q", v.target, v.seed, got, v.expectedName)
34 + }
35 + }
36 +}