Feat/231-explorer-infinite-loading-blocks (#306)

* Making a start with react-window-infinite-loader for the blocks infinite scrolling

* WIP block explorer infinte scroll

* WIP pairing

* pairing tidying

* Applied refetch url params more cleanly, moved useFetch to react-helpers lib

* Add notice of new blocks created since page load, some cleanup of blocks-infinite-list.tsx component

* Attempting a refresh of the 'new blocks' value in blocks-refetch.tsx

* Correctly updating state based on previous value

* Update libs/react-helpers/src/hooks/use-fetch.ts

Co-authored-by: Matthew Russell <mattrussell36@gmail.com>

* Update libs/react-helpers/src/hooks/use-fetch.ts

Co-authored-by: Matthew Russell <mattrussell36@gmail.com>

* Update apps/explorer/src/app/components/blocks/blocks-refetch.tsx

Co-authored-by: Matthew Russell <mattrussell36@gmail.com>

* Cleanup from convos and PR

* Prettier formatting

* struggling with websocket tests

* struggling with websocket tests

* Progress on websocket tests for blocks-refetch.tsx

* Tests for blocks-refetch

* Tests for blocks-infinite-list

* Scroll test for blocks-infinite-list

* Defined NOOP in blocks-infinite-list.tsx

* Separate web sockets for each test

* Separate web sockets for each test

* Tweaked e2e tests to account for blocks taking longer to load

* fix tests

* Removed nx knowledge of empty simple-trading-app-e2e for now

* mock at use fetch level instead of at fetch level

* fix edge cases and add further tests

* fix failing e2e tests

* rename test

* Update apps/explorer-e2e/src/support/pages/blocks-page.ts

* Update apps/explorer-e2e/src/support/pages/blocks-page.ts

* rename

* test: use explicit wait for rather than times

* style: remove console

* test: correct env file

* Revert "test: correct env file"

This reverts commit d01d3cfa5e.

* think env var is incorrect

* correct env file

* fix flakiness

* add minor wait for test flakiness

* longer timeout

Co-authored-by: Dexter <dexter.edwards93@gmail.com>
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
This commit is contained in:
Sam Keen 2022-05-13 12:03:08 +01:00 committed by GitHub
parent 107171d46a
commit e0bcfe7bbe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 838 additions and 271 deletions

View File

@ -1,14 +1,16 @@
NX_CHAIN_EXPLORER_URL = "https://explorer.vega.trading/.netlify/functions/chain-explorer-api"
NX_TENDERMINT_URL = "https://lb.testnet.vega.xyz/tm"
NX_TENDERMINT_WEBSOCKET_URL = "wss://lb.testnet.vega.xyz/tm/websocket"
NX_VEGA_URL = "https://lb.testnet.vega.xyz/query"
NX_VEGA_ENV = 'TESTNET'
NX_VEGA_REST = 'https://lb.testnet.vega.xyz/datanode/rest'
NX_CHAIN_EXPLORER_URL="https://explorer.vega.trading/.netlify/functions/chain-explorer-api"
NX_TENDERMINT_URL="https://lb.testnet.vega.xyz/tm"
NX_TENDERMINT_WEBSOCKET_URL="wss://lb.testnet.vega.xyz/tm/websocket"
NX_VEGA_URL="https://lb.testnet.vega.xyz/query"
NX_VEGA_ENV='TESTNET'
NX_VEGA_REST='https://lb.testnet.vega.xyz/datanode/rest'
NX_EXPLORER_ASSETS = 1
NX_EXPLORER_GENESIS = 1
NX_EXPLORER_GOVERNANCE = 1
NX_EXPLORER_MARKETS = 1
NX_EXPLORER_NETWORK_PARAMETERS = 1
NX_EXPLORER_PARTIES = 1
NX_EXPLORER_VALIDATORS = 1
CYPRESS_VEGA_TENDERMINT_URL='https://lb.testnet.vega.xyz/tm'
NX_EXPLORER_ASSETS=1
NX_EXPLORER_GENESIS=1
NX_EXPLORER_GOVERNANCE=1
NX_EXPLORER_MARKETS=1
NX_EXPLORER_NETWORK_PARAMETERS=1
NX_EXPLORER_PARTIES=1
NX_EXPLORER_VALIDATORS=1

View File

@ -19,6 +19,9 @@ export default class BasePage {
}
navigateToBlocks() {
const base = Cypress.env('VEGA_TENDERMINT_URL');
const url = new URL('/tm/blockchain*', base).toString();
cy.intercept(url).as('blockChain');
cy.get(`a[href='${this.blocksUrl}']`).click();
}

View File

@ -15,7 +15,14 @@ export default class BlocksPage extends BasePage {
jumpToBlockInput = 'block-input';
jumpToBlockSubmit = 'go-submit';
private waitForBlocksResponse() {
cy.wait('@blockChain');
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(1000);
}
validateBlocksPageDisplayed() {
this.waitForBlocksResponse();
cy.getByTestId(this.blockRow).should('have.length.above', 1);
cy.getByTestId(this.blockHeight).first().should('not.be.empty');
cy.getByTestId(this.numberOfTransactions).first().should('not.be.empty');
@ -24,6 +31,7 @@ export default class BlocksPage extends BasePage {
}
clickOnTopBlockHeight() {
this.waitForBlocksResponse();
cy.getByTestId(this.blockHeight).first().click();
}

View File

@ -18,6 +18,14 @@ NX_URL=$URL
NX_DEPLOY_URL=$DEPLOY_URL
NX_DEPLOY_PRIME_URL=$DEPLOY_PRIME_URL
NX_CHAIN_EXPLORER_URL = "https://explorer.vega.trading/.netlify/functions/chain-explorer-api"
NX_TENDERMINT_URL = "https://lb.testnet.vega.xyz/tm"
NX_TENDERMINT_WEBSOCKET_URL = "wss://lb.testnet.vega.xyz/tm/websocket"
NX_VEGA_URL = "https://lb.testnet.vega.xyz/query"
NX_VEGA_ENV = 'TESTNET'
NX_VEGA_REST = 'https://lb.testnet.vega.xyz/datanode/rest'
CYPRESS_VEGA_TENDERMINT_URL='https://lb.testnet.vega.xyz/tm'
# App flags
NX_EXPLORER_ASSETS = 1
NX_EXPLORER_GENESIS = 1

View File

@ -15,7 +15,7 @@ export const BlockData = ({ block, className }: BlockProps) => {
return (
<TableWithTbody
aria-label={`Data for block ${block.header?.height}`}
className={className}
className={`${className} block-data-table`}
>
<TableRow data-testid="block-row" modifier="background">
<TableCell

View File

@ -0,0 +1,166 @@
import { BlocksInfiniteList } from './blocks-infinite-list';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
const generateBlocks = (number: number) => {
return Array.from(Array(number)).map((_) => ({
block_id: {
hash: '1C4D61AD80C250713EB996FB19B4537283083FB1878A98DA620872EE17A2103A',
parts: {
total: 1,
hash: '75A26A06BAA518A94B873229BF3A6DC5DD376A588C476B6416E181CF07F36FA1',
},
},
block_size: '1965',
header: {
version: {
block: '11',
},
chain_id: 'vega-mainnet-0006',
height: '4993074',
time: '2022-05-09T12:14:31.021164227Z',
last_block_id: {
hash: '3EA62A0EF7B668E1522EE06DC5395E2B8DE31B621C707B68D05F8AA66BD168EE',
parts: {
total: 1,
hash: '5B3CD9883FFA5282FB46B0A2D7C587FDCEF2DC1660DF105B6D67649A21AEF3C7',
},
},
last_commit_hash:
'2CDC8B12074A5A4FD67B95CEB573BFC1E91A58D2E839E03B2B3FC94214100D04',
data_hash:
'E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855',
validators_hash:
'C3F329FFD2DC0B5F51D6100669BAE705647D0ED016B82AFADE38AA1723ABE9D9',
next_validators_hash:
'C3F329FFD2DC0B5F51D6100669BAE705647D0ED016B82AFADE38AA1723ABE9D9',
consensus_hash:
'048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F',
app_hash:
'A7FFC6F8BF1ED76651C14756A061D662F580FF4DE43B49FA82D80A4B80F8434A3237DE294EFA38CE03133F411BFE0CEE5D6948CEA838B57C475143E2BF3A3D0CA1292C11CCDB876535C6699E8217E1A1294190D83E4233ECC490D32DF17A4116ADB0E5863D3F5309179F94FD63634116788C827BE5C6163F12DA68FAA77261CD',
last_results_hash:
'E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855',
evidence_hash:
'E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855',
proposer_address: '53D53755DA3B815AB029C0062DD6A2B392E640DC',
},
num_txs: '0',
}));
};
describe('Blocks infinite list', () => {
it('should display a "no items" message when no items provided', () => {
render(
<BlocksInfiniteList
blocks={undefined}
areBlocksLoading={false}
hasMoreBlocks={false}
loadMoreBlocks={() => null}
error={undefined}
/>
);
expect(screen.getByText('No items')).toBeInTheDocument();
});
it('error is displayed at item level', () => {
const blocks = generateBlocks(1);
render(
<BlocksInfiniteList
blocks={blocks}
areBlocksLoading={false}
hasMoreBlocks={false}
loadMoreBlocks={() => null}
error={Error('test error!')}
/>
);
expect(screen.getByText('Error: test error!')).toBeInTheDocument();
});
it('item renders data of n length into list of n length', () => {
// Provided the number of items doesn't exceed the 20 it initially
// desires, all blocks will initially render
const blocks = generateBlocks(10);
render(
<MemoryRouter>
<BlocksInfiniteList
blocks={blocks}
areBlocksLoading={false}
hasMoreBlocks={false}
loadMoreBlocks={() => null}
error={undefined}
/>
</MemoryRouter>
);
expect(
screen
.getByTestId('infinite-scroll-wrapper')
.querySelectorAll('.block-data-table')
).toHaveLength(10);
});
it('tries to load more items when required to initially fill the list', () => {
// For example, if initially rendering 15, the bottom of the list is
// in view of the viewport, and the callback should be executed
const blocks = generateBlocks(15);
const callback = jest.fn();
render(
<MemoryRouter>
<BlocksInfiniteList
blocks={blocks}
areBlocksLoading={false}
hasMoreBlocks={true}
loadMoreBlocks={callback}
error={undefined}
/>
</MemoryRouter>
);
expect(callback.mock.calls.length).toEqual(1);
});
it('does not try to load more items if there are no more', () => {
const blocks = generateBlocks(3);
const callback = jest.fn();
render(
<MemoryRouter>
<BlocksInfiniteList
blocks={blocks}
areBlocksLoading={false}
hasMoreBlocks={false}
loadMoreBlocks={callback}
error={undefined}
/>
</MemoryRouter>
);
expect(callback.mock.calls.length).toEqual(0);
});
it('loads more items is called when scrolled', () => {
const blocks = generateBlocks(20);
const callback = jest.fn();
render(
<MemoryRouter>
<BlocksInfiniteList
blocks={blocks}
areBlocksLoading={false}
hasMoreBlocks={true}
loadMoreBlocks={callback}
error={undefined}
/>
</MemoryRouter>
);
act(() => {
fireEvent.scroll(screen.getByTestId('infinite-scroll-wrapper'), {
target: { scrollY: 600 },
});
});
expect(callback.mock.calls.length).toEqual(1);
});
});

View File

@ -0,0 +1,84 @@
import React from 'react';
import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { t } from '@vegaprotocol/react-helpers';
import type { BlockMeta } from '../../routes/blocks/tendermint-blockchain-response';
import { BlockData } from './block-data';
interface BlocksInfiniteListProps {
hasMoreBlocks: boolean;
areBlocksLoading: boolean | undefined;
blocks: BlockMeta[] | undefined;
loadMoreBlocks: () => void;
error: Error | undefined;
className?: string;
}
interface ItemProps {
index: number;
style: React.CSSProperties;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
const NOOP = () => {};
export const BlocksInfiniteList = ({
hasMoreBlocks,
areBlocksLoading,
blocks,
loadMoreBlocks,
error,
className,
}: BlocksInfiniteListProps) => {
if (!blocks) {
return <div>No items</div>;
}
// If there are more items to be loaded then add an extra row to hold a loading indicator.
const itemCount = hasMoreBlocks ? blocks.length + 1 : blocks.length;
// Pass an empty callback to InfiniteLoader in case it asks us to load more than once.
// eslint-disable-next-line @typescript-eslint/no-empty-function
const loadMoreItems = areBlocksLoading ? NOOP : loadMoreBlocks;
// Every row is loaded except for our loading indicator row.
const isItemLoaded = (index: number) =>
!hasMoreBlocks || index < blocks.length;
const Item = ({ index, style }: ItemProps) => {
let content;
if (error) {
content = t(`${error}`);
} else if (!isItemLoaded(index)) {
content = t('Loading...');
} else {
content = <BlockData block={blocks[index]} />;
}
return <div style={style}>{content}</div>;
};
return (
<div className={className} data-testid="infinite-scroll-wrapper">
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={itemCount}
loadMoreItems={loadMoreItems}
>
{({ onItemsRendered, ref }) => (
<List
className="List"
height={585}
itemCount={itemCount}
itemSize={30}
onItemsRendered={onItemsRendered}
ref={ref}
width={'100%'}
>
{Item}
</List>
)}
</InfiniteLoader>
</div>
);
};

View File

@ -0,0 +1,142 @@
import WS from 'jest-websocket-mock';
import useWebSocket from 'react-use-websocket';
import {
render,
screen,
fireEvent,
act,
waitFor,
} from '@testing-library/react';
import { TendermintWebsocketContext } from '../../contexts/websocket/tendermint-websocket-context';
import { BlocksRefetch } from './blocks-refetch';
const BlocksRefetchInWebsocketProvider = ({
callback,
mocketLocation,
}: {
callback: () => null;
mocketLocation: string;
}) => {
const contextShape = useWebSocket(mocketLocation);
return (
<TendermintWebsocketContext.Provider value={{ ...contextShape }}>
<BlocksRefetch refetch={callback} />
</TendermintWebsocketContext.Provider>
);
};
describe('Blocks refetch', () => {
it('should render inner components', async () => {
const mocketLocation = 'wss:localhost:3002';
const mocket = new WS(mocketLocation, { jsonProtocol: true });
new WebSocket(mocketLocation);
render(
<BlocksRefetchInWebsocketProvider
callback={() => null}
mocketLocation={mocketLocation}
/>
);
await mocket.connected;
expect(screen.getByTestId('new-blocks')).toHaveTextContent('new blocks');
expect(screen.getByTestId('refresh')).toBeInTheDocument();
mocket.close();
});
it('should initiate callback when the button is clicked', async () => {
const mocketLocation = 'wss:localhost:3003';
const mocket = new WS(mocketLocation, { jsonProtocol: true });
new WebSocket(mocketLocation);
const callback = jest.fn();
render(
<BlocksRefetchInWebsocketProvider
callback={callback}
mocketLocation={mocketLocation}
/>
);
await mocket.connected;
const button = screen.getByTestId('refresh');
act(() => {
fireEvent.click(button);
});
expect(callback.mock.calls.length).toEqual(1);
mocket.close();
});
it('should show new blocks as websocket is correctly updated', async () => {
const mocketLocation = 'wss:localhost:3004';
const mocket = new WS(mocketLocation, { jsonProtocol: true });
new WebSocket(mocketLocation);
render(
<BlocksRefetchInWebsocketProvider
callback={() => null}
mocketLocation={mocketLocation}
/>
);
await mocket.connected;
// Ensuring we send an ID equal to the one the client subscribed with.
await waitFor(() => expect(mocket.messages.length).toEqual(1));
// @ts-ignore id on messages
const id = mocket.messages[0].id;
const newBlockMessage = {
id,
result: {
query: "tm.event = 'NewBlock'",
},
};
expect(screen.getByTestId('new-blocks')).toHaveTextContent('0 new blocks');
act(() => {
mocket.send(newBlockMessage);
});
expect(screen.getByTestId('new-blocks')).toHaveTextContent('1 new blocks');
act(() => {
mocket.send(newBlockMessage);
});
expect(screen.getByTestId('new-blocks')).toHaveTextContent('2 new blocks');
mocket.close();
});
it('will not show new blocks if websocket has wrong ID', async () => {
const mocketLocation = 'wss:localhost:3005';
const mocket = new WS(mocketLocation, { jsonProtocol: true });
new WebSocket(mocketLocation);
render(
<BlocksRefetchInWebsocketProvider
callback={() => null}
mocketLocation={mocketLocation}
/>
);
await mocket.connected;
// Ensuring we send an ID equal to the one the client subscribed with.
await waitFor(() => expect(mocket.messages.length).toEqual(1));
const newBlockMessageBadId = {
id: 'blahblahblah',
result: {
query: "tm.event = 'NewBlock'",
},
};
expect(screen.getByTestId('new-blocks')).toHaveTextContent('0 new blocks');
act(() => {
mocket.send(newBlockMessageBadId);
});
expect(screen.getByTestId('new-blocks')).toHaveTextContent('0 new blocks');
mocket.close();
});
});

View File

@ -1,3 +1,5 @@
import { useState, useEffect } from 'react';
import { useTendermintWebsocket } from '../../hooks/use-tendermint-websocket';
import { t } from '@vegaprotocol/react-helpers';
import { Button } from '@vegaprotocol/ui-toolkit';
@ -6,14 +8,35 @@ interface BlocksRefetchProps {
}
export const BlocksRefetch = ({ refetch }: BlocksRefetchProps) => {
const [blocksToLoad, setBlocksToLoad] = useState<number>(0);
const { messages } = useTendermintWebsocket({
query: "tm.event = 'NewBlock'",
});
useEffect(() => {
if (messages.length > 0) {
setBlocksToLoad((prev) => prev + 1);
}
}, [messages]);
const refresh = () => {
refetch();
setBlocksToLoad(0);
};
return (
<Button
onClick={() => refetch()}
variant="inline-link"
className="mb-28"
data-testid="refresh"
>
{t('Refresh to see latest blocks')}
</Button>
<>
<span data-testid="new-blocks">{blocksToLoad} new blocks -</span>
<Button
onClick={refresh}
variant="inline-link"
className="mb-28"
data-testid="refresh"
>
{t('refresh to see latest')}
</Button>
</>
);
};

View File

@ -20,7 +20,7 @@ export const Header = ({
<header className="grid grid-rows-2 grid-cols-[1fr_auto] md:flex md:col-span-2 p-16 gap-12 border-b-1">
<Link to={Routes.HOME}>
<h1
className="text-h3 font-alpha uppercase calt"
className="text-h3 font-alpha uppercase calt mb-2"
data-testid="explorer-header"
>
{t('Vega Explorer')}

View File

@ -1,4 +1,3 @@
import useFetch from '../../hooks/use-fetch';
import type { ChainExplorerTxResponse } from '../../routes/types/chain-explorer-response';
import { Routes } from '../../routes/route-names';
import { DATA_SOURCES } from '../../config';
@ -6,7 +5,7 @@ import { RenderFetched } from '../render-fetched';
import { TruncatedLink } from '../truncate/truncated-link';
import { TxOrderType } from './tx-order-type';
import { Table, TableRow, TableCell } from '../table';
import { t } from '@vegaprotocol/react-helpers';
import { t, useFetch } from '@vegaprotocol/react-helpers';
interface TxsPerBlockProps {
blockHeight: string | undefined;

View File

@ -1,97 +0,0 @@
import { useCallback, useEffect, useReducer, useRef } from 'react';
interface State<T> {
data?: T;
error?: Error;
loading?: boolean;
}
enum ActionType {
LOADING = 'LOADING',
ERROR = 'ERROR',
FETCHED = 'FETCHED',
}
// discriminated union type
type Action<T> =
| { type: ActionType.LOADING }
| { type: ActionType.FETCHED; payload: T }
| { type: ActionType.ERROR; error: Error };
function useFetch<T>(
url: string,
options?: RequestInit
): { state: State<T>; refetch: () => void } {
// Used to prevent state update if the component is unmounted
const cancelRequest = useRef<{ [key: string]: boolean }>({ [url]: false });
const initialState: State<T> = {
error: undefined,
data: undefined,
loading: false,
};
// Keep state logic separated
const fetchReducer = (state: State<T>, action: Action<T>): State<T> => {
switch (action.type) {
case ActionType.LOADING:
return { ...initialState, loading: true };
case ActionType.FETCHED:
return { ...initialState, data: action.payload, loading: false };
case ActionType.ERROR:
return { ...initialState, error: action.error, loading: false };
}
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
const fetchCallback = useCallback(() => {
if (!url) return;
const fetchData = async () => {
dispatch({ type: ActionType.LOADING });
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(response.statusText);
}
const data = (await response.json()) as T;
if (data && 'error' in data) {
// @ts-ignore - data.error
throw new Error(data.error);
}
if (cancelRequest.current[url]) return;
dispatch({ type: ActionType.FETCHED, payload: data });
} catch (error) {
if (cancelRequest.current[url]) return;
dispatch({ type: ActionType.ERROR, error: error as Error });
}
};
void fetchData();
// Do nothing if the url is not given
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
useEffect(() => {
const cancel = cancelRequest.current;
cancel[url] = false;
fetchCallback();
// Use the cleanup function for avoiding a possibly...
// ...state update after the component was unmounted
return () => {
cancel[url] = true;
};
}, [fetchCallback, url]);
return {
state,
refetch: fetchCallback,
};
}
export default useFetch;

View File

@ -1,29 +1,120 @@
import { useCallback, useState } from 'react';
import { DATA_SOURCES } from '../../../config';
import useFetch from '../../../hooks/use-fetch';
import type { TendermintBlockchainResponse } from '../tendermint-blockchain-response';
import type {
BlockMeta,
TendermintBlockchainResponse,
} from '../tendermint-blockchain-response';
import { RouteTitle } from '../../../components/route-title';
import { RenderFetched } from '../../../components/render-fetched';
import { BlocksData, BlocksRefetch } from '../../../components/blocks';
import { BlocksRefetch } from '../../../components/blocks';
import { BlocksInfiniteList } from '../../../components/blocks/blocks-infinite-list';
import { JumpToBlock } from '../../../components/jump-to-block';
import { t } from '@vegaprotocol/react-helpers';
import { t, useFetch } from '@vegaprotocol/react-helpers';
// This constant should only be changed if Tendermint API changes the max blocks returned
const TM_BLOCKS_PER_REQUEST = 20;
interface BlocksStateProps {
areBlocksLoading: boolean | undefined;
blocksError: Error | undefined;
blocksData: BlockMeta[];
hasMoreBlocks: boolean;
nextBlockHeightToLoad: number | undefined;
}
const Blocks = () => {
const [
{
areBlocksLoading,
blocksError,
blocksData,
hasMoreBlocks,
nextBlockHeightToLoad,
},
setBlocksState,
] = useState<BlocksStateProps>({
areBlocksLoading: false,
blocksError: undefined,
blocksData: [],
hasMoreBlocks: true,
nextBlockHeightToLoad: undefined,
});
const {
state: { data, error, loading },
state: { error, loading },
refetch,
} = useFetch<TendermintBlockchainResponse>(
`${DATA_SOURCES.tendermintUrl}/blockchain`
`${DATA_SOURCES.tendermintUrl}/blockchain`,
undefined,
false
);
const loadBlocks = useCallback(async () => {
setBlocksState((prev) => ({
...prev,
areBlocksLoading: loading,
}));
const maxHeight = Math.max(
Number(nextBlockHeightToLoad),
TM_BLOCKS_PER_REQUEST
);
const minHeight =
Number(nextBlockHeightToLoad) - TM_BLOCKS_PER_REQUEST > 1
? Number(nextBlockHeightToLoad) - TM_BLOCKS_PER_REQUEST - 1
: undefined;
const data = await refetch({
maxHeight,
minHeight,
});
if (data) {
const blockMetas = data.result.block_metas;
const lastBlockHeightLoaded =
blockMetas && blockMetas.length > 0
? parseInt(blockMetas[blockMetas.length - 1].header.height)
: undefined;
setBlocksState((prev) => ({
...prev,
nextBlockHeightToLoad: lastBlockHeightLoaded
? lastBlockHeightLoaded - 1
: undefined,
hasMoreBlocks: !!lastBlockHeightLoaded && lastBlockHeightLoaded > 1,
blocksData: [...prev.blocksData, ...blockMetas],
}));
}
if (error) {
setBlocksState((prev) => ({
...prev,
blocksError: error,
}));
}
}, [error, loading, nextBlockHeightToLoad, refetch]);
const refreshBlocks = useCallback(async () => {
setBlocksState((prev) => ({
...prev,
nextBlockHeightToLoad: undefined,
hasMoreBlocks: true,
blocksData: [],
}));
}, [setBlocksState]);
return (
<section>
<RouteTitle>{t('Blocks')}</RouteTitle>
<RenderFetched error={error} loading={loading}>
<>
<BlocksRefetch refetch={refetch} />
<BlocksData data={data} className="mb-28" />
</>
</RenderFetched>
<BlocksRefetch refetch={refreshBlocks} />
<BlocksInfiniteList
hasMoreBlocks={hasMoreBlocks}
areBlocksLoading={areBlocksLoading}
blocks={blocksData}
loadMoreBlocks={loadBlocks}
error={blocksError}
className="mb-28"
/>
<JumpToBlock />
</section>
);

View File

@ -2,10 +2,19 @@ import { Block } from './block';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Routes as RouteNames } from '../../route-names';
import { useFetch } from '@vegaprotocol/react-helpers';
jest.mock('@vegaprotocol/react-helpers', () => {
const original = jest.requireActual('@vegaprotocol/react-helpers');
return {
...original,
useFetch: jest.fn(),
};
});
const blockId = 1085890;
const createBlockResponse = () => {
const createBlockResponse = (id: number = blockId) => {
return {
jsonrpc: '2.0',
id: -1,
@ -58,7 +67,7 @@ const createBlockResponse = () => {
evidence: [],
},
last_commit: {
height: blockId.toString(),
height: id.toString(),
round: 0,
block_id: {
hash: 'C50CA169545AC1280220433D7971C50D941F675E9B0FFF358ABE8F3A7F74AE0E',
@ -110,9 +119,9 @@ const createBlockResponse = () => {
};
};
const renderComponent = () => {
const renderComponent = (id: number = blockId) => {
return (
<MemoryRouter initialEntries={[`/${RouteNames.BLOCKS}/${blockId}`]}>
<MemoryRouter initialEntries={[`/${RouteNames.BLOCKS}/${id}`]}>
<Routes>
<Route path={`/${RouteNames.BLOCKS}/:block`} element={<Block />} />
</Routes>
@ -121,31 +130,41 @@ const renderComponent = () => {
};
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(createBlockResponse()),
})
) as jest.Mock;
jest.useFakeTimers().setSystemTime(1648123348642);
});
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
jest.useRealTimers();
});
describe('Block', () => {
it('renders error state if error is present', async () => {
(useFetch as jest.Mock).mockReturnValue({
state: { data: null, loading: false, error: 'asd' },
});
render(renderComponent());
expect(screen.getByText(`BLOCK ${blockId}`)).toBeInTheDocument();
expect(screen.getByText('Error retrieving data')).toBeInTheDocument();
});
it('renders loading state if present', async () => {
(useFetch as jest.Mock).mockReturnValue({
state: { data: null, loading: true, error: null },
});
render(renderComponent());
expect(screen.getByText(`BLOCK ${blockId}`)).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should render title, proposer address and time mined', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(createBlockResponse()),
})
) as jest.Mock;
(useFetch as jest.Mock).mockReturnValue({
state: { data: createBlockResponse(), loading: false, error: null },
});
render(renderComponent());
await waitFor(() => screen.getByTestId('block-header'));
expect(screen.getByTestId('block-header')).toHaveTextContent(
`BLOCK ${blockId}`
);
@ -160,6 +179,9 @@ describe('Block', () => {
});
it('renders next and previous buttons', async () => {
(useFetch as jest.Mock).mockReturnValue({
state: { data: createBlockResponse(), loading: false, error: null },
});
render(renderComponent());
await waitFor(() => screen.getByTestId('block-header'));
@ -172,4 +194,15 @@ describe('Block', () => {
`/${RouteNames.BLOCKS}/${blockId + 1}`
);
});
it('disables previous button on block 1', async () => {
(useFetch as jest.Mock).mockReturnValue({
state: { data: createBlockResponse(1), loading: false, error: null },
});
render(renderComponent(1));
await waitFor(() => screen.getByTestId('block-header'));
expect(screen.getByTestId('previous-block-button')).toHaveAttribute(
'disabled'
);
});
});

View File

@ -1,7 +1,6 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { DATA_SOURCES } from '../../../config';
import useFetch from '../../../hooks/use-fetch';
import type { TendermintBlocksResponse } from '../tendermint-blocks-response';
import { RouteTitle } from '../../../components/route-title';
import { SecondsAgo } from '../../../components/seconds-ago';
@ -16,7 +15,7 @@ import { Button } from '@vegaprotocol/ui-toolkit';
import { Routes } from '../../route-names';
import { RenderFetched } from '../../../components/render-fetched';
import { HighlightedLink } from '../../../components/highlighted-link';
import { t } from '@vegaprotocol/react-helpers';
import { t, useFetch } from '@vegaprotocol/react-helpers';
const Block = () => {
const { block } = useParams<{ block: string }>();
@ -26,11 +25,6 @@ const Block = () => {
`${DATA_SOURCES.tendermintUrl}/block?height=${block}`
);
const header = blockData?.result.block.header;
if (!header) {
return <p>{t('Could not get block data')}</p>;
}
return (
<section>
<RouteTitle data-testid="block-header">{t(`BLOCK ${block}`)}</RouteTitle>
@ -42,6 +36,7 @@ const Block = () => {
to={`/${Routes.BLOCKS}/${Number(block) - 1}`}
>
<Button
data-testid="previous-block-button"
className="w-full"
disabled={Number(block) === 1}
variant="secondary"
@ -64,7 +59,7 @@ const Block = () => {
<TableCell modifier="bordered">
<HighlightedLink
to={`/${Routes.VALIDATORS}`}
text={header.proposer_address}
text={blockData?.result.block.header.proposer_address}
data-testid="block-validator"
/>
</TableCell>
@ -72,7 +67,10 @@ const Block = () => {
<TableRow modifier="bordered">
<TableHeader scope="row">Time</TableHeader>
<TableCell modifier="bordered">
<SecondsAgo data-testid="block-time" date={header.time} />
<SecondsAgo
data-testid="block-time"
date={blockData?.result.block.header.time}
/>
</TableCell>
</TableRow>
</TableWithTbody>

View File

@ -1,8 +1,7 @@
import { t } from '@vegaprotocol/react-helpers';
import { t, useFetch } from '@vegaprotocol/react-helpers';
import { RouteTitle } from '../../components/route-title';
import { SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
import { DATA_SOURCES } from '../../config';
import useFetch from '../../hooks/use-fetch';
import type { TendermintGenesisResponse } from './tendermint-genesis-response';
const Genesis = () => {

View File

@ -1,13 +1,12 @@
import { useQuery } from '@apollo/client';
import { gql } from '@apollo/client';
import { t } from '@vegaprotocol/react-helpers';
import { t, useFetch } from '@vegaprotocol/react-helpers';
import React from 'react';
import { useParams } from 'react-router-dom';
import { RouteTitle } from '../../../components/route-title';
import { SubHeading } from '../../../components/sub-heading';
import { SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
import { DATA_SOURCES } from '../../../config';
import useFetch from '../../../hooks/use-fetch';
import type { TendermintSearchTransactionResponse } from '../tendermint-transaction-response';
import type {
PartyAssetsQuery,

View File

@ -1,10 +1,9 @@
import React from 'react';
import { DATA_SOURCES } from '../../config';
import useFetch from '../../hooks/use-fetch';
import type { TendermintUnconfirmedTransactionsResponse } from '../txs/tendermint-unconfirmed-transactions-response.d';
import { TxList } from '../../components/txs';
import { RouteTitle } from '../../components/route-title';
import { t } from '@vegaprotocol/react-helpers';
import { t, useFetch } from '@vegaprotocol/react-helpers';
const PendingTxs = () => {
const {

View File

@ -1,4 +1,3 @@
import useFetch from '../../../hooks/use-fetch';
import type { TendermintBlockchainResponse } from '../../blocks/tendermint-blockchain-response';
import { DATA_SOURCES } from '../../../config';
import { RouteTitle } from '../../../components/route-title';
@ -6,7 +5,7 @@ import { BlocksRefetch } from '../../../components/blocks';
import { RenderFetched } from '../../../components/render-fetched';
import { BlockTxsData } from '../../../components/txs';
import { JumpToBlock } from '../../../components/jump-to-block';
import { t } from '@vegaprotocol/react-helpers';
import { t, useFetch } from '@vegaprotocol/react-helpers';
const Txs = () => {
const {

View File

@ -1,6 +1,6 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import useFetch from '../../../hooks/use-fetch';
import { useFetch } from '@vegaprotocol/react-helpers';
import type { TendermintTransactionResponse } from '../tendermint-transaction-response.d';
import type { ChainExplorerTxResponse } from '../../types/chain-explorer-response';
import { DATA_SOURCES } from '../../../config';

View File

@ -5,7 +5,7 @@ import { RouteTitle } from '../../components/route-title';
import { SubHeading } from '../../components/sub-heading';
import { SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
import { DATA_SOURCES } from '../../config';
import useFetch from '../../hooks/use-fetch';
import { useFetch } from '@vegaprotocol/react-helpers';
import type { TendermintValidatorsResponse } from './tendermint-validator-response';
import type { NodesQuery } from './__generated__/NodesQuery';

View File

@ -10,7 +10,8 @@
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"lib": ["es5", "es6", "dom", "dom.iterable"]
},
"files": [],
"include": [],

View File

@ -0,0 +1,26 @@
# React Environment Variables
# https://facebook.github.io/create-react-app/docs/adding-custom-environment-variables#expanding-environment-variables-in-env
# Netlify Environment Variables
# https://www.netlify.com/docs/continuous-deployment/#environment-variables
NX_VERSION=$npm_package_version
NX_REPOSITORY_URL=$REPOSITORY_URL
NX_BRANCH=$BRANCH
NX_PULL_REQUEST=$PULL_REQUEST
NX_HEAD=$HEAD
NX_COMMIT_REF=$COMMIT_REF
NX_CONTEXT=$CONTEXT
NX_REVIEW_ID=$REVIEW_ID
NX_INCOMING_HOOK_TITLE=$INCOMING_HOOK_TITLE
NX_INCOMING_HOOK_URL=$INCOMING_HOOK_URL
NX_INCOMING_HOOK_BODY=$INCOMING_HOOK_BODY
NX_URL=$URL
NX_DEPLOY_URL=$DEPLOY_URL
NX_DEPLOY_PRIME_URL=$DEPLOY_PRIME_URL
NX_VEGA_URL = "https://lb.testnet.vega.xyz/query"
NX_VEGA_ENV = 'TESTNET'
NX_VEGA_REST = 'https://lb.testnet.vega.xyz/datanode/rest'

View File

@ -0,0 +1,7 @@
describe('simple trading app', () => {
beforeEach(() => cy.visit('/'));
it('render', () => {
cy.get('#root').should('exist');
});
});

View File

@ -1,95 +0,0 @@
import { useCallback, useEffect, useReducer, useRef } from 'react';
interface State<T> {
data?: T;
error?: Error;
loading?: boolean;
}
enum ActionType {
LOADING = 'LOADING',
ERROR = 'ERROR',
FETCHED = 'FETCHED',
}
// discriminated union type
type Action<T> =
| { type: ActionType.LOADING }
| { type: ActionType.FETCHED; payload: T }
| { type: ActionType.ERROR; error: Error };
function useFetch<T = unknown>(
url?: string,
options?: RequestInit
): { state: State<T>; refetch: () => void } {
// Used to prevent state update if the component is unmounted
const cancelRequest = useRef<boolean>(false);
const initialState: State<T> = {
error: undefined,
data: undefined,
loading: false,
};
// Keep state logic separated
const fetchReducer = (state: State<T>, action: Action<T>): State<T> => {
switch (action.type) {
case ActionType.LOADING:
return { ...initialState, loading: true };
case ActionType.FETCHED:
return { ...initialState, data: action.payload, loading: false };
case ActionType.ERROR:
return { ...initialState, error: action.error, loading: false };
}
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
const fetchCallback = useCallback(() => {
if (!url) return;
const fetchData = async () => {
dispatch({ type: ActionType.LOADING });
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(response.statusText);
}
const data = (await response.json()) as T;
if ('error' in data) {
// @ts-ignore - data.error
throw new Error(data.error);
}
if (cancelRequest.current) return;
dispatch({ type: ActionType.FETCHED, payload: data });
} catch (error) {
if (cancelRequest.current) return;
dispatch({ type: ActionType.ERROR, error: error as Error });
}
};
void fetchData();
// Do nothing if the url is not given
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
useEffect(() => {
fetchCallback();
// Use the cleanup function for avoiding a possibly...
// ...state update after the component was unmounted
return () => {
cancelRequest.current = true;
};
}, [fetchCallback]);
return {
state,
refetch: fetchCallback,
};
}
export default useFetch;

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { SplashLoader } from '../../../components/splash-loader';
import useFetch from '../../../hooks/use-fetch';
import { useFetch } from '@vegaprotocol/react-helpers';
import { getDataNodeUrl } from '../../../lib/get-data-node-url';
import { Proposal } from '../components/proposal';
import { PROPOSALS_FRAGMENT } from '../proposal-fragment';

View File

@ -3,7 +3,10 @@ declare global {
namespace Cypress {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Chainable<Subject> {
getByTestId(selector: string): Chainable<JQuery<HTMLElement>>;
getByTestId(
selector: string,
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
): Chainable<JQuery<HTMLElement>>;
}
}
}

View File

@ -1,3 +1,4 @@
export * from './use-apply-grid-transaction';
export * from './use-data-provider';
export * from './use-theme-switcher';
export * from './use-fetch';

View File

@ -0,0 +1,117 @@
import { useCallback, useEffect, useReducer, useRef } from 'react';
interface State<T> {
data?: T;
error?: Error;
loading?: boolean;
}
enum ActionType {
LOADING = 'LOADING',
ERROR = 'ERROR',
FETCHED = 'FETCHED',
}
// discriminated union type
type Action<T> =
| { type: ActionType.LOADING }
| { type: ActionType.FETCHED; payload: T }
| { type: ActionType.ERROR; error: Error };
export const useFetch = <T>(
url: string,
options?: RequestInit,
initialFetch = true
): {
state: State<T>;
refetch: (
params?: Record<string, string | number | null | undefined> | undefined
) => Promise<T | undefined>;
} => {
// Used to prevent state update if the component is unmounted
const cancelRequest = useRef<boolean>(false);
const initialState: State<T> = {
error: undefined,
data: undefined,
loading: false,
};
// Keep state logic separated
const fetchReducer = (state: State<T>, action: Action<T>): State<T> => {
switch (action.type) {
case ActionType.LOADING:
return { ...initialState, loading: true };
case ActionType.FETCHED:
return { ...initialState, data: action.payload, loading: false };
case ActionType.ERROR:
return { ...initialState, error: action.error, loading: false };
}
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
const fetchCallback = useCallback(
async (params?: Record<string, string | null | undefined | number>) => {
if (!url) return;
const fetchData = async () => {
dispatch({ type: ActionType.LOADING });
let data;
try {
const assembledUrl = new URL(url);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value) {
assembledUrl.searchParams.set(key, value.toString());
}
}
}
const response = await fetch(assembledUrl.toString(), options);
if (!response.ok) {
throw new Error(response.statusText);
}
data = (await response.json()) as T;
if (data && 'error' in data) {
// @ts-ignore - data.error
throw new Error(data.error);
}
if (cancelRequest.current) return;
dispatch({ type: ActionType.FETCHED, payload: data });
} catch (error) {
if (cancelRequest.current) return;
dispatch({ type: ActionType.ERROR, error: error as Error });
}
return data;
};
return fetchData();
},
// Do nothing if the url is not given
// eslint-disable-next-line react-hooks/exhaustive-deps
[url]
);
useEffect(() => {
cancelRequest.current = false;
if (initialFetch) {
fetchCallback();
}
}, [fetchCallback, initialFetch, url]);
useEffect(() => {
// Use the cleanup function for avoiding a possibly...
// ...state update after the component was unmounted
return () => {
cancelRequest.current = true;
};
}, []);
return {
state,
refetch: fetchCallback,
};
};

View File

@ -64,6 +64,8 @@
"react-syntax-highlighter": "^15.4.5",
"react-use-websocket": "^3.0.0",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.7",
"react-window-infinite-loader": "^1.0.7",
"recharts": "^2.1.2",
"regenerator-runtime": "0.13.7",
"sha3": "^2.1.4",
@ -109,6 +111,8 @@
"@types/react-dom": "17.0.9",
"@types/react-router-dom": "5.3.1",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/react-window-infinite-loader": "^1.0.6",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "5.18.0",
"@typescript-eslint/parser": "5.18.0",
@ -130,6 +134,7 @@
"husky": "^7.0.4",
"jest": "27.2.3",
"jest-canvas-mock": "^2.3.1",
"jest-websocket-mock": "^2.3.0",
"lint-staged": "^12.3.3",
"nx": "13.10.1",
"prettier": "^2.5.1",

View File

@ -5586,6 +5586,21 @@
dependencies:
"@types/react" "*"
"@types/react-window-infinite-loader@^1.0.6":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@types/react-window-infinite-loader/-/react-window-infinite-loader-1.0.6.tgz#d7b23b4afaa1e0e2050876b766c3ea19f748f549"
integrity sha512-V8g8sBDLVeJJAfEENJS7VXZK+DRJ+jzPNtk8jpj2G+obhf+iqGNUDGwNWCbBhLiD+KpHhf3kWQlKBRi0tAeU4Q==
dependencies:
"@types/react" "*"
"@types/react-window" "*"
"@types/react-window@*", "@types/react-window@^1.8.5":
version "1.8.5"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@>=16.9.0":
version "18.0.1"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.1.tgz#1b2e02fb7613212518733946e49fb963dfc66e19"
@ -13873,7 +13888,7 @@ jest-config@^27.5.1:
slash "^3.0.0"
strip-json-comments "^3.1.1"
jest-diff@^27.0.0, jest-diff@^27.5.1:
jest-diff@^27.0.0, jest-diff@^27.0.2, jest-diff@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def"
integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==
@ -14252,6 +14267,14 @@ jest-watcher@^27.5.1:
jest-util "^27.5.1"
string-length "^4.0.1"
jest-websocket-mock@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/jest-websocket-mock/-/jest-websocket-mock-2.3.0.tgz#317e7d7f8ba54ba632a7300777b02b7ebb606845"
integrity sha512-kXhRRApRdT4hLG/4rhsfcR0Ke0OzqIsDj0P5t0dl5aiAftShSgoRqp/0pyjS5bh+b9GrIzmfkrV2cn9LxxvSvA==
dependencies:
jest-diff "^27.0.2"
mock-socket "^9.1.0"
jest-worker@27.0.0-next.5:
version "27.0.0-next.5"
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.0.0-next.5.tgz#5985ee29b12a4e191f4aae4bb73b97971d86ec28"
@ -15167,6 +15190,11 @@ memfs@^3.1.2, memfs@^3.2.2, memfs@^3.4.1:
dependencies:
fs-monkey "1.0.3"
"memoize-one@>=3.1.1 <6":
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
memoizerific@^1.11.3:
version "1.11.3"
resolved "https://registry.yarnpkg.com/memoizerific/-/memoizerific-1.11.3.tgz#7c87a4646444c32d75438570905f2dbd1b1a805a"
@ -15431,6 +15459,11 @@ mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1:
dependencies:
minimist "^1.2.6"
mock-socket@^9.1.0:
version "9.1.3"
resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.1.3.tgz#bcb106c6b345001fa7619466fcf2f8f5a156b10f"
integrity sha512-uz8lx8c5wuJYJ21f5UtovqpV0+KJuVwE7cVOLNhrl2QW/CvmstOLRfjXnLSbfFHZtJtiaSGQu0oCJA8SmRcK6A==
module-deps@^6.2.3:
version "6.2.3"
resolved "https://registry.yarnpkg.com/module-deps/-/module-deps-6.2.3.tgz#15490bc02af4b56cf62299c7c17cba32d71a96ee"
@ -17832,6 +17865,19 @@ react-virtualized-auto-sizer@^1.0.4, react-virtualized-auto-sizer@^1.0.6:
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz#66c5b1c9278064c5ef1699ed40a29c11518f97ca"
integrity sha512-7tQ0BmZqfVF6YYEWcIGuoR3OdYe8I/ZFbNclFlGOC3pMqunkYF/oL30NCjSGl9sMEb17AnzixDz98Kqc3N76HQ==
react-window-infinite-loader@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/react-window-infinite-loader/-/react-window-infinite-loader-1.0.7.tgz#958ef1a689d20dce122ef377583acd987760aee8"
integrity sha512-wg3LWkUpG21lhv+cZvNy+p0+vtclZw+9nP2vO6T9PKT50EN1cUq37Dq6FzcM38h/c2domE0gsUhb6jHXtGogAA==
react-window@^1.8.7:
version "1.8.7"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.7.tgz#5e9fd0d23f48f432d7022cdb327219353a15f0d4"
integrity sha512-JHEZbPXBpKMmoNO1bNhoXOOLg/ujhL/BU4IqVU9r8eQPcy5KQnGHIHDRkJ0ns9IM5+Aq5LNwt3j8t3tIrePQzA==
dependencies:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"
react@17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"