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)}`);