diff --git a/.changeset/hot-pots-tap.md b/.changeset/hot-pots-tap.md new file mode 100644 index 000000000..511440534 --- /dev/null +++ b/.changeset/hot-pots-tap.md @@ -0,0 +1,5 @@ +--- +"@usedapp/core": patch +--- + +🔬 Make useRawLogs more efficient and responsive diff --git a/packages/core/src/helpers/common.test.ts b/packages/core/src/helpers/common.test.ts new file mode 100644 index 000000000..eebe757ca --- /dev/null +++ b/packages/core/src/helpers/common.test.ts @@ -0,0 +1,269 @@ +import { expect } from 'chai' +import { deepEqual, isPrimitive } from './common' +import { Filter } from '@ethersproject/abstract-provider' + +describe('common', function () { + describe('isPrimitive', function () { + it('Returns true for 0', function () { + expect(isPrimitive(0)).to.equal(true) + }) + + it('Returns true for 1', function () { + expect(isPrimitive(1)).to.equal(true) + }) + + it("Returns true for 'a'", function () { + expect(isPrimitive('a')).to.equal(true) + }) + + it("Returns true for ''", function () { + expect(isPrimitive('')).to.equal(true) + }) + + it('Returns true for undefined', function () { + expect(isPrimitive(undefined)).to.equal(true) + }) + + it('Returns true for null', function () { + expect(isPrimitive(null)).to.equal(true) + }) + + it('Returns true for true', function () { + expect(isPrimitive(true)).to.equal(true) + }) + + it('Returns true for false', function () { + expect(isPrimitive(false)).to.equal(true) + }) + + it('Returns false for []', function () { + expect(isPrimitive([])).to.equal(false) + }) + + it('Returns false for {}', function () { + expect(isPrimitive({})).to.equal(false) + }) + }) + + describe('deepEqual', function () { + it('Returns true for 0 and 0', function () { + expect(deepEqual(0, 0)).to.equal(true) + }) + + it('Returns true for 1 and 1', function () { + expect(deepEqual(1, 1)).to.equal(true) + }) + + it("Returns true for 'a' and 'a'", function () { + expect(deepEqual('a', 'a')).to.equal(true) + }) + + it("Returns true for '' and ''", function () { + expect(deepEqual('', '')).to.equal(true) + }) + + it('Returns true for undefined and undefined', function () { + expect(deepEqual(undefined, undefined)).to.equal(true) + }) + + it('Returns true for null and null', function () { + expect(deepEqual(null, null)).to.equal(true) + }) + + it('Returns true for true and true', function () { + expect(deepEqual(true, true)).to.equal(true) + }) + + it('Returns true for false and false', function () { + expect(deepEqual(false, false)).to.equal(true) + }) + + it('Returns true for [] and []', function () { + expect(deepEqual([], [])).to.equal(true) + }) + + it('Returns true for undefined and null', function () { + expect(deepEqual(undefined, null)).to.equal(true) + }) + + it('Returns true for null and undefined', function () { + expect(deepEqual(null, undefined)).to.equal(true) + }) + + it('Returns true for two block filters with different property orderings', function () { + const filter1: Filter = { + fromBlock: 1, + toBlock: 2, + } + + const filter2: Filter = { + toBlock: 2, + fromBlock: 1, + } + + expect(deepEqual(filter1, filter2)).to.equal(true) + }) + + it('Returns false for two block filters with different property counts (one has less than two)', function () { + const filter1: Filter = { + fromBlock: 1, + } + + const filter2: Filter = { + fromBlock: 1, + toBlock: 2, + } + + expect(deepEqual(filter1, filter2)).to.equal(false) + }) + + it('Returns false for two block filters with different property counts (one has more than two)', function () { + const filter1: Filter = { + fromBlock: 1, + toBlock: 2, + } + + const filter2: Filter = { + fromBlock: 1, + } + + expect(deepEqual(filter1, filter2)).to.equal(false) + }) + + it('Returns false for two block filters with different addresses', function () { + const filter1: Filter = { + address: '0x0000000000000000000000000000000000000000', + } + + const filter2: Filter = { + address: '0x0000000000000000000000000000000000000001', + } + + expect(deepEqual(filter1, filter2)).to.equal(false) + }) + + it("Returns false for 0 and '0'", function () { + expect(deepEqual(0, '0')).to.equal(false) + }) + + it("Returns false for '0' and 0", function () { + expect(deepEqual('0', 0)).to.equal(false) + }) + + it('Returns false for false and 0', function () { + expect(deepEqual(false, 0)).to.equal(false) + }) + + it('Returns false for 0 and false', function () { + expect(deepEqual(0, false)).to.equal(false) + }) + + it('Returns false for 0 and null', function () { + expect(deepEqual(0, null)).to.equal(false) + }) + + it('Returns false for null and 0', function () { + expect(deepEqual(null, 0)).to.equal(false) + }) + + it('Returns false for 0 and undefined', function () { + expect(deepEqual(0, undefined)).to.equal(false) + }) + + it('Returns false for undefined and 0', function () { + expect(deepEqual(undefined, 0)).to.equal(false) + }) + + it("Returns false for '' and null", function () { + expect(deepEqual('', null)).to.equal(false) + }) + + it("Returns false for null and ''", function () { + expect(deepEqual(null, '')).to.equal(false) + }) + + it("Returns false for '' and undefined", function () { + expect(deepEqual('', undefined)).to.equal(false) + }) + + it("Returns false for undefined and ''", function () { + expect(deepEqual(undefined, '')).to.equal(false) + }) + + it('Returns false for {} and {}', function () { + expect(deepEqual({}, {})).to.equal(true) + }) + + it('Returns false for 0 and 1', function () { + expect(deepEqual(0, 1)).to.equal(false) + }) + + it('Returns false for 1 and 0', function () { + expect(deepEqual(1, 0)).to.equal(false) + }) + + it("Returns false for 'a' and 'b'", function () { + expect(deepEqual('a', 'b')).to.equal(false) + }) + + it("Returns false for 'b' and 'a'", function () { + expect(deepEqual('b', 'a')).to.equal(false) + }) + + it("Returns false for '' and 'a'", function () { + expect(deepEqual('', 'a')).to.equal(false) + }) + + it("Returns false for 'a' and ''", function () { + expect(deepEqual('a', '')).to.equal(false) + }) + + it('Retuens false for [0] and [0, 0]', function () { + expect(deepEqual([0], [0, 0])).to.equal(false) + }) + + it('Returns false for [0, 0] and [0]', function () { + expect(deepEqual([0, 0], [0])).to.equal(false) + }) + + it('Returns false for [0] and [1]', function () { + expect(deepEqual([0], [1])).to.equal(false) + }) + + it('Returns false for [1] and [0]', function () { + expect(deepEqual([1], [0])).to.equal(false) + }) + + it('Returns false for [0] and [null]', function () { + expect(deepEqual([0], [null])).to.equal(false) + }) + + it('Returns false for [null] and [0]', function () { + expect(deepEqual([null], [0])).to.equal(false) + }) + + it('Returns false for [0] and [undefined]', function () { + expect(deepEqual([0], [undefined])).to.equal(false) + }) + + it('Returns false for [undefined] and [0]', function () { + expect(deepEqual([undefined], [0])).to.equal(false) + }) + + it('Returns false for [0] and [{}]', function () { + expect(deepEqual([0], [{}])).to.equal(false) + }) + + it('Returns false for [{}] and [0]', function () { + expect(deepEqual([{}], [0])).to.equal(false) + }) + + it('Returns false for [0, 1] and [1, 0]', function () { + expect(deepEqual([0, 1], [1, 0])).to.equal(false) + }) + + it('Returns false for [1, 0] and [0, 1]', function () { + expect(deepEqual([1, 0], [0, 1])).to.equal(false) + }) + }) +}) diff --git a/packages/core/src/helpers/common.ts b/packages/core/src/helpers/common.ts index 77af99c9b..58b707e20 100644 --- a/packages/core/src/helpers/common.ts +++ b/packages/core/src/helpers/common.ts @@ -1,3 +1,47 @@ export function shortenString(str: string) { return str.substring(0, 6) + '...' + str.substring(str.length - 4) } + +/** + * Determines whether two objects are equal using a deep comparison. Null and undefined are considered equal. Arrays + * with the same elements are not considered equal if they are in different orders. Objects with the same properties + * can have different property orderings and still be considered equal. + * @param obj1 The first object to compare. + * @param obj2 The second object to compare. + * @returns True if the objects are deep equal, false otherwise. + */ +export function deepEqual(obj1: any, obj2: any) { + if (obj1 === obj2) return true + + if (obj1 == null || obj2 == null) return obj1 == obj2 + + const obj1Primitive = isPrimitive(obj1) + const obj2Primitive = isPrimitive(obj2) + if (obj1Primitive || obj2Primitive) + // compare primitives + return obj1Primitive === obj2Primitive && obj1 === obj2 + + let obj1KeyCount = 0 + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ in obj1) obj1KeyCount++ + + let obj2KeyCount = 0 + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ in obj2) { + if (++obj2KeyCount > obj1KeyCount) return false + } + + if (obj1KeyCount !== obj2KeyCount) return false + + // compare objects with same number of keys + for (const key in obj1) { + if (!(key in obj2)) return false //other object doesn't have this prop + if (!deepEqual(obj1[key], obj2[key])) return false + } + + return true +} + +export function isPrimitive(obj: any) { + return obj !== Object(obj) +} diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index 74f504ba1..6af4e97be 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -8,3 +8,4 @@ export * from './eip1193' export * from './logs' export * from './isWebSocketProvider' export * from './event' +export * from './common' diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index 1af769f24..4ac54b993 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -30,3 +30,4 @@ export * from './useRawLogs' export * from './useRawCalls' export * from './useResolveName' export * from './useSigner' +export * from './useResolvedPromise' diff --git a/packages/core/src/hooks/useRawLogs.ts b/packages/core/src/hooks/useRawLogs.ts index d311f8210..1ec935f6f 100644 --- a/packages/core/src/hooks/useRawLogs.ts +++ b/packages/core/src/hooks/useRawLogs.ts @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useEthers } from './useEthers' import { useReadonlyNetworks } from '../providers/network/readonlyNetworks' -import { useBlockNumbers, useBlockNumber } from '../hooks' +import { useBlockNumbers, useBlockNumber, useConfig, useResolvedPromise } from '../hooks' import { QueryParams } from '../constants/type/QueryParams' import type { Filter, FilterByBlockHash, Log } from '@ethersproject/abstract-provider' import { Falsy } from '../model/types' +import { ChainId } from '../constants' /** * Returns all blockchain logs given a block filter. @@ -25,21 +26,104 @@ export function useRawLogs( const blockNumbers = useBlockNumbers() const [logs, setLogs] = useState() + const [lastContractAddress, setLastContractAddress] = useState() + const [lastTopics, setLastTopics] = useState() + const [lastChainId, setLastChainId] = useState() + const [lastBlockNumber, setLastBlockNumber] = useState() + const resolvedFilter = useResolvedPromise(filter) - const { chainId } = queryParams + const isLoadingRef = useRef(false) + + const { chainId, isStatic } = queryParams + const config = useConfig() + const refresh = queryParams?.refresh ?? config.refresh const [provider, blockNumber] = useMemo( () => (chainId ? [providers[chainId], blockNumbers[chainId]] : [library, _blockNumber]), [providers, library, blockNumbers, _blockNumber, chainId] ) - async function updateLogs() { - setLogs(!filter ? undefined : await provider?.getLogs(filter)) - } + const deps: any[] = [provider] + + const filterTopicsAsJson = resolvedFilter && JSON.stringify(resolvedFilter.topics) + + // Push the filter elements to the dependencies. We do this individually b/c hook dependency checks are shallow + deps.push(resolvedFilter && resolvedFilter.address) + deps.push(filterTopicsAsJson) + deps.push(resolvedFilter && (resolvedFilter as FilterByBlockHash).blockHash) + deps.push(resolvedFilter && (resolvedFilter as Filter).fromBlock) + deps.push(resolvedFilter && (resolvedFilter as Filter).toBlock) + + // Push the block number if we are not static + deps.push(!isStatic && refresh !== 'never' ? blockNumber : 0) useEffect(() => { + let active = true // Flag to indicate if the effect is still in effect + + async function updateLogs() { + if (isLoadingRef.current || !active) { + // We are already loading, don't start another request + // or the component has been unmounted + return + } + + isLoadingRef.current = true + try { + let filterChanged = true + if ( + chainId === lastChainId && + resolvedFilter && + lastContractAddress === resolvedFilter.address && + lastTopics === filterTopicsAsJson + ) { + // The filter did not change + filterChanged = false + } else { + // Filter changed. Reset logs + setLogs(undefined) + } + + if (!filterChanged) { + if (isStatic || refresh === 'never') { + // Only update logs if contract address or topics changed + return + } else if (typeof refresh === 'number') { + // Only update logs if the block number has increased by the refresh interval + if (blockNumber && lastBlockNumber && blockNumber - lastBlockNumber < refresh) { + return + } + } + } + + // Shallow copy the criteria to later store it + // This is necessary because the resolved filter can change after the async call, leading to a mismatch and + // thus logs being stale + const usedContractAddress = !resolvedFilter ? undefined : resolvedFilter.address + const usedTopics = !resolvedFilter ? undefined : JSON.stringify(resolvedFilter.topics) + const usedChainId = chainId + const usedBlockNumber = blockNumber + + const rawLogs = !resolvedFilter ? undefined : await provider?.getLogs(resolvedFilter) + + // Active state could have changed while we were waiting for the logs. Don't update state if it has + if (active) { + setLogs(rawLogs) + setLastContractAddress(usedContractAddress) + setLastTopics(usedTopics) + setLastChainId(usedChainId) + setLastBlockNumber(usedBlockNumber) + } + } finally { + isLoadingRef.current = false + } + } + void updateLogs() - }, [provider, blockNumber]) + + return () => { + active = false // Prevent state updates after the component has unmounted + } + }, deps) return logs } diff --git a/packages/core/src/hooks/useResolvedPromise.test.ts b/packages/core/src/hooks/useResolvedPromise.test.ts new file mode 100644 index 000000000..1906211e5 --- /dev/null +++ b/packages/core/src/hooks/useResolvedPromise.test.ts @@ -0,0 +1,40 @@ +import { Filter } from '@ethersproject/abstract-provider' +import { renderHook } from '@testing-library/react-hooks' +import { expect } from 'chai' +import { useResolvedPromise } from './useResolvedPromise' +import { deepEqual } from '../helpers' + +describe('useResolvedPromise', function () { + it('Resolves a block filter', async function () { + const filter: Filter = { toBlock: 1 } + + const filterPromise = new Promise((resolve) => { + resolve(filter) + }) + + const result = renderHook(() => useResolvedPromise(filterPromise)) + + await result.waitForNextUpdate() + + expect(deepEqual(filter, result.result.current)).to.equal(true) + }) + + it("Immediately returns undefined if the promise hasn't resolved", async function () { + const filter: Filter = { toBlock: 1 } + const filterPromise = new Promise((resolve) => { + resolve(filter) + }) + + const result = renderHook(() => useResolvedPromise(filterPromise)) + + expect(result.result.current).to.equal(undefined) + }) + + it("Immediately returns the value if it's not a promise", async function () { + const filter: Filter = { toBlock: 1 } + + const result = renderHook(() => useResolvedPromise(filter)) + + expect(deepEqual(filter, result.result.current)).to.equal(true) + }) +}) diff --git a/packages/core/src/hooks/useResolvedPromise.ts b/packages/core/src/hooks/useResolvedPromise.ts new file mode 100644 index 000000000..6ad76b0a6 --- /dev/null +++ b/packages/core/src/hooks/useResolvedPromise.ts @@ -0,0 +1,38 @@ +import { useState, useEffect } from 'react' +import { Falsy } from '../model' +import { deepEqual } from '../helpers' + +export function useResolvedPromise(promise: Promise | T | Falsy): T | Falsy { + const [resolvedValue, setResolvedValue] = useState(promise instanceof Promise ? undefined : promise) + + useEffect(() => { + let active = true // Flag to prevent setting state after component unmounts + + const resolvePromise = async () => { + if (!active) { + // We are already loading, don't start another request + // or the component has been unmounted + return + } + + // If the input is not a promise, it directly sets the resolved value + const value: T | Falsy = await promise + + // Check if the component is still mounted before setting the state + if (active) { + if (!deepEqual(resolvedValue, value)) { + // Calling setResolvedValue will trigger a rerender, so we only call it if the value has changed + setResolvedValue(value) + } + } + } + + void resolvePromise() + + return () => { + active = false // Cleanup to prevent state update after component unmounts + } + }, [promise]) // Rerun effect if the promise changes + + return resolvedValue +}