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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -136,3 +136,20 @@ func TestApplyBadUUID(t *testing.T) {
|
|||||||
t.Fatalf("expected 400 for bad uuid, got %d", w.Code)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+9
-1
@@ -72,7 +72,15 @@ func toRecordView(d diff.RecordDiff) recordView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toChangesetResponse(cs diff.Changeset) changesetResponse {
|
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() {
|
for _, d := range cs.Updates() {
|
||||||
resp.Updates = append(resp.Updates, toRecordView(d))
|
resp.Updates = append(resp.Updates, toRecordView(d))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,17 @@ test("marks read-only records", () => {
|
|||||||
render(<DiffView changeset={cs} />)
|
render(<DiffView changeset={cs} />)
|
||||||
expect(screen.getByText(/NS/)).toBeInTheDocument()
|
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(<DiffView changeset={nullish} />)
|
||||||
|
expect(screen.getByText(/5/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/in sync/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|||||||
@@ -137,11 +137,13 @@ export function DiffView({
|
|||||||
changeset: ChangesetResponse
|
changeset: ChangesetResponse
|
||||||
footerExtra?: ReactNode
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Section tone="update" records={changeset.updates} />
|
<Section tone="update" records={changeset.updates ?? []} />
|
||||||
<Section tone="delete" records={changeset.prunes} />
|
<Section tone="delete" records={changeset.prunes ?? []} />
|
||||||
<Section tone="readonly" records={changeset.readOnly} />
|
<Section tone="readonly" records={changeset.readOnly ?? []} />
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export function DomainDiffPage() {
|
|||||||
const pruneCheckboxId = useId()
|
const pruneCheckboxId = useId()
|
||||||
|
|
||||||
const changeset = check.data
|
const changeset = check.data
|
||||||
const hasPrunes = (changeset?.prunes.length ?? 0) > 0
|
const hasPrunes = (changeset?.prunes?.length ?? 0) > 0
|
||||||
const hasUpdates = (changeset?.updates.length ?? 0) > 0
|
const hasUpdates = (changeset?.updates?.length ?? 0) > 0
|
||||||
const pruneWarning = applyPrunes && hasPrunes
|
const pruneWarning = applyPrunes && hasPrunes
|
||||||
const recordList = zoneRecords.data ?? []
|
const recordList = zoneRecords.data ?? []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user