diff --git a/apps/trading/hooks/use-markets.ts b/apps/trading/hooks/use-markets.ts deleted file mode 100644 index 88b60e49f..000000000 --- a/apps/trading/hooks/use-markets.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { gql, useApolloClient } from '@apollo/client'; -import { - Markets, - Markets_markets, - MarketDataSub, - MarketDataSub_marketData, -} from '@vegaprotocol/graphql'; -import { useCallback, useEffect, useState } from 'react'; - -const MARKET_DATA_FRAGMENT = gql` - fragment MarketDataFields on MarketData { - market { - id - state - tradingMode - } - bestBidPrice - bestOfferPrice - markPrice - } -`; - -const MARKETS_QUERY = gql` - ${MARKET_DATA_FRAGMENT} - query Markets { - markets { - id - name - decimalPlaces - data { - ...MarketDataFields - } - tradableInstrument { - instrument { - code - product { - ... on Future { - settlementAsset { - symbol - } - } - } - } - } - } - } -`; - -const MARKET_DATA_SUB = gql` - ${MARKET_DATA_FRAGMENT} - subscription MarketDataSub { - marketData { - ...MarketDataFields - } - } -`; - -interface UseMarkets { - markets: Markets_markets[]; - error: Error | null; - loading: boolean; -} - -export const useMarkets = (): UseMarkets => { - const client = useApolloClient(); - const [markets, setMarkets] = useState([]); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - const mergeMarketData = useCallback((update: MarketDataSub_marketData) => { - setMarkets((curr) => { - return curr.map((m) => { - if (update.market.id === m.id) { - return { - ...m, - data: update, - }; - } - - return m; - }); - }); - }, []); - - // Make initial fetch - useEffect(() => { - const fetchOrders = async () => { - setLoading(true); - - try { - const res = await client.query({ - query: MARKETS_QUERY, - }); - - if (!res.data.markets?.length) return; - - setMarkets(res.data.markets); - } catch (err) { - setError(err); - } finally { - setLoading(false); - } - }; - - fetchOrders(); - }, [mergeMarketData, client]); - - // Start subscription - useEffect(() => { - const sub = client - // This data callback will unfortunately be called separately with an update for every market, - // perhaps we should batch this somehow... - .subscribe({ - query: MARKET_DATA_SUB, - }) - .subscribe(({ data }) => { - mergeMarketData(data.marketData); - }); - - return () => { - if (sub) { - sub.unsubscribe(); - } - }; - }, [client, mergeMarketData]); - - return { markets, error, loading }; -}; diff --git a/apps/trading/pages/markets/index.page.tsx b/apps/trading/pages/markets/index.page.tsx index fd909eeca..f7a538413 100644 --- a/apps/trading/pages/markets/index.page.tsx +++ b/apps/trading/pages/markets/index.page.tsx @@ -1,17 +1,76 @@ -import { Markets } from '@vegaprotocol/graphql'; +import { useState, useEffect, useRef } from 'react'; +import { produce } from 'immer'; +import assign from 'assign-deep'; +import { useApolloClient } from '@apollo/client'; import { useRouter } from 'next/router'; -import { MarketListTable } from '@vegaprotocol/market-list'; -import { useMarkets } from '../../hooks/use-markets'; import { AsyncRenderer } from '../../components/async-renderer'; +import { MarketListTable, getRowNodeId } from '@vegaprotocol/market-list'; +import { + Markets_markets, + Markets_markets_data, + MarketsDataProviderCallbackArg, + marketsDataProvider, +} from '@vegaprotocol/graphql'; + +import type { AgGridReact } from 'ag-grid-react'; const Markets = () => { const { pathname, push } = useRouter(); - const { markets, error, loading } = useMarkets(); + const [markets, setMarkets] = useState(undefined); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(undefined); + const client = useApolloClient(); + const gridRef = useRef(); + const initialized = useRef(false); + + useEffect(() => { + return marketsDataProvider( + client, + ({ data, error, loading, delta }: MarketsDataProviderCallbackArg) => { + setError(error); + setLoading(loading); + if (!error && !loading) { + if (!initialized.current || !gridRef.current) { + initialized.current = true; + setMarkets(data); + } else { + const update: Markets_markets[] = []; + const add: Markets_markets[] = []; + + // split into updates and adds + if (!gridRef.current) return; + const rowNode = gridRef.current.api.getRowNode( + getRowNodeId(delta.market) + ); + + if (rowNode) { + const updatedData = produce( + rowNode.data.data, + (draft: Markets_markets_data) => assign(draft, delta) + ); + if (updatedData !== rowNode.data.data) { + update.push({ ...rowNode.data, data: delta }); + } + } + // @TODO - else add new market + if (update.length || add.length) { + gridRef.current.api.applyTransactionAsync({ + update, + add, + addIndex: 0, + }); + } + } + } + } + ); + }, [client, initialized]); return ( {(data) => ( push(`${pathname}/${id}?portfolio=orders&trade=orderbook`) diff --git a/libs/graphql/src/data-providers/index.ts b/libs/graphql/src/data-providers/index.ts new file mode 100644 index 000000000..8da2b0152 --- /dev/null +++ b/libs/graphql/src/data-providers/index.ts @@ -0,0 +1 @@ +export * from './markets-data-provider'; diff --git a/libs/graphql/src/data-providers/markets-data-provider.ts b/libs/graphql/src/data-providers/markets-data-provider.ts new file mode 100644 index 000000000..6309de1db --- /dev/null +++ b/libs/graphql/src/data-providers/markets-data-provider.ts @@ -0,0 +1,193 @@ +import { gql } from '@apollo/client'; +import { produce } from 'immer'; +import type { ApolloClient } from '@apollo/client'; +import type { Subscription } from 'zen-observable-ts'; +import { Markets, Markets_markets } from '../__generated__/Markets'; + +import { + MarketDataSub, + MarketDataSub_marketData, +} from '../__generated__/MarketDataSub'; + +const MARKET_DATA_FRAGMENT = gql` + fragment MarketDataFields on MarketData { + market { + id + state + tradingMode + } + bestBidPrice + bestOfferPrice + markPrice + } +`; + +const MARKETS_QUERY = gql` + ${MARKET_DATA_FRAGMENT} + query Markets { + markets { + id + name + decimalPlaces + data { + ...MarketDataFields + } + tradableInstrument { + instrument { + code + product { + ... on Future { + settlementAsset { + symbol + } + } + } + } + } + } + } +`; + +const MARKET_DATA_SUB = gql` + ${MARKET_DATA_FRAGMENT} + subscription MarketDataSub { + marketData { + ...MarketDataFields + } + } +`; + +export interface MarketsDataProviderCallbackArg { + data: Markets_markets[] | null; + error?: Error; + loading: boolean; + delta?: MarketDataSub_marketData; +} + +export interface MarketsDataProviderCallback { + (arg: MarketsDataProviderCallbackArg): void; +} + +const callbacks: MarketsDataProviderCallback[] = []; +const updateQueue: MarketDataSub_marketData[] = []; + +let data: Markets_markets[] | null = null; +let error: Error | undefined = undefined; +let loading = false; +let client: ApolloClient | undefined = undefined; +let subscription: Subscription | undefined = undefined; + +const notify = ( + callback: MarketsDataProviderCallback, + delta?: MarketDataSub_marketData +) => { + callback({ + data, + error, + loading, + delta, + }); +}; + +const notifyAll = (delta?: MarketDataSub_marketData) => { + callbacks.forEach((callback) => notify(callback, delta)); +}; + +const update = ( + draft: Markets_markets[] | null, + delta: MarketDataSub_marketData +) => { + if (!draft) { + return; + } + const index = draft.findIndex((m) => m.id === delta.market.id); + if (index !== -1) { + draft[index].data = delta; + } + // @TODO - else push new market to draft +}; + +const initialize = async () => { + if (subscription) { + return; + } + loading = true; + error = undefined; + notifyAll(); + if (!client) { + return; + } + subscription = client + .subscribe({ + query: MARKET_DATA_SUB, + }) + .subscribe(({ data: delta }) => { + if (!delta) { + return; + } + if (loading) { + updateQueue.push(delta.marketData); + } else { + const newData = produce(data, (draft) => { + update(draft, delta.marketData); + }); + if (newData === data) { + return; + } + data = newData; + notifyAll(delta.marketData); + } + }); + try { + const res = await client.query({ + query: MARKETS_QUERY, + }); + data = res.data.markets; + if (updateQueue && updateQueue.length > 0) { + data = produce(data, (draft) => { + while (updateQueue.length) { + const delta = updateQueue.shift(); + if (delta) { + update(draft, delta); + } + } + }); + } + } catch (e) { + error = e as Error; + subscription.unsubscribe(); + subscription = undefined; + } finally { + loading = false; + notifyAll(); + } +}; + +const unsubscribe = (callback: MarketsDataProviderCallback) => { + callbacks.splice(callbacks.indexOf(callback), 1); + if (callbacks.length === 0) { + if (subscription) { + subscription.unsubscribe(); + subscription = undefined; + } + data = null; + error = undefined; + loading = false; + } +}; + +export const marketsDataProvider = ( + c: ApolloClient, + callback: MarketsDataProviderCallback +) => { + if (!client) { + client = c; + } + callbacks.push(callback); + if (callbacks.length === 1) { + initialize(); + } else { + notify(callback); + } + return () => unsubscribe(callback); +}; diff --git a/libs/graphql/src/index.ts b/libs/graphql/src/index.ts index 59c3a0077..37e47e81f 100644 --- a/libs/graphql/src/index.ts +++ b/libs/graphql/src/index.ts @@ -14,3 +14,5 @@ export * from './__generated__/Orders'; export * from './__generated__/OrderSub'; export * from './__generated__/PartyAssetsQuery'; export * from './__generated__/ProposalsQuery'; + +export * from './data-providers'; diff --git a/libs/market-list/src/lib/market-list-table.spec.tsx b/libs/market-list/src/lib/market-list-table.spec.tsx index 1989c52d2..19d64ed6c 100644 --- a/libs/market-list/src/lib/market-list-table.spec.tsx +++ b/libs/market-list/src/lib/market-list-table.spec.tsx @@ -1,14 +1,17 @@ import { render } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; import { MockedProvider } from '@apollo/react-testing'; import MarketListTable from './market-list-table'; describe('MarketListTable', () => { - it('should render successfully', () => { - const { baseElement } = render( - - - - ); - expect(baseElement).toBeTruthy(); + it('should render successfully', async () => { + await act(async () => { + const { baseElement } = render( + + + + ); + expect(baseElement).toBeTruthy(); + }); }); }); diff --git a/libs/market-list/src/lib/market-list-table.tsx b/libs/market-list/src/lib/market-list-table.tsx index 89295fe20..a3a567a46 100644 --- a/libs/market-list/src/lib/market-list-table.tsx +++ b/libs/market-list/src/lib/market-list-table.tsx @@ -1,89 +1,83 @@ -import type { GridApi, ValueFormatterParams } from 'ag-grid-community'; -import { - PriceCell, - formatNumber, - useApplyGridTransaction, -} from '@vegaprotocol/react-helpers'; +import { forwardRef } from 'react'; +import type { ValueFormatterParams } from 'ag-grid-community'; +import { PriceCell, formatNumber } from '@vegaprotocol/react-helpers'; import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit'; import { Markets_markets } from '@vegaprotocol/graphql'; import { AgGridColumn } from 'ag-grid-react'; -import { useRef, useState } from 'react'; +import type { AgGridReact } from 'ag-grid-react'; +import { useState } from 'react'; interface MarketListTableProps { markets: Markets_markets[]; onRowClicked: (marketId: string) => void; } -export const MarketListTable = ({ - markets, - onRowClicked, -}: MarketListTableProps) => { - const [initialMarkets] = useState(markets); - const gridApi = useRef(null); - useApplyGridTransaction(markets, gridApi.current); +export const getRowNodeId = (data: { id: string }) => data.id; - return ( - data.id} - suppressCellFocus={true} - defaultColDef={{ - flex: 1, - resizable: true, - }} - onGridReady={(params) => { - gridApi.current = params.api; - }} - onRowClicked={({ data }) => onRowClicked(data.id)} - components={{ PriceCell }} - > - - - - `${value.market.state} (${value.market.tradingMode})` +export const MarketListTable = forwardRef( + ({ markets, onRowClicked }, ref) => { + return ( + + onRowClicked(data.id) } - /> - - formatNumber(value, data.decimalPlaces) - } - /> - - formatNumber(value, data.decimalPlaces) - } - cellRenderer="PriceCell" - /> - - formatNumber(value, data.decimalPlaces) - } - /> - - - ); -}; + components={{ PriceCell }} + > + + + + `${value.market.state} (${value.market.tradingMode})` + } + /> + + formatNumber(value, data.decimalPlaces) + } + /> + + formatNumber(value, data.decimalPlaces) + } + cellRenderer="PriceCell" + /> + + formatNumber(value, data.decimalPlaces) + } + /> + + + ); + } +); export default MarketListTable; diff --git a/libs/react-helpers/src/lib/grid-cells/flash-cell.stories.tsx b/libs/react-helpers/src/lib/grid-cells/flash-cell.stories.tsx new file mode 100644 index 000000000..6530aae60 --- /dev/null +++ b/libs/react-helpers/src/lib/grid-cells/flash-cell.stories.tsx @@ -0,0 +1,21 @@ +import { FlashCell } from './flash-cell'; +import { Meta, Story } from '@storybook/react'; +import * as React from 'react'; + +export default { + title: 'Component/FlashCell', + argTypes: { + value: { + control: { type: 'range', min: -20, max: 20, step: 1 }, + }, + }, +} as Meta; + +const Template: Story<{ value: number }> = ({ value }) => ( + {value.toFixed(0)} +); + +export const Basic = Template.bind({}); +Basic.args = { + value: 100, +}; diff --git a/libs/react-helpers/src/lib/grid-cells/flash-cell.test.tsx b/libs/react-helpers/src/lib/grid-cells/flash-cell.test.tsx new file mode 100644 index 000000000..14c598e82 --- /dev/null +++ b/libs/react-helpers/src/lib/grid-cells/flash-cell.test.tsx @@ -0,0 +1,34 @@ +import { findFirstDiffPos } from './flash-cell'; + +describe('findFirstDiffPos', () => { + it('Returns -1 for matching strings', () => { + const a = 'test'; + const b = 'test'; + + expect(findFirstDiffPos(a, b)).toEqual(-1); + }); + + it('Returns -1 if a string is undefined (just in case)', () => { + const a = 'test'; + const b = undefined as any as string; + + expect(findFirstDiffPos(a, b)).toEqual(-1); + expect(findFirstDiffPos(b, a)).toEqual(-1); + }); + + it('Returns -1 if one string is empty', () => { + const a = 'test'; + const b = ''; + + expect(findFirstDiffPos(a, b)).toEqual(-1); + expect(findFirstDiffPos(b, a)).toEqual(-1); + }); + + it('Happy path', () => { + const a = 'test'; + + expect(findFirstDiffPos(a, 'test')).toEqual(-1); + expect(findFirstDiffPos(a, '!est')).toEqual(0); + expect(findFirstDiffPos(a, 't!st')).toEqual(1); + }); +}); diff --git a/libs/react-helpers/src/lib/grid-cells/price-cell.test.tsx b/libs/react-helpers/src/lib/grid-cells/price-cell.test.tsx new file mode 100644 index 000000000..aec62c3b9 --- /dev/null +++ b/libs/react-helpers/src/lib/grid-cells/price-cell.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import * as React from 'react'; + +import { PriceCell } from './price-cell'; + +describe('', () => { + it('Displayes formatted value', () => { + render(); + expect(screen.getByTestId('price')).toHaveTextContent('100.00'); + }); + it('Displayes 0', () => { + render(); + expect(screen.getByTestId('price')).toHaveTextContent('0.00'); + }); + + it('Displayes - if value is not a number', () => { + render(); + expect(screen.getByTestId('price')).toHaveTextContent('-'); + }); +}); diff --git a/libs/react-helpers/src/lib/grid-cells/price-cell.tsx b/libs/react-helpers/src/lib/grid-cells/price-cell.tsx index 27daa50d1..1b9102784 100644 --- a/libs/react-helpers/src/lib/grid-cells/price-cell.tsx +++ b/libs/react-helpers/src/lib/grid-cells/price-cell.tsx @@ -6,12 +6,12 @@ export interface IPriceCellProps { } export const PriceCell = ({ value, valueFormatted }: IPriceCellProps) => { - if (!value || isNaN(Number(value))) return -; + if ((!value && value !== 0) || isNaN(Number(value))) { + return -; + } return ( - - - {valueFormatted} - + + {valueFormatted} ); }; diff --git a/libs/ui-toolkit/src/components/ag-grid/ag-grid-dynamic-themed.tsx b/libs/ui-toolkit/src/components/ag-grid/ag-grid-dynamic-themed.tsx index a931c6527..9579616df 100644 --- a/libs/ui-toolkit/src/components/ag-grid/ag-grid-dynamic-themed.tsx +++ b/libs/ui-toolkit/src/components/ag-grid/ag-grid-dynamic-themed.tsx @@ -18,10 +18,12 @@ const AgGridDarkTheme = dynamic<{ children: React.ReactElement }>( export const AgGridThemed = ({ style, className, + gridRef, ...props }: (AgGridReactProps | AgReactUiProps) & { style?: React.CSSProperties; className?: string; + gridRef?: React.ForwardedRef; }) => { const theme = React.useContext(ThemeContext); const defaultProps = { rowHeight: 20, headerHeight: 22 }; @@ -34,11 +36,11 @@ export const AgGridThemed = ({ > {theme === 'dark' ? ( - + ) : ( - + )} diff --git a/libs/ui-toolkit/src/components/ag-grid/ag-grid-dynamic.tsx b/libs/ui-toolkit/src/components/ag-grid/ag-grid-dynamic.tsx index 37eb6e6d9..1fcc41129 100644 --- a/libs/ui-toolkit/src/components/ag-grid/ag-grid-dynamic.tsx +++ b/libs/ui-toolkit/src/components/ag-grid/ag-grid-dynamic.tsx @@ -1,15 +1,28 @@ +import * as React from 'react'; import dynamic from 'next/dynamic'; -import type { AgGridReactProps, AgReactUiProps } from 'ag-grid-react'; +import type { + AgGridReactProps, + AgReactUiProps, + AgGridReact, +} from 'ag-grid-react'; + +type Props = (AgGridReactProps | AgReactUiProps) & { + style?: React.CSSProperties; + className?: string; + gridRef?: React.Ref; +}; // https://stackoverflow.com/questions/69433673/nextjs-reactdomserver-does-not-yet-support-suspense -export const AgGridDynamic = dynamic< - (AgGridReactProps | AgReactUiProps) & { - style?: React.CSSProperties; - className?: string; +const AgGridDynamicInternal = dynamic( + () => import('./ag-grid-dynamic-themed').then((mod) => mod.AgGridThemed), + { + ssr: false, + // https://nextjs.org/docs/messages/invalid-dynamic-suspense + // suspense: true } ->(() => import('./ag-grid-dynamic-themed').then((mod) => mod.AgGridThemed), { - ssr: false, - // https://nextjs.org/docs/messages/invalid-dynamic-suspense - // suspense: true -}); +); + +export const AgGridDynamic = React.forwardRef( + (props, ref) => +); diff --git a/libs/ui-toolkit/src/components/ag-grid/ag-grid-lazy-themed.tsx b/libs/ui-toolkit/src/components/ag-grid/ag-grid-lazy-themed.tsx index 54da4a860..8d8b160e9 100644 --- a/libs/ui-toolkit/src/components/ag-grid/ag-grid-lazy-themed.tsx +++ b/libs/ui-toolkit/src/components/ag-grid/ag-grid-lazy-themed.tsx @@ -16,14 +16,13 @@ const AgGridDarkTheme = React.lazy(() => })) ); -export const AgGridThemed = ({ - style, - className, - ...props -}: (AgGridReactProps | AgReactUiProps) & { - style?: React.CSSProperties; - className?: string; -}) => { +export const AgGridThemed = React.forwardRef< + AgGridReact, + (AgGridReactProps | AgReactUiProps) & { + style?: React.CSSProperties; + className?: string; + } +>(({ style, className, ...props }, ref) => { const theme = React.useContext(ThemeContext); return (
{theme === 'dark' ? ( - + ) : ( - + )}
); -}; +}); diff --git a/libs/ui-toolkit/src/components/ag-grid/ag-grid-lazy.tsx b/libs/ui-toolkit/src/components/ag-grid/ag-grid-lazy.tsx index 9d73d49ad..75bc5218b 100644 --- a/libs/ui-toolkit/src/components/ag-grid/ag-grid-lazy.tsx +++ b/libs/ui-toolkit/src/components/ag-grid/ag-grid-lazy.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; -import type { AgGridReactProps, AgReactUiProps } from 'ag-grid-react'; +import type { AgGridReact } from 'ag-grid-react'; -const LazyAgGridStyled = React.lazy(() => +export const AgGridLazyInternal = React.lazy(() => import('./ag-grid-lazy-themed').then((module) => ({ default: module.AgGridThemed, })) ); -export const AgGridLazy = ( - props: (AgGridReactProps | AgReactUiProps) & { style: React.CSSProperties } -) => ; +export const AgGridLazy = React.forwardRef((props, ref) => ( + +)); diff --git a/package.json b/package.json index 1b268ddf0..80cbbdbde 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "private": true, "dependencies": { "@apollo/client": "^3.5.8", - "@apollo/react-testing": "^4.0.0", "@blueprintjs/icons": "^3.32.0", "@nrwl/next": "13.8.1", "@radix-ui/react-dialog": "^0.1.5", @@ -32,6 +31,7 @@ "ag-grid-community": "^27.0.1", "ag-grid-react": "^27.0.1", "apollo": "^2.33.9", + "assign-deep": "^1.0.1", "autoprefixer": "^10.4.2", "bignumber.js": "^9.0.2", "classnames": "^2.3.1", @@ -42,6 +42,7 @@ "graphql": "^15.7.2", "graphql-ws": "^5.6.3", "lodash": "^4.17.21", + "immer": "^9.0.12", "next": "12.0.7", "nx": "^13.8.3", "postcss": "^8.4.6", @@ -59,6 +60,7 @@ "web-vitals": "^2.1.4" }, "devDependencies": { + "@apollo/react-testing": "^4.0.0", "@babel/core": "7.12.13", "@babel/preset-typescript": "7.12.13", "@nrwl/cli": "13.8.1", diff --git a/yarn.lock b/yarn.lock index 1821b6c12..72b126202 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7026,11 +7026,23 @@ assertion-error@^1.1.0: resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== +assign-deep@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/assign-deep/-/assign-deep-1.0.1.tgz#b6d21d74e2f28bf6592e4c0c541bed6ab59c5f27" + integrity sha512-CSXAX79mibneEYfqLT5FEmkqR5WXF+xDRjgQQuVf6wSCXCYU8/vHttPidNar7wJ5BFmKAo8Wei0rCtzb+M/yeA== + dependencies: + assign-symbols "^2.0.2" + assign-symbols@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= +assign-symbols@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-2.0.2.tgz#0fb9191dd9d617042746ecfc354f3a3d768a0c98" + integrity sha512-9sBQUQZMKFKcO/C3Bo6Rx4CQany0R0UeVcefNGRRdW2vbmaMOhV1sbmlXcQLcD56juLXbSGTBm0GGuvmrAF8pA== + ast-types-flow@^0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" @@ -12484,6 +12496,11 @@ image-size@~0.5.0: resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" integrity sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w= +immer@^9.0.12: + version "9.0.12" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20" + integrity sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA== + immutable@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0.tgz#b86f78de6adef3608395efb269a91462797e2c23"