From bc2e77ad4efda553249f26d0e04a61f3bad332b1 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 5 Jul 2026 13:10:08 +0700 Subject: [PATCH] fix: empty changeset must serialize as [] not null (white-screen after snapshot) toChangesetResponse initialises updates/prunes/readOnly so a zone matching its template exactly (e.g. right after 'create template from zone') marshals arrays, not null. DiffView/DomainDiffPage also normalise null defensively. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- internal/api/api_test.go | 17 +++++++++++++++++ internal/api/dto.go | 10 +++++++++- web/src/components/DiffView.test.tsx | 14 ++++++++++++++ web/src/components/DiffView.tsx | 8 +++++--- web/src/pages/DomainDiffPage.tsx | 4 ++-- 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 1002bd2..3c55cd1 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -136,3 +136,20 @@ func TestApplyBadUUID(t *testing.T) { t.Fatalf("expected 400 for bad uuid, got %d", w.Code) } } + +// TestChangesetResponseEmptyMarshalsToArrays guards the белый-экран bug: an +// empty changeset (zone matches its template exactly, e.g. right after a +// snapshot) must marshal updates/prunes/readOnly as [] not null — a nil slice +// becomes JSON null and crashes the client's .length/.map calls. +func TestChangesetResponseEmptyMarshalsToArrays(t *testing.T) { + b, err := json.Marshal(toChangesetResponse(diff.Changeset{})) + if err != nil { + t.Fatal(err) + } + s := string(b) + for _, want := range []string{`"updates":[]`, `"prunes":[]`, `"readOnly":[]`} { + if !strings.Contains(s, want) { + t.Fatalf("expected %s in %s", want, s) + } + } +} diff --git a/internal/api/dto.go b/internal/api/dto.go index b127976..ddef2dd 100644 --- a/internal/api/dto.go +++ b/internal/api/dto.go @@ -72,7 +72,15 @@ func toRecordView(d diff.RecordDiff) recordView { } func toChangesetResponse(cs diff.Changeset) changesetResponse { - resp := changesetResponse{} + // Initialise the slices so an empty changeset (e.g. a zone that exactly + // matches its template right after a snapshot) marshals to [] rather than + // null — a nil slice becomes JSON null, which crashes clients that call + // .length/.map on the field. + resp := changesetResponse{ + Updates: []recordView{}, + Prunes: []recordView{}, + ReadOnly: []recordView{}, + } for _, d := range cs.Updates() { resp.Updates = append(resp.Updates, toRecordView(d)) } diff --git a/web/src/components/DiffView.test.tsx b/web/src/components/DiffView.test.tsx index 50bba97..6d58953 100644 --- a/web/src/components/DiffView.test.tsx +++ b/web/src/components/DiffView.test.tsx @@ -26,3 +26,17 @@ test("marks read-only records", () => { render() expect(screen.getByText(/NS/)).toBeInTheDocument() }) + +test("does not crash when changeset fields are null", () => { + // An empty changeset from an older/edge backend can arrive with null slices + // instead of []. DiffView must normalise them, not blow up on .length/.map. + const nullish = { + updates: null, + prunes: null, + readOnly: null, + inSyncCount: 5, + } as unknown as ChangesetResponse + render() + expect(screen.getByText(/5/)).toBeInTheDocument() + expect(screen.getByText(/in sync/)).toBeInTheDocument() +}) diff --git a/web/src/components/DiffView.tsx b/web/src/components/DiffView.tsx index 6d2983a..813170f 100644 --- a/web/src/components/DiffView.tsx +++ b/web/src/components/DiffView.tsx @@ -137,11 +137,13 @@ export function DiffView({ changeset: ChangesetResponse footerExtra?: ReactNode }) { + // Defensive: a field may arrive as null (e.g. a nil slice from an older + // backend) — normalise to [] so Section never calls .length/.map on null. return (
-
-
-
+
+
+
diff --git a/web/src/pages/DomainDiffPage.tsx b/web/src/pages/DomainDiffPage.tsx index 6c179b2..9bc49ff 100644 --- a/web/src/pages/DomainDiffPage.tsx +++ b/web/src/pages/DomainDiffPage.tsx @@ -41,8 +41,8 @@ export function DomainDiffPage() { const pruneCheckboxId = useId() const changeset = check.data - const hasPrunes = (changeset?.prunes.length ?? 0) > 0 - const hasUpdates = (changeset?.updates.length ?? 0) > 0 + const hasPrunes = (changeset?.prunes?.length ?? 0) > 0 + const hasUpdates = (changeset?.updates?.length ?? 0) > 0 const pruneWarning = applyPrunes && hasPrunes const recordList = zoneRecords.data ?? []