From 5a01ed936176a2a9d99aa8286b293bab9f02c73e Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 27 Feb 2026 20:21:28 +0100 Subject: [PATCH 01/10] chore(deps): update ipfs-geoip to 9.3.0 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8d2f8bd2c..82e270e47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "intl-messageformat": "^10.3.3", "ip": "^1.1.9", "ipfs-css": "^1.4.0", - "ipfs-geoip": "^9.2.0", + "ipfs-geoip": "^9.3.0", "ipfs-provider": "^2.1.0", "ipld-explorer-components": "^8.1.3", "is-ipfs": "^8.0.1", @@ -37806,9 +37806,9 @@ "license": "MIT" }, "node_modules/ipfs-geoip": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/ipfs-geoip/-/ipfs-geoip-9.2.0.tgz", - "integrity": "sha512-f7lS2YppDVxbyDrcIz1G8QmKBOuFdCxyIDpN03/DF13XjnMnyV2YooFswOv+DBlcS4EMyuHTHn6+UvRf6K2gcg==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ipfs-geoip/-/ipfs-geoip-9.3.0.tgz", + "integrity": "sha512-OL9GJdxaSOzHK2DKEQZq/0MU9ggj/uVAV6UrNeKyHaPKST32cg0dkvgyNtScH5l4kix/H4yLaQDwzxVfkUOklQ==", "license": "MIT", "engines": { "node": ">=16.0.0", diff --git a/package.json b/package.json index 859b6169f..fcc71a76e 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "intl-messageformat": "^10.3.3", "ip": "^1.1.9", "ipfs-css": "^1.4.0", - "ipfs-geoip": "^9.2.0", + "ipfs-geoip": "^9.3.0", "ipfs-provider": "^2.1.0", "ipld-explorer-components": "^8.1.3", "is-ipfs": "^8.0.1", From 594bd878924f89d7140f23acf12513103d243c31 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 27 Feb 2026 23:32:48 +0100 Subject: [PATCH 02/10] perf(peers): make geoip selectors synchronous, reduce re-renders - peer-locations.js: replace p-memoize getPublicIP with sync ref-cached version, make isPrivateAndNearby/selectPeerLocationsForSwarm/selectPeersCoordinates sync, add in-memory cache to PeerLocationResolver to skip IndexedDB reads, bump poll interval from 1s to 3s, add queue.onIdle() re-fetch trigger - PeersTable.js: remove useState/useEffect async wrapper, consume array directly - WorldMap.js: remove async wrappers from MapPins and PeerInfo, fix resize useEffect missing dependency array - peer-locations.test.js: remove unnecessary await from sync selector calls --- src/bundles/peer-locations.js | 66 +++++++++++++++++++++--------- src/bundles/peer-locations.test.js | 20 ++++----- src/peers/PeersTable/PeersTable.js | 20 ++++----- src/peers/WorldMap/WorldMap.js | 24 ++--------- 4 files changed, 67 insertions(+), 63 deletions(-) diff --git a/src/bundles/peer-locations.js b/src/bundles/peer-locations.js index adf01d343..f89b79849 100644 --- a/src/bundles/peer-locations.js +++ b/src/bundles/peer-locations.js @@ -6,7 +6,6 @@ import HLRU from 'hashlru' import { multiaddr } from '@multiformats/multiaddr' import ms from 'milliseconds' import ip from 'ip' -import memoize from 'p-memoize' import { createContextSelector } from '../helpers/context-bridge' import pkgJson from '../../package.json' @@ -25,7 +24,7 @@ const selectIdentityData = () => { // After this time interval, we re-check the locations for each peer // once again through PeerLocationResolver. -const UPDATE_EVERY = ms.seconds(1) +const UPDATE_EVERY = ms.seconds(3) // We reuse cached geoip lookups as long geoipVersion is the same. const geoipVersion = dependencies['ipfs-geoip'] @@ -44,8 +43,20 @@ function createPeersLocations (opts) { const bundle = createAsyncResourceBundle({ name: 'peerLocations', actionBaseType: 'PEER_LOCATIONS', - getPromise: ({ store }) => peerLocResolver.findLocations( - store.selectAvailableGatewayUrl(), store.selectPeers()), + getPromise: ({ store }) => { + const promise = peerLocResolver.findLocations( + store.selectAvailableGatewayUrl(), store.selectPeers()) + + if (!peerLocResolver._idleHandlerRegistered && peerLocResolver.queue.size > 0) { + peerLocResolver._idleHandlerRegistered = true + peerLocResolver.queue.onIdle().then(() => { + peerLocResolver._idleHandlerRegistered = false + store.doMarkPeerLocationsAsOutdated() + }) + } + + return promise + }, staleAfter: UPDATE_EVERY, retryAfter: UPDATE_EVERY, persist: false, @@ -68,7 +79,7 @@ function createPeersLocations (opts) { 'selectPeerLocations', 'selectBootstrapPeers', selectIdentityData, // ipfs.id info from identity context, used for detecting local peers - (peers, locations = {}, bootstrapPeers, identity) => peers && Promise.all(peers.map(async (peer) => { + (peers, locations = {}, bootstrapPeers, identity) => peers && peers.map((peer) => { const peerId = peer.peer const locationObj = locations ? locations[peerId] : null const location = toLocationString(locationObj) @@ -81,7 +92,7 @@ function createPeersLocations (opts) { const address = peer.addr.toString() const latency = parseLatency(peer.latency) const direction = peer.direction - const { isPrivate, isNearby } = await isPrivateAndNearby(peer.addr, identity) + const { isPrivate, isNearby } = isPrivateAndNearby(peer.addr, identity) const protocols = (Array.isArray(peer.streams) ? Array.from(new Set(peer.streams @@ -107,18 +118,17 @@ function createPeersLocations (opts) { isNearby, agentVersion } - })) + }) ) const COORDINATES_RADIUS = 4 bundle.selectPeersCoordinates = createSelector( 'selectPeerLocationsForSwarm', - async (peers) => { + (peers) => { if (!peers) return [] - const fetchedPeers = await peers - return fetchedPeers.reduce((previous, { peerId, coordinates }) => { + return peers.reduce((previous, { peerId, coordinates }) => { if (!coordinates) return previous let hasFoundACloseCoordinate = false @@ -178,27 +188,32 @@ const parseLatency = (latency) => { return value } -const getPublicIP = memoize((identity) => { +let _cachedPublicIP +let _lastIdentityRef + +const getPublicIP = (identity) => { if (!identity) return + if (identity === _lastIdentityRef) return _cachedPublicIP + + _lastIdentityRef = identity + _cachedPublicIP = undefined for (const maddr of identity.addresses) { try { const addr = multiaddr(maddr).nodeAddress() if ((ip.isV4Format(addr.address) || ip.isV6Format(addr.address)) && !ip.isPrivate(addr.address)) { - return addr.address + _cachedPublicIP = addr.address + return _cachedPublicIP } } catch (e) { - // TODO: We should provide a way to log these errors when debugging - // if (['development', 'test'].includes(process.env.REACT_APP_ENV)) { - // console.error(e) - // } + // Might fail for non-IP multiaddrs, safe to ignore. } } -}) +} -const isPrivateAndNearby = async (maddr, identity) => { - const publicIP = await getPublicIP(identity) +const isPrivateAndNearby = (maddr, identity) => { + const publicIP = getPublicIP(identity) let isPrivate = false let isNearby = false let addr @@ -246,8 +261,10 @@ class PeerLocationResolver { }) this.geoipLookupPromises = new Map() + this.memoryCache = new Map() this.pass = 0 + this._idleHandlerRegistered = false } async findLocations (gatewayUrls, peers) { @@ -270,9 +287,17 @@ class PeerLocationResolver { continue } - // maybe we have it cached by ipv4 address already, check that. + // check in-memory cache first (avoids IndexedDB reads for known IPs) + const memoryCached = this.memoryCache.get(ipv4Addr) + if (memoryCached) { + res[peerId] = memoryCached + continue + } + + // maybe we have it cached by ipv4 address in IndexedDB const location = await this.geoipCache.get(ipv4Addr) if (location) { + this.memoryCache.set(ipv4Addr, location) res[peerId] = location continue } @@ -285,6 +310,7 @@ class PeerLocationResolver { this.geoipLookupPromises.set(ipv4Addr, this.queue.add(async () => { try { const data = await lookup(gatewayUrls, ipv4Addr) + this.memoryCache.set(ipv4Addr, data) await this.geoipCache.set(ipv4Addr, data) } catch (e) { // mark this one as failed so we don't retry again diff --git a/src/bundles/peer-locations.test.js b/src/bundles/peer-locations.test.js index aaa416dbd..a973e4bf2 100644 --- a/src/bundles/peer-locations.test.js +++ b/src/bundles/peer-locations.test.js @@ -145,7 +145,7 @@ describe('selectPeerLocationsForSwarm', () => { expect(callSelectorMethod(selectPeerLocationsForSwarm)).toBeFalsy() }) - it('should map the peers with the location information', async () => { + it('should map the peers with the location information', () => { const { selectPeerLocationsForSwarm } = createPeersLocationBundle() const locations = { @@ -185,7 +185,7 @@ describe('selectPeerLocationsForSwarm', () => { latency: '1s' } - const result = await callSelectorMethod(selectPeerLocationsForSwarm, [peer1, peer2], locations, ['/p2p/1']) + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer1, peer2], locations, ['/p2p/1']) expect(result).toEqual([ { address: '1.test', @@ -219,7 +219,7 @@ describe('selectPeerLocationsForSwarm', () => { ]) }) - it('should also handle the public ip', async () => { + it('should also handle the public ip', () => { const { selectPeerLocationsForSwarm } = createPeersLocationBundle() const locations = { @@ -250,7 +250,7 @@ describe('selectPeerLocationsForSwarm', () => { ] } - const result = await callSelectorMethod(selectPeerLocationsForSwarm, [peer1], locations, ['/ipfs/1'], identity) + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer1], locations, ['/ipfs/1'], identity) expect(result).toEqual([ { address: '1.test', @@ -269,7 +269,7 @@ describe('selectPeerLocationsForSwarm', () => { ]) }) - it('should also handle the near addresses', async () => { + it('should also handle the near addresses', () => { const { selectPeerLocationsForSwarm } = createPeersLocationBundle() const peer1 = { @@ -292,7 +292,7 @@ describe('selectPeerLocationsForSwarm', () => { ] } - const result = await callSelectorMethod(selectPeerLocationsForSwarm, [peer1], null, ['/ipfs/1'], identity) + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer1], null, ['/ipfs/1'], identity) expect(result).toEqual([ { address: '1.test', @@ -322,14 +322,14 @@ describe('selectPeersCoordinates', () => { ) }) - it('should do nothing when there are no peers', async () => { + it('should do nothing when there are no peers', () => { const { selectPeersCoordinates } = createPeersLocationBundle() - expect(await callSelectorMethod(selectPeersCoordinates)).toEqual([]) + expect(callSelectorMethod(selectPeersCoordinates)).toEqual([]) }) - it('should aggregate peers by close coordinates', async () => { + it('should aggregate peers by close coordinates', () => { const { selectPeersCoordinates } = createPeersLocationBundle() - const result = await callSelectorMethod(selectPeersCoordinates, [ + const result = callSelectorMethod(selectPeersCoordinates, [ { peerId: '1', coordinates: [1, 1] }, { peerId: '2' }, { peerId: '3', coordinates: [1000, 1000] }, diff --git a/src/peers/PeersTable/PeersTable.js b/src/peers/PeersTable/PeersTable.js index bfa1b9b23..ea08f1fa2 100644 --- a/src/peers/PeersTable/PeersTable.js +++ b/src/peers/PeersTable/PeersTable.js @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import classNames from 'classnames' import ms from 'milliseconds' import { connect } from 'redux-bundler-react' @@ -143,7 +143,7 @@ const FilterInput = ({ filter, setFilter, t, filteredCount }) => { export const PeersTable = ({ className, t, peerLocationsForSwarm, selectedPeers }) => { const tableHeight = 400 - const [awaitedPeerLocationsForSwarm, setAwaitedPeerLocationsForSwarm] = useState([]) + const peers = peerLocationsForSwarm || [] const [sortBy, setSortBy] = useState('latency') const [sortDirection, setSortDirection] = useState(SortDirection.ASC) const [filter, setFilter] = useState('') @@ -156,17 +156,11 @@ export const PeersTable = ({ className, t, peerLocationsForSwarm, selectedPeers setFilter(value) }, []) - useEffect(() => { - peerLocationsForSwarm?.then?.((peerLocationsForSwarm) => { - setAwaitedPeerLocationsForSwarm(peerLocationsForSwarm) - }) - }, [peerLocationsForSwarm]) - const filteredPeerList = useMemo(() => { const filterLower = filter.toLowerCase() - if (filterLower === '') return awaitedPeerLocationsForSwarm + if (filterLower === '') return peers const peerFilter = filter.startsWith('/p2p/') ? filter.slice(5) : filter - return awaitedPeerLocationsForSwarm.filter(({ location, latency, peerId, connection, protocols, agentVersion }) => { + return peers.filter(({ location, latency, peerId, connection, protocols, agentVersion }) => { if (location != null && location.toLowerCase().includes(filterLower)) { return true } @@ -188,7 +182,7 @@ export const PeersTable = ({ className, t, peerLocationsForSwarm, selectedPeers return false }) - }, [awaitedPeerLocationsForSwarm, filter]) + }, [peers, filter]) const sortedList = useMemo( () => filteredPeerList.sort(sortByProperty(sortBy, sortDirection === SortDirection.ASC ? 1 : -1)), @@ -198,13 +192,13 @@ export const PeersTable = ({ className, t, peerLocationsForSwarm, selectedPeers return (
- { awaitedPeerLocationsForSwarm && + { peers && {({ width }) => ( <> rowClassRenderer(rowInfo, awaitedPeerLocationsForSwarm, selectedPeers)} + rowClassName={(rowInfo) => rowClassRenderer(rowInfo, peers, selectedPeers)} width={width} height={tableHeight} headerHeight={32} diff --git a/src/peers/WorldMap/WorldMap.js b/src/peers/WorldMap/WorldMap.js index b4b8b2c92..838a45200 100644 --- a/src/peers/WorldMap/WorldMap.js +++ b/src/peers/WorldMap/WorldMap.js @@ -60,7 +60,7 @@ const WorldMap = ({ t, className, selectedPeers, doSetSelectedPeers }) => { window.addEventListener('resize', debouncedHandleResize) return () => window.removeEventListener('resize', debouncedHandleResize) - }) + }, []) const handleMapPinMouseEnter = useCallback((peerIds, element) => { if (!element) return @@ -145,20 +145,13 @@ const getDotsColor = (numberOfDots) => { // Just the dots on the map, this gets called a lot. const MapPins = connect('selectPeersCoordinates', ({ width, height, path, peersCoordinates, handleMouseEnter, handleMouseLeave }) => { - const [awaitedPeerCoordinates, setAwaitedPeerCoordinates] = useState([]) + const coords = peersCoordinates || [] const el = d3.select(ReactFauxDOM.createElement('svg')) .attr('width', width) .attr('height', height) .attr('viewBox', `0 0 ${width} ${height}`) - useEffect(() => { - const asyncFn = async () => { - setAwaitedPeerCoordinates(await peersCoordinates) - } - asyncFn() - }, [peersCoordinates]) - - awaitedPeerCoordinates.forEach(({ peerIds, coordinates }) => { + coords.forEach(({ peerIds, coordinates }) => { el.append('path') .datum({ type: 'Point', @@ -177,16 +170,7 @@ const MapPins = connect('selectPeersCoordinates', ({ width, height, path, peersC const MAX_PEERS = 5 const PeerInfo = connect('selectPeerLocationsForSwarm', ({ ids, peerLocationsForSwarm, t }) => { - const [allPeers, setAllPeers] = useState([]) - - useEffect(() => { - if (!peerLocationsForSwarm) return - const asyncFn = async () => { - setAllPeers(await peerLocationsForSwarm) - } - asyncFn() - }, [peerLocationsForSwarm]) - + const allPeers = peerLocationsForSwarm || [] const peers = allPeers.filter(({ peerId }) => ids.includes(peerId)) const isWindows = useMemo(() => window.navigator.appVersion.indexOf('Win') !== -1, []) From 50228e3c2503a40968c12652beb24d69fe694bdb Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 27 Feb 2026 23:50:00 +0100 Subject: [PATCH 03/10] feat(peers): enable geoip lookups for IPv6 peers - peer-locations.js: replace `isNonHomeIPv4` with `isPublicIP` to accept both protocol 4 (IPv4) and 41 (IPv6), using `ip.isPrivate()` to skip all private/loopback/link-local addresses for both families - peer-locations.js: rename `ipv4Tuple`/`ipv4Addr` to `ipTuple`/`ipAddr` since the filter now matches both IP versions - peer-locations.test.js: add tests for IPv6 peer location resolution, private IPv6/IPv4 `isPrivate` flag, and private IP filtering in `findLocations` - peer-locations.test.js: fix `optimizedPeerSet` IP generation to use only public addresses (old pattern produced 10.x and 127.x) --- src/bundles/peer-locations.js | 33 ++++--- src/bundles/peer-locations.test.js | 150 ++++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 17 deletions(-) diff --git a/src/bundles/peer-locations.js b/src/bundles/peer-locations.js index f89b79849..8c878b442 100644 --- a/src/bundles/peer-locations.js +++ b/src/bundles/peer-locations.js @@ -162,7 +162,8 @@ function createPeersLocations (opts) { return bundle } -const isNonHomeIPv4 = t => t[0] === 4 && t[1] !== '127.0.0.1' +const isPublicIP = t => + (t[0] === 4 || t[0] === 41) && !ip.isPrivate(t[1]) const toLocationString = loc => { if (!loc) return null @@ -277,46 +278,46 @@ class PeerLocationResolver { for (const p of this.optimizedPeerSet(peers)) { const peerId = p.peer - const ipv4Tuple = p.addr.stringTuples().find(isNonHomeIPv4) - if (!ipv4Tuple) { + const ipTuple = p.addr.stringTuples().find(isPublicIP) + if (!ipTuple) { continue } - const ipv4Addr = ipv4Tuple[1] - if (this.failedAddrs.has(ipv4Addr)) { + const ipAddr = ipTuple[1] + if (this.failedAddrs.has(ipAddr)) { continue } // check in-memory cache first (avoids IndexedDB reads for known IPs) - const memoryCached = this.memoryCache.get(ipv4Addr) + const memoryCached = this.memoryCache.get(ipAddr) if (memoryCached) { res[peerId] = memoryCached continue } - // maybe we have it cached by ipv4 address in IndexedDB - const location = await this.geoipCache.get(ipv4Addr) + // maybe we have it cached by IP address in IndexedDB + const location = await this.geoipCache.get(ipAddr) if (location) { - this.memoryCache.set(ipv4Addr, location) + this.memoryCache.set(ipAddr, location) res[peerId] = location continue } // no ip address cached. are we looking it up already? - if (this.geoipLookupPromises.has(ipv4Addr)) { + if (this.geoipLookupPromises.has(ipAddr)) { continue } - this.geoipLookupPromises.set(ipv4Addr, this.queue.add(async () => { + this.geoipLookupPromises.set(ipAddr, this.queue.add(async () => { try { - const data = await lookup(gatewayUrls, ipv4Addr) - this.memoryCache.set(ipv4Addr, data) - await this.geoipCache.set(ipv4Addr, data) + const data = await lookup(gatewayUrls, ipAddr) + this.memoryCache.set(ipAddr, data) + await this.geoipCache.set(ipAddr, data) } catch (e) { // mark this one as failed so we don't retry again - this.failedAddrs.set(ipv4Addr, true) + this.failedAddrs.set(ipAddr, true) } finally { - this.geoipLookupPromises.delete(ipv4Addr) + this.geoipLookupPromises.delete(ipAddr) } })) } diff --git a/src/bundles/peer-locations.test.js b/src/bundles/peer-locations.test.js index a973e4bf2..2b26b8c1e 100644 --- a/src/bundles/peer-locations.test.js +++ b/src/bundles/peer-locations.test.js @@ -269,6 +269,86 @@ describe('selectPeerLocationsForSwarm', () => { ]) }) + it('should map IPv6 peer with location information', () => { + const { selectPeerLocationsForSwarm } = createPeersLocationBundle() + + const locations = { + ipv6peer: { + country_name: 'Republic of Mocks', + city: 'Mocky', + country_code: 'ROM', + longitude: 3.33, + latitude: 3.03 + } + } + + const peer = { + peer: 'ipv6peer', + addr: { + protoNames: () => ['ip6', 'tcp'], + toString: () => '/ip6/2001:db8::1/tcp/4001', + encapsulate: (arg) => ({ toString: () => arg }) + }, + latency: '5ms' + } + + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer], locations, []) + expect(result).toEqual([ + { + address: '/ip6/2001:db8::1/tcp/4001', + agentVersion: undefined, + connection: 'ip6/tcp', + coordinates: [3.33, 3.03], + direction: undefined, + flagCode: 'ROM', + isNearby: false, + isPrivate: false, + latency: 5, + location: 'Republic of Mocks, Mocky', + peerId: 'ipv6peer', + protocols: '' + } + ]) + }) + + it('should show private IPv6 peer with isPrivate flag', () => { + const { selectPeerLocationsForSwarm } = createPeersLocationBundle() + + const peer = { + peer: 'privateV6', + addr: { + protoNames: () => ['ip6', 'tcp'], + toString: () => '/ip6/fe80::1/tcp/4001', + encapsulate: (arg) => ({ toString: () => arg }), + nodeAddress: () => ({ address: 'fe80::1' }) + }, + latency: 'n/a' + } + + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer], {}, []) + expect(result[0].isPrivate).toBe(true) + expect(result[0].peerId).toBe('privateV6') + }) + + it('should show private IPv4 peer with isPrivate flag', () => { + const { selectPeerLocationsForSwarm } = createPeersLocationBundle() + + const peer = { + peer: 'privateV4', + addr: { + protoNames: () => ['ip4', 'tcp'], + toString: () => '/ip4/192.168.1.1/tcp/4001', + encapsulate: (arg) => ({ toString: () => arg }), + nodeAddress: () => ({ address: '192.168.1.1' }) + }, + latency: 'n/a' + } + + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer], {}, []) + expect(result[0].isPrivate).toBe(true) + expect(result[0].peerId).toBe('privateV4') + }) + it('should also handle the near addresses', () => { const { selectPeerLocationsForSwarm } = createPeersLocationBundle() @@ -432,11 +512,79 @@ describe('PeerLocationResolver', () => { }) }) + describe('findLocations with IPv6', () => { + it('should resolve IPv6 peer from cache', async () => { + await mockGeoIpCache('2001:db8::1') + + const fakePeers = [ + { + peer: 'v6peer', + latency: '1ms', + addr: { + stringTuples: () => [[41, '2001:db8::1']] + } + } + ] + + const store = await createStore({ + selectors: { + selectAvailableGatewayUrl: () => 'https://ipfs.io', + selectIsOnline: () => true, + selectBootstrapPeers: () => fakePeers, + selectPeers: () => fakePeers, + selectRouteInfo: _ => ({ url: '/peers' }), + selectIdentity: () => ({ + addresses: ['/ip4/4.4.4.4/udp/1234'] + }) + } + }) + + const result = await getPeerLocationsFromStore({ store }) + expect(result).toEqual({ v6peer: 'location-cached' }) + }) + + it('should skip private IPs for both IPv4 and IPv6', async () => { + const privatePeers = [ + { + peer: 'loopbackV6', + latency: '1ms', + addr: { stringTuples: () => [[41, '::1']] } + }, + { + peer: 'linkLocalV6', + latency: '1ms', + addr: { stringTuples: () => [[41, 'fe80::1']] } + }, + { + peer: 'privateV4', + latency: '1ms', + addr: { stringTuples: () => [[4, '192.168.1.1']] } + } + ] + + const store = await createStore({ + selectors: { + selectAvailableGatewayUrl: () => 'https://ipfs.io', + selectIsOnline: () => true, + selectBootstrapPeers: () => privatePeers, + selectPeers: () => privatePeers, + selectRouteInfo: _ => ({ url: '/peers' }), + selectIdentity: () => ({ + addresses: ['/ip4/4.4.4.4/udp/1234'] + }) + } + }) + + const result = await getPeerLocationsFromStore({ store }) + expect(result).toEqual({}) + }) + }) + describe('optimizedPeerSet', () => { it('should return sets of 10, 100, 200 peers and more according to the number of calls', async () => { const ipAddresses = [] const peers = new Array(1000).fill().map((_, index) => { - const ipAddress = `${index}.0.${index}.${index}` + const ipAddress = `33.1.${Math.floor(index / 256)}.${index % 256}` ipAddresses.push(ipAddress) return ({ peer: `${index}aa`, From 3e3fbd568c1d6a69c1c2ed86b2e19ea5168f964b Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 27 Feb 2026 23:54:20 +0100 Subject: [PATCH 04/10] fix(peers): use HLRU for geoip memory cache to prevent unbounded growth - peer-locations.js: swap `new Map()` for `HLRU(500)`, matching `failedAddrs` capacity --- src/bundles/peer-locations.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundles/peer-locations.js b/src/bundles/peer-locations.js index 8c878b442..e0b412127 100644 --- a/src/bundles/peer-locations.js +++ b/src/bundles/peer-locations.js @@ -262,7 +262,7 @@ class PeerLocationResolver { }) this.geoipLookupPromises = new Map() - this.memoryCache = new Map() + this.memoryCache = HLRU(500) this.pass = 0 this._idleHandlerRegistered = false From 37b3491fbcb69a4bd21979922e3729827739d953 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 28 Feb 2026 00:23:57 +0100 Subject: [PATCH 05/10] perf(peers): progressive geoip rendering on initial load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guard getPromise against null peers to prevent crash during ramp-up - chain immediate re-fetches during optimizedPeerSet ramp-up (10→100→200→all) instead of waiting 3s staleAfter between each pass - replace onIdle handler with throttled completed event listener so uncached geoip lookups render progressively as they land --- src/bundles/peer-locations.js | 42 ++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/bundles/peer-locations.js b/src/bundles/peer-locations.js index e0b412127..fc0a45544 100644 --- a/src/bundles/peer-locations.js +++ b/src/bundles/peer-locations.js @@ -44,13 +44,45 @@ function createPeersLocations (opts) { name: 'peerLocations', actionBaseType: 'PEER_LOCATIONS', getPromise: ({ store }) => { + const peers = store.selectPeers() + if (!peers) return Promise.resolve({}) + const promise = peerLocResolver.findLocations( - store.selectAvailableGatewayUrl(), store.selectPeers()) + store.selectAvailableGatewayUrl(), peers) + + // While optimizedPeerSet is still ramping up (pass 0→10, 1→100, 2→200, 3→all), + // chain an immediate re-fetch after the current one resolves instead of + // waiting for staleAfter (3s) between each pass. + if (peerLocResolver.pass <= 3) { + promise.then(() => setTimeout(() => store.doMarkPeerLocationsAsOutdated(), 0)) + } + + if (!peerLocResolver._completedHandler && peerLocResolver.queue.size > 0) { + let throttleTimer = null + let pendingUpdate = false + + const throttledUpdate = () => { + if (throttleTimer) { + pendingUpdate = true + return + } + store.doMarkPeerLocationsAsOutdated() + throttleTimer = setTimeout(() => { + throttleTimer = null + if (pendingUpdate) { + pendingUpdate = false + throttledUpdate() + } + }, 500) + } + + peerLocResolver._completedHandler = throttledUpdate + peerLocResolver.queue.on('completed', throttledUpdate) - if (!peerLocResolver._idleHandlerRegistered && peerLocResolver.queue.size > 0) { - peerLocResolver._idleHandlerRegistered = true peerLocResolver.queue.onIdle().then(() => { - peerLocResolver._idleHandlerRegistered = false + peerLocResolver.queue.off('completed', throttledUpdate) + peerLocResolver._completedHandler = null + clearTimeout(throttleTimer) store.doMarkPeerLocationsAsOutdated() }) } @@ -265,7 +297,7 @@ class PeerLocationResolver { this.memoryCache = HLRU(500) this.pass = 0 - this._idleHandlerRegistered = false + this._completedHandler = null } async findLocations (gatewayUrls, peers) { From b1faa1204556ce496468d29127518b49f949e84f Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 28 Feb 2026 00:47:25 +0100 Subject: [PATCH 06/10] perf(peers): skip redundant re-renders when geoip data is unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guard null peers with quick 100ms retry instead of waiting 3s staleAfter - shallow-compare new locations with previous data and return the same reference when unchanged, preventing the full selector/render cascade (selectPeerLocationsForSwarm → selectPeersCoordinates → MapPins D3 rebuild) --- src/bundles/peer-locations.js | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/bundles/peer-locations.js b/src/bundles/peer-locations.js index fc0a45544..991e696e0 100644 --- a/src/bundles/peer-locations.js +++ b/src/bundles/peer-locations.js @@ -45,7 +45,13 @@ function createPeersLocations (opts) { actionBaseType: 'PEER_LOCATIONS', getPromise: ({ store }) => { const peers = store.selectPeers() - if (!peers) return Promise.resolve({}) + if (!peers) { + // Peers bundle hasn't loaded yet. Return empty result but schedule + // a quick retry -- the reactor doesn't depend on selectPeers, so + // without this we'd wait the full staleAfter (3s) before re-fetching. + setTimeout(() => store.doMarkPeerLocationsAsOutdated(), 100) + return Promise.resolve({}) + } const promise = peerLocResolver.findLocations( store.selectAvailableGatewayUrl(), peers) @@ -87,7 +93,17 @@ function createPeersLocations (opts) { }) } - return promise + // Avoid unnecessary selector recomputation and re-renders: return + // the previous data reference when nothing actually changed. This is + // cheap because memoryCache (HLRU) returns the same value references + // for the same IPs, so a shallow comparison suffices. + return promise.then(newLocations => { + const prev = store.selectPeerLocations() + if (prev && shallowEqualObjects(prev, newLocations)) { + return prev + } + return newLocations + }) }, staleAfter: UPDATE_EVERY, retryAfter: UPDATE_EVERY, @@ -194,6 +210,13 @@ function createPeersLocations (opts) { return bundle } +const shallowEqualObjects = (a, b) => { + const keysA = Object.keys(a) + const keysB = Object.keys(b) + if (keysA.length !== keysB.length) return false + return keysA.every(key => a[key] === b[key]) +} + const isPublicIP = t => (t[0] === 4 || t[0] === 41) && !ip.isPrivate(t[1]) From 305553adac9d9049ef3caa425ef3739e7129a952 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 28 Feb 2026 00:53:32 +0100 Subject: [PATCH 07/10] perf(peers): reduce unnecessary re-renders on peers page - WorldMap: use useRef for selectedTimeout instead of useState every mouse leave was calling setSelectedTimeout which re-rendered WorldMap, recreated handleMapPinMouseEnter (selectedTimeout in deps), passed new prop to MapPins, triggering full D3 SVG rebuild. timeout ID is bookkeeping, not display state -- useRef avoids renders. - WorldMap: memoize GeoPath d3 projection with useMemo([width, height]) every mouse hover changed selectedPeers, re-rendered WorldMap, re-rendered GeoPath which created a new d3.geoPath() reference, passed as new prop to MapPins triggering SVG rebuild. now stable unless window is resized. - peer-locations: remove unused selectBootstrapPeers from selectPeerLocationsForSwarm. reselect recomputed the selector (mapping all 298 peers) whenever bootstrap peers changed, even though bootstrapPeers was never referenced in the function body. --- src/bundles/peer-locations.js | 3 +-- src/bundles/peer-locations.test.js | 15 +++++++-------- src/peers/WorldMap/WorldMap.js | 28 ++++++++++++++-------------- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/bundles/peer-locations.js b/src/bundles/peer-locations.js index 991e696e0..7af704467 100644 --- a/src/bundles/peer-locations.js +++ b/src/bundles/peer-locations.js @@ -125,9 +125,8 @@ function createPeersLocations (opts) { bundle.selectPeerLocationsForSwarm = createSelector( 'selectPeers', 'selectPeerLocations', - 'selectBootstrapPeers', selectIdentityData, // ipfs.id info from identity context, used for detecting local peers - (peers, locations = {}, bootstrapPeers, identity) => peers && peers.map((peer) => { + (peers, locations = {}, identity) => peers && peers.map((peer) => { const peerId = peer.peer const locationObj = locations ? locations[peerId] : null const location = toLocationString(locationObj) diff --git a/src/bundles/peer-locations.test.js b/src/bundles/peer-locations.test.js index 2b26b8c1e..474ceeb1d 100644 --- a/src/bundles/peer-locations.test.js +++ b/src/bundles/peer-locations.test.js @@ -133,8 +133,7 @@ describe('selectPeerLocationsForSwarm', () => { expect(createSelector).toHaveBeenNthCalledWith(2, 'selectPeers', 'selectPeerLocations', - 'selectBootstrapPeers', - 'selectIdentity', + expect.any(Function), // selectIdentityData expect.any(Function) ) }) @@ -185,7 +184,7 @@ describe('selectPeerLocationsForSwarm', () => { latency: '1s' } - const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer1, peer2], locations, ['/p2p/1']) + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer1, peer2], locations) expect(result).toEqual([ { address: '1.test', @@ -250,7 +249,7 @@ describe('selectPeerLocationsForSwarm', () => { ] } - const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer1], locations, ['/ipfs/1'], identity) + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer1], locations, identity) expect(result).toEqual([ { address: '1.test', @@ -292,7 +291,7 @@ describe('selectPeerLocationsForSwarm', () => { latency: '5ms' } - const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer], locations, []) + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer], locations) expect(result).toEqual([ { address: '/ip6/2001:db8::1/tcp/4001', @@ -325,7 +324,7 @@ describe('selectPeerLocationsForSwarm', () => { latency: 'n/a' } - const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer], {}, []) + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer], {}) expect(result[0].isPrivate).toBe(true) expect(result[0].peerId).toBe('privateV6') }) @@ -344,7 +343,7 @@ describe('selectPeerLocationsForSwarm', () => { latency: 'n/a' } - const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer], {}, []) + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer], {}) expect(result[0].isPrivate).toBe(true) expect(result[0].peerId).toBe('privateV4') }) @@ -372,7 +371,7 @@ describe('selectPeerLocationsForSwarm', () => { ] } - const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer1], null, ['/ipfs/1'], identity) + const result = callSelectorMethod(selectPeerLocationsForSwarm, [peer1], null, identity) expect(result).toEqual([ { address: '1.test', diff --git a/src/peers/WorldMap/WorldMap.js b/src/peers/WorldMap/WorldMap.js index 838a45200..ca649f907 100644 --- a/src/peers/WorldMap/WorldMap.js +++ b/src/peers/WorldMap/WorldMap.js @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useMemo, useEffect } from 'react' +import React, { useCallback, useRef, useState, useMemo, useEffect } from 'react' import ReactFauxDOM from 'react-faux-dom' import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' @@ -48,7 +48,7 @@ const calculateHeight = (width) => { const WorldMap = ({ t, className, selectedPeers, doSetSelectedPeers }) => { const [width, setWidth] = useState(calculateWidth(window.innerWidth)) const [height, setHeight] = useState(calculateHeight(width)) - const [selectedTimeout, setSelectedTimeout] = useState(null) + const selectedTimeoutRef = useRef(null) useEffect(() => { const debouncedHandleResize = debounce(() => { @@ -65,20 +65,18 @@ const WorldMap = ({ t, className, selectedPeers, doSetSelectedPeers }) => { const handleMapPinMouseEnter = useCallback((peerIds, element) => { if (!element) return - clearTimeout(selectedTimeout) + clearTimeout(selectedTimeoutRef.current) const { x, y, width, height } = element.getBBox() doSetSelectedPeers({ peerIds, left: `${x + width / 2}px`, top: `${y - height / 2}px` }) - }, [doSetSelectedPeers, selectedTimeout]) + }, [doSetSelectedPeers]) const handleMapPinMouseLeave = useCallback(() => { - setSelectedTimeout( - setTimeout(() => doSetSelectedPeers({}), 600) - ) + selectedTimeoutRef.current = setTimeout(() => doSetSelectedPeers({}), 600) }, [doSetSelectedPeers]) - const handlePopoverMouseEnter = useCallback(() => clearTimeout(selectedTimeout), [selectedTimeout]) + const handlePopoverMouseEnter = useCallback(() => clearTimeout(selectedTimeoutRef.current), []) return (
@@ -121,12 +119,14 @@ const PeersCount = connect('selectPeers', ({ peers }) => peers ? peers.length : const GeoPath = ({ width, height, children }) => { // https://github.com/d3/d3-geo/blob/master/README.md#geoEquirectangular - const projection = d3.geoEquirectangular() - .scale(height / Math.PI) - .translate([width / 2, height / 2]) - .precision(0.1) - // https://github.com/d3/d3-geo/blob/master/README.md#paths - const path = d3.geoPath().projection(projection) + const path = useMemo(() => { + const projection = d3.geoEquirectangular() + .scale(height / Math.PI) + .translate([width / 2, height / 2]) + .precision(0.1) + // https://github.com/d3/d3-geo/blob/master/README.md#paths + return d3.geoPath().projection(projection) + }, [width, height]) return children({ path }) } From aff4be49a1a707ed1368d422ece802d4130b71c2 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 28 Feb 2026 01:04:01 +0100 Subject: [PATCH 08/10] fix(peers): wrap peers fallback in useMemo to fix CI build react-hooks/exhaustive-deps flagged the `peerLocationsForSwarm || []` expression as unstable dependency for the filteredPeerList useMemo. CI treats warnings as errors, failing the build. --- src/peers/PeersTable/PeersTable.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/peers/PeersTable/PeersTable.js b/src/peers/PeersTable/PeersTable.js index ea08f1fa2..2c6789002 100644 --- a/src/peers/PeersTable/PeersTable.js +++ b/src/peers/PeersTable/PeersTable.js @@ -143,7 +143,7 @@ const FilterInput = ({ filter, setFilter, t, filteredCount }) => { export const PeersTable = ({ className, t, peerLocationsForSwarm, selectedPeers }) => { const tableHeight = 400 - const peers = peerLocationsForSwarm || [] + const peers = useMemo(() => peerLocationsForSwarm || [], [peerLocationsForSwarm]) const [sortBy, setSortBy] = useState('latency') const [sortDirection, setSortDirection] = useState(SortDirection.ASC) const [filter, setFilter] = useState('') From 1de615b91c12d764d51796a447fe3b83353b5459 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 28 Feb 2026 01:05:56 +0100 Subject: [PATCH 09/10] chore(deps): update baseline-browser-mapping --- package-lock.json | 12 ++++++++---- package.json | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 82e270e47..387c5f3bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,6 +116,7 @@ "@typescript-eslint/parser": "^5.62.0", "aegir": "^42.2.2", "autoprefixer": "^10.4.7", + "baseline-browser-mapping": "^2.10.0", "basic-auth": "^2.0.1", "big.js": "^5.2.2", "concurrently": "^7.3.0", @@ -25681,12 +25682,15 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", - "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/basic-auth": { diff --git a/package.json b/package.json index fcc71a76e..0d7ccfd8e 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "@typescript-eslint/parser": "^5.62.0", "aegir": "^42.2.2", "autoprefixer": "^10.4.7", + "baseline-browser-mapping": "^2.10.0", "basic-auth": "^2.0.1", "big.js": "^5.2.2", "concurrently": "^7.3.0", From 48d8538a0716065ef44a165fe894e6e657dac95b Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 28 Feb 2026 01:17:07 +0100 Subject: [PATCH 10/10] Revert "chore(deps): update baseline-browser-mapping" This reverts commit 1de615b91c12d764d51796a447fe3b83353b5459. --- package-lock.json | 12 ++++-------- package.json | 1 - 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 387c5f3bc..82e270e47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,7 +116,6 @@ "@typescript-eslint/parser": "^5.62.0", "aegir": "^42.2.2", "autoprefixer": "^10.4.7", - "baseline-browser-mapping": "^2.10.0", "basic-auth": "^2.0.1", "big.js": "^5.2.2", "concurrently": "^7.3.0", @@ -25682,15 +25681,12 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", + "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" + "baseline-browser-mapping": "dist/cli.js" } }, "node_modules/basic-auth": { diff --git a/package.json b/package.json index 0d7ccfd8e..fcc71a76e 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,6 @@ "@typescript-eslint/parser": "^5.62.0", "aegir": "^42.2.2", "autoprefixer": "^10.4.7", - "baseline-browser-mapping": "^2.10.0", "basic-auth": "^2.0.1", "big.js": "^5.2.2", "concurrently": "^7.3.0",