From 7eff28053a4cea259d9a7d0400d6984f60271d73 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Thu, 2 Jul 2026 09:04:33 +0700 Subject: [PATCH] fix(api): return empty arrays not null for empty lists (frontend null.length crash) Root cause: store List* methods used 'var out []T' which stays nil on empty result sets and serializes to JSON null; the SPA then crashed on .length/.map (e.g. endpoints.length on the Tasks page right after deploy). Return []T{} at the source; coerce null->[] on the frontend load sites as defense-in-depth. Regression test asserts List* are non-nil when empty. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd --- internal/store/accounts.go | 2 +- internal/store/emptylist_test.go | 38 ++++++++++++++++++++++++++++++++ internal/store/endpoints.go | 2 +- internal/store/tasks.go | 2 +- web/src/pages/Endpoints.tsx | 2 +- web/src/pages/Tasks.tsx | 4 ++-- 6 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 internal/store/emptylist_test.go diff --git a/internal/store/accounts.go b/internal/store/accounts.go index 80041d6..86bdfd5 100644 --- a/internal/store/accounts.go +++ b/internal/store/accounts.go @@ -38,7 +38,7 @@ func (s *Store) ListAccountsByTask(ctx context.Context, taskID int64) ([]Account return nil, err } defer rows.Close() - var out []Account + out := []Account{} for rows.Next() { var a Account if err := rows.Scan(&a.ID, &a.TaskID, &a.SrcLogin, &a.SrcPassEnc, &a.DstLogin, &a.DstPassEnc, diff --git a/internal/store/emptylist_test.go b/internal/store/emptylist_test.go new file mode 100644 index 0000000..afb0cf8 --- /dev/null +++ b/internal/store/emptylist_test.go @@ -0,0 +1,38 @@ +package store + +import ( + "context" + "testing" +) + +// Empty lists must serialize as JSON [] (non-nil slice), not null, +// otherwise the frontend crashes on `.length`/`.map`. Regression for +// "Uncaught TypeError: can't access property length, n is null". +func TestListsNeverNilWhenEmpty(t *testing.T) { + s := testStore(t) + ctx := context.Background() + + eps, err := s.ListEndpoints(ctx) + if err != nil { + t.Fatalf("ListEndpoints: %v", err) + } + if eps == nil { + t.Fatal("ListEndpoints returned nil slice on empty DB (would serialize as null)") + } + + tasks, err := s.ListTasks(ctx) + if err != nil { + t.Fatalf("ListTasks: %v", err) + } + if tasks == nil { + t.Fatal("ListTasks returned nil slice on empty DB (would serialize as null)") + } + + accs, err := s.ListAccountsByTask(ctx, 999999) + if err != nil { + t.Fatalf("ListAccountsByTask: %v", err) + } + if accs == nil { + t.Fatal("ListAccountsByTask returned nil slice for no rows (would serialize as null)") + } +} diff --git a/internal/store/endpoints.go b/internal/store/endpoints.go index aa08f07..e0b26ad 100644 --- a/internal/store/endpoints.go +++ b/internal/store/endpoints.go @@ -34,7 +34,7 @@ func (s *Store) ListEndpoints(ctx context.Context) ([]Endpoint, error) { return nil, err } defer rows.Close() - var out []Endpoint + out := []Endpoint{} for rows.Next() { var e Endpoint if err := rows.Scan(&e.ID, &e.RoleLabel, &e.Host, &e.Port, &e.TLSMode); err != nil { diff --git a/internal/store/tasks.go b/internal/store/tasks.go index f4b13d3..c6e07ba 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -40,7 +40,7 @@ func (s *Store) ListTasks(ctx context.Context) ([]Task, error) { return nil, err } defer rows.Close() - var out []Task + out := []Task{} for rows.Next() { var t Task if err := rows.Scan(&t.ID, &t.Name, &t.SrcEndpointID, &t.DstEndpointID, &t.Status, &t.FolderMapping); err != nil { diff --git a/web/src/pages/Endpoints.tsx b/web/src/pages/Endpoints.tsx index 82c08a9..3f2d2c4 100644 --- a/web/src/pages/Endpoints.tsx +++ b/web/src/pages/Endpoints.tsx @@ -11,7 +11,7 @@ export function Endpoints() { function reload() { listEndpoints() - .then(setEndpoints) + .then((e) => setEndpoints(e ?? [])) .catch((e) => setError(String(e.message || e))) } diff --git a/web/src/pages/Tasks.tsx b/web/src/pages/Tasks.tsx index d534480..b972a70 100644 --- a/web/src/pages/Tasks.tsx +++ b/web/src/pages/Tasks.tsx @@ -13,13 +13,13 @@ export function Tasks() { function reload() { listTasks() - .then(setTasks) + .then((t) => setTasks(t ?? [])) .catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load tasks')) } useEffect(() => { reload() - listEndpoints().then(setEndpoints).catch(() => {}) + listEndpoints().then((e) => setEndpoints(e ?? [])).catch(() => {}) }, []) async function submit(e: FormEvent) {