From c16b2c3ad5e48fb3f5935ba9d444d882f53ef611 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 30 Sep 2026 11:21:59 -0400 Subject: [PATCH] fix(nodes): clear a node's position when nodeUpdate sends null The server now nulls a location on an explicit 0/0 advert and the WS nodeUpdate event mirrors that: omitted lat/lng keeps the current position, null clears it. patchNodeSummary used ?? so a clear was ignored. --- src/api/client.ts | 2 +- src/features/nodes/node-updates.ts | 4 ++-- src/types/ws.ts | 6 +++--- tests/features/nodes/node-updates.test.ts | 19 +++++++++++++++++++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index bfdd2fed..e375f6cf 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -233,7 +233,7 @@ export function getTraceDetail(tag: string): Promise { export async function getObserver(observerId: string): Promise { const observer = await request(`/observers/${observerId}`); - // The server marshals the stored JSON byte slice as base64; decode it, and accept an object once json.RawMessage ships. + // Servers before json.RawMessage (and cache entries written by them) send the stored JSON as base64. let metadata: unknown = observer.statusMetadata; if (typeof metadata === "string") { try { metadata = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(metadata), c => c.charCodeAt(0)))); } diff --git a/src/features/nodes/node-updates.ts b/src/features/nodes/node-updates.ts index 138ef5cf..578c00f0 100644 --- a/src/features/nodes/node-updates.ts +++ b/src/features/nodes/node-updates.ts @@ -18,8 +18,8 @@ export function patchNodeSummary( // only name/coords move in practice; nodeType/iatas are near-static, so we drop data.nodeType here // and let a reload carry a rare type change rather than keep a numeric-type lookup in sync const name = data.name || prev.name; - const lat = data.lat ?? prev.lat; - const lng = data.lng ?? prev.lng; + const lat = data.lat === undefined ? prev.lat : data.lat; + const lng = data.lng === undefined ? prev.lng : data.lng; const possiblyForeign = data.possiblyForeign === undefined ? prev.possiblyForeign : data.possiblyForeign ?? undefined; // a re-advert that re-sends the same values must keep the SAME ref so patchInfinitePages no-ops // (otherwise an unchanged node would trigger a full map FeatureCollection rebuild + setData) diff --git a/src/types/ws.ts b/src/types/ws.ts index eafdced8..bd933550 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -110,9 +110,9 @@ export interface WsNodeUpdate { // Omitted leaves the prior verdict unchanged; null clears an unknown/reset position. possiblyForeign?: boolean | null; iata: string; - // decimal degrees, same as REST /nodes (api/nodes.go serializes *float64 degrees to both) - lat?: number; - lng?: number; + // decimal degrees as in REST /nodes; omitted keeps the prior position, null clears it (explicit 0/0 advert) + lat?: number | null; + lng?: number | null; isObserver: boolean; iatas: NodeIATA[]; defaultScope?: string; diff --git a/tests/features/nodes/node-updates.test.ts b/tests/features/nodes/node-updates.test.ts index 93013dea..bc42afb3 100644 --- a/tests/features/nodes/node-updates.test.ts +++ b/tests/features/nodes/node-updates.test.ts @@ -68,6 +68,19 @@ describe("patchNodeSummary", () => { expect(out[0]!.lng).toBe(20); }); + it("clears the position when the update sends explicit null", () => { + const list = [node({ id: "a", lat: 10, lng: 20 })]; + const out = patchNodeSummary(list, update({ nodeId: "a", lat: null, lng: null }))!; + expect(out).not.toBe(list); + expect(out[0]!.lat).toBeNull(); + expect(out[0]!.lng).toBeNull(); + }); + + it("returns the same ref when a null clear hits a node that is already unlocated", () => { + const list = [node({ id: "a", name: "Keep", lat: null, lng: null })]; + expect(patchNodeSummary(list, update({ nodeId: "a", name: "Keep", lat: null, lng: null }))).toBe(list); + }); + it("returns the same list ref when the update changes nothing (no needless repaint)", () => { const list = [node({ id: "a", name: "Keep", lat: 10, lng: 20 })]; // re-advert with identical name + omitted coords: every field resolves back to the prev value @@ -114,4 +127,10 @@ describe("upsertNodePages", () => { it("passes undefined caches through", () => { expect(upsertNodePages(undefined, update({}))).toBeUndefined(); }); + + it("inserts a fresh node with an explicit null position", () => { + const old = pages([node({ id: "a" })]); + const out = upsertNodePages(old, update({ nodeId: "new1", lat: null, lng: null }))!; + expect(out.pages[0]!.items[1]).toMatchObject({ id: "new1", lat: null, lng: null }); + }); });