From ad97c0fdd16db25088278fdd86007daee5a66f1e Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Thu, 2 Apr 2026 19:47:17 -0700 Subject: [PATCH] fix: clear stale parsed cache on observation packets (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #504 — Expanding a packet in the packets UI showed the same path on every observation instead of each observation's unique path. ## Root Cause PR #400 (fixing #387) added caching of `JSON.parse` results as `_parsedPath` and `_parsedDecoded` properties on packet objects. When observation packets are created via object spread (`{...parentPacket, ...obs}`), these cache properties are copied from the parent. Subsequent calls to `getParsedPath(obsPacket)` hit the stale cache and return the parent's path, ignoring the observation's own `path_json`. ## Fix After every object spread that creates an observation packet from a parent packet, delete the cache properties so they get re-parsed from the observation's own data: ```js delete obsPacket._parsedPath; delete obsPacket._parsedDecoded; ``` Applied to all 5 spread sites in `public/packets.js`: - Line 271: detail pane observation selection - Line 504: flat view observation expansion - Line 840: grouped view observation expansion - Line 1012: child observation selection in grouped view - Line 1982: WebSocket live update observation expansion ## Tests Added 2 new tests in `test-frontend-helpers.js`: 1. Verifies observation packets get their own path after cache invalidation (not the parent's) 2. Verifies observation path differs from parent path after cache invalidation All 431 frontend helper tests pass. All 62 packet filter tests pass. --------- Co-authored-by: you --- public/packet-helpers.js | 11 ++++++++ public/packets.js | 8 +++--- test-frontend-helpers.js | 55 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/public/packet-helpers.js b/public/packet-helpers.js index e75dee48..5a97a573 100644 --- a/public/packet-helpers.js +++ b/public/packet-helpers.js @@ -20,6 +20,17 @@ window.getParsedPath = function getParsedPath(p) { return p._parsedPath; }; +/** + * Clear cached _parsedPath/_parsedDecoded from a packet object. + * Must be called after spreading a parent packet into an observation/child, + * otherwise the child inherits stale cached values from the parent (issue #504). + */ +window.clearParsedCache = function clearParsedCache(p) { + delete p._parsedPath; + delete p._parsedDecoded; + return p; +}; + window.getParsedDecoded = function getParsedDecoded(p) { if (p._parsedDecoded !== undefined) return p._parsedDecoded; var raw = p.decoded_json; diff --git a/public/packets.js b/public/packets.js index b7923599..5fccb69b 100644 --- a/public/packets.js +++ b/public/packets.js @@ -269,6 +269,7 @@ if (obs) { expandedHashes.add(h); const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, timestamp: obs.timestamp, first_seen: obs.timestamp}; + clearParsedCache(obsPacket); selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id); } else { selectPacket(data.packet.id, h, data); @@ -501,7 +502,7 @@ await Promise.all(multiObs.map(async (p) => { try { const d = await api(`/packets/${p.hash}`); - if (d?.observations) p._children = d.observations.map(o => ({...d.packet, ...o, _isObservation: true})); + if (d?.observations) p._children = d.observations.map(o => clearParsedCache({...d.packet, ...o, _isObservation: true})); } catch {} })); // Flatten: replace grouped packets with individual observations @@ -837,7 +838,7 @@ try { const data = await api(`/packets/${p.hash}`); if (data?.packet && data.observations) { - p._children = data.observations.map(o => ({...data.packet, ...o, _isObservation: true})); + p._children = data.observations.map(o => clearParsedCache({...data.packet, ...o, _isObservation: true})); p._fetchedData = data; } } catch {} @@ -1010,6 +1011,7 @@ if (child) { const parentData = group._fetchedData; const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, timestamp: child.timestamp, first_seen: child.timestamp} : child; + if (parentData) { clearParsedCache(obsPacket); } selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id); } } @@ -1979,7 +1981,7 @@ if (!pkt) return; const group = packets.find(p => p.hash === hash); if (group && data.observations) { - group._children = data.observations.map(o => ({...pkt, ...o, _isObservation: true})); + group._children = data.observations.map(o => clearParsedCache({...pkt, ...o, _isObservation: true})); group._fetchedData = data; // Sort children based on current sort mode sortGroupChildren(group); diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index da850804..0fbbe6d5 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -4411,6 +4411,61 @@ console.log('\n=== app.js: routeTypeName/payloadTypeName edge cases ==='); }); } +// ===== observation packet cache invalidation (issue #504) ===== +{ + console.log('\n=== Issue #504: observation packets must not inherit parent cache ==='); + + const helperSource = fs.readFileSync('public/packet-helpers.js', 'utf8'); + const ctx = vm.createContext({ window: {}, console, JSON, Array, Object }); + vm.runInContext(helperSource, ctx); + const getParsedPath = ctx.window.getParsedPath; + const getParsedDecoded = ctx.window.getParsedDecoded; + const clearParsedCache = ctx.window.clearParsedCache; + + test('clearParsedCache removes cached properties and returns the object', () => { + const p = { path_json: '["A"]', decoded_json: '{"t":1}' }; + getParsedPath(p); + getParsedDecoded(p); + assert.ok(p._parsedPath !== undefined); + assert.ok(p._parsedDecoded !== undefined); + const ret = clearParsedCache(p); + assert.strictEqual(ret, p, 'returns same object'); + assert.strictEqual(p._parsedPath, undefined); + assert.strictEqual(p._parsedDecoded, undefined); + }); + + test('observation packet gets its own path after cache invalidation', () => { + const parent = { path_json: '["A","B"]', decoded_json: '{"type":"GRP_TXT"}' }; + // Prime the cache on parent + getParsedPath(parent); + getParsedDecoded(parent); + + // Simulate spread + fix (like packets.js does after issue #504) + const obs = { ...parent, path_json: '["X","Y","Z"]', decoded_json: '{"type":"TXT_MSG"}' }; + clearParsedCache(obs); + + // getParsedPath re-parses from obs's own path_json + const obsPath = getParsedPath(obs); + assert.deepStrictEqual(obsPath, ['X', 'Y', 'Z'], 'obs gets its own path, not parent\'s'); + const obsDecoded = getParsedDecoded(obs); + assert.deepStrictEqual(obsDecoded, { type: 'TXT_MSG' }, 'obs gets its own decoded, not parent\'s'); + }); + + test('observation packet path differs from parent after cache invalidation', () => { + const parent = { path_json: '["hop1"]', decoded_json: '{"type":"REQ"}' }; + getParsedPath(parent); + getParsedDecoded(parent); + + const obs = { ...parent, path_json: '["hop2","hop3"]', decoded_json: '{"type":"GRP_TXT","text":"hi"}' }; + clearParsedCache(obs); + + assert.notDeepStrictEqual(getParsedPath(obs), getParsedPath(parent), + 'observation must have different path from parent'); + assert.notDeepStrictEqual(getParsedDecoded(obs), getParsedDecoded(parent), + 'observation must have different decoded from parent'); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);