Repository: ripple/explorer (ts)

Issue:
custom mode has trouble connecting when no port is specified.
The App component re-renders twice and causes the `xrpl-client` to be double instantiated. 

Pull request description:
fix: custom mode double initialized xrpl-client
## High Level Overview of Change

Dynamically determining the VHS network id was causing the sockets to be reinitialized when the response was received. This moves the logic into their own components with their own scope that doesn't trigger everything to be called again.  This would only happen in custom mode which is when network id is unknown upon load.

Fixes #645

### Type of Change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] Refactor (non-breaking change that only restructures code)
- [x] Tests (You added tests for code that already exists, or your new feature included in this PR)

Patch:
diff --git a/src/containers/App/App.tsx b/src/containers/App/App.tsx
index 1cb1fe47..25b2a5c9 100644
--- a/src/containers/App/App.tsx
+++ b/src/containers/App/App.tsx
@@ -1,10 +1,9 @@
-import { useEffect, useState } from 'react'
+import { useEffect } from 'react'
 import { useLocation, useParams } from 'react-router'
 import { Switch, Route, Redirect, BrowserRouter } from 'react-router-dom'
 import { connect } from 'react-redux'
 import { bindActionCreators } from 'redux'
 import { QueryClientProvider } from 'react-query'
-import axios from 'axios'
 import { AccountsRouter } from '../Accounts/AccountsRouter'
 import { updateViewportDimensions, onScroll } from './actions'
 import Ledgers from '../Ledgers'
@@ -18,10 +17,9 @@ import paystrings from '../PayStrings'
 import token from '../Token'
 import noMatch from '../NoMatch'
 import { NFT } from '../NFT/NFT'
-import SocketContext, { getSocket } from '../shared/SocketContext'
+import { SocketProvider } from '../shared/SocketContext'
 import { queryClient } from '../shared/QueryClient'
-import NetworkContext, { getNetworkName } from '../shared/NetworkContext'
-import Log from '../shared/log'
+import { NetworkProvider } from '../shared/NetworkContext'
 
 export interface AppProps {
   actions: {
@@ -34,10 +32,6 @@ const App = ({ actions }: AppProps) => {
   const location = useLocation()
   const { rippledUrl = undefined } = useParams<{ rippledUrl: string }>()
 
-  const initialNetworkName = getNetworkName()
-  const [networkName, setNetworkName] = useState(initialNetworkName)
-  const socket = getSocket(rippledUrl)
-
   useEffect(() => {
     actions.updateViewportDimensions()
     window.addEventListener('resize', actions.updateViewportDimensions)
@@ -46,28 +40,9 @@ const App = ({ actions }: AppProps) => {
     return function cleanup() {
       window.removeEventListener('resize', actions.updateViewportDimensions)
       window.removeEventListener('scroll', actions.onScroll)
-
-      socket.close()
-      if (socket.p2pSocket !== undefined) {
-        socket.p2pSocket.close()
-      }
     }
   })
 
-  useEffect(() => {
-    if (initialNetworkName == null) {
-      axios
-        .get(`${process.env.VITE_DATA_URL}/get_network/${rippledUrl}`)
-        .then((resp) => resp.data)
-        .then((data) =>
-          setNetworkName(
-            data.result && data.result === 'error' ? null : data.network,
-          ),
-        )
-        .catch((e) => Log.error(e))
-    }
-  }, [initialNetworkName, rippledUrl])
-
   const urlLink = rippledUrl ? `/${rippledUrl}` : ''
 
   /* START: Map legacy routes to new routes */
@@ -98,8 +73,8 @@ const App = ({ actions }: AppProps) => {
   return (
     <div className="app">
       <QueryClientProvider client={queryClient}>
-        <SocketContext.Provider value={socket}>
-          <NetworkContext.Provider value={networkName}>
+        <SocketProvider rippledUrl={rippledUrl}>
+          <NetworkProvider rippledUrl={rippledUrl}>
             <BrowserRouter basename={rippledUrl ?? ''}>
               <Header />
               <div className="content">
@@ -129,8 +104,8 @@ const App = ({ actions }: AppProps) => {
                 </Switch>
               </div>
             </BrowserRouter>
-          </NetworkContext.Provider>
-        </SocketContext.Provider>
+          </NetworkProvider>
+        </SocketProvider>
       </QueryClientProvider>
     </div>
   )
diff --git a/src/containers/shared/NetworkContext.ts b/src/containers/shared/NetworkContext.ts
deleted file mode 100644
index fc3ed156..00000000
--- a/src/containers/shared/NetworkContext.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from 'react'
-
-const ENV_NETWORK_MAP: Record<string, string> = {
-  mainnet: 'main',
-  testnet: 'test',
-  devnet: 'dev',
-  nft_sandbox: 'nft-dev',
-  amm: 'amm-dev',
-}
-
-function getNetworkName() {
-  if (
-    process.env.VITE_ENVIRONMENT &&
-    process.env.VITE_ENVIRONMENT in ENV_NETWORK_MAP
-  ) {
-    return ENV_NETWORK_MAP[process.env.VITE_ENVIRONMENT]
-  }
-  return undefined
-}
-
-const NetworkContext = React.createContext(getNetworkName())
-
-export { getNetworkName }
-
-export default NetworkContext
diff --git a/src/containers/shared/NetworkContext.tsx b/src/containers/shared/NetworkContext.tsx
new file mode 100644
index 00000000..726a2ff0
--- /dev/null
+++ b/src/containers/shared/NetworkContext.tsx
@@ -0,0 +1,60 @@
+import React, { useEffect, useState } from 'react'
+import axios from 'axios'
+import Log from './log'
+
+const ENV_NETWORK_MAP: Record<string, string> = {
+  mainnet: 'main',
+  testnet: 'test',
+  devnet: 'dev',
+  nft_sandbox: 'nft-dev',
+  amm: 'amm-dev',
+}
+
+function getNetworkName() {
+  if (
+    process.env.VITE_ENVIRONMENT &&
+    process.env.VITE_ENVIRONMENT in ENV_NETWORK_MAP
+  ) {
+    return ENV_NETWORK_MAP[process.env.VITE_ENVIRONMENT]
+  }
+  return undefined
+}
+
+const NetworkContext = React.createContext(getNetworkName())
+
+export type NetworkProviderProps = React.PropsWithChildren<{
+  children: any
+  rippledUrl?: string
+}>
+
+export const NetworkProvider = ({
+  children,
+  rippledUrl,
+}: NetworkProviderProps) => {
+  const initialNetworkName = getNetworkName()
+  const [networkName, setNetworkName] = useState(initialNetworkName)
+
+  useEffect(() => {
+    if (initialNetworkName == null && rippledUrl) {
+      axios
+        .get(`${process.env.VITE_DATA_URL}/get_network/${rippledUrl}`)
+        .then((resp) => resp.data)
+        .then((data) =>
+          setNetworkName(
+            data.result && data.result === 'error' ? null : data.network,
+          ),
+        )
+        .catch((e) => Log.error(e))
+    }
+  }, [initialNetworkName, rippledUrl])
+
+  return (
+    <NetworkContext.Provider value={networkName}>
+      {children}
+    </NetworkContext.Provider>
+  )
+}
+
+export { getNetworkName }
+
+export default NetworkContext
diff --git a/src/containers/shared/SocketContext.tsx b/src/containers/shared/SocketContext.tsx
index 0abe53ed..8484fd1b 100644
--- a/src/containers/shared/SocketContext.tsx
+++ b/src/containers/shared/SocketContext.tsx
@@ -1,4 +1,4 @@
-import { useContext, createContext, useEffect, useState } from 'react'
+import React, { useContext, createContext, useEffect, useState } from 'react'
 import { XrplClient } from 'xrpl-client'
 
 const LOCALHOST_URLS = ['localhost', '127.0.0.1', '0.0.0.0']
@@ -54,6 +54,28 @@ function getSocket(rippledUrl?: string): ExplorerXrplClient {
 
 const SocketContext = createContext<XrplClient>(undefined!)
 
+export type SocketProviderProps = React.PropsWithChildren<{
+  children: any
+  rippledUrl?: string
+}>
+
+export const SocketProvider = ({
+  children,
+  rippledUrl,
+}: SocketProviderProps) => {
+  const socket = getSocket(rippledUrl)
+
+  useEffect(() => () => {
+    socket.close()
+    if (socket.p2pSocket !== undefined) {
+      socket.p2pSocket.close()
+    }
+  })
+  return (
+    <SocketContext.Provider value={socket}>{children}</SocketContext.Provider>
+  )
+}
+
 /**
  * Hook that says whether or not the global socket is currently connected
  */


Test patch:
diff --git a/src/containers/App/test/App.test.js b/src/containers/App/test/App.test.js
index 2fd00b3e..a54a56f5 100644
--- a/src/containers/App/test/App.test.js
+++ b/src/containers/App/test/App.test.js
@@ -1,8 +1,10 @@
 import { mount } from 'enzyme'
+import moxios from 'moxios'
 import { MemoryRouter } from 'react-router'
 import { I18nextProvider } from 'react-i18next'
 import configureMockStore from 'redux-mock-store'
 import { Provider } from 'react-redux'
+import { XrplClient } from 'xrpl-client'
 import { initialState } from '../../../rootReducer'
 import i18n from '../../../i18n/testConfig'
 import { AppWrapper } from '../index'
@@ -24,15 +26,9 @@ jest.mock('react-router-dom', () => {
   }
 })
 
-jest.mock('../../shared/SocketContext', () => {
-  const originalModule = jest.requireActual('../../shared/SocketContext')
-
-  return {
-    __esModule: true,
-    ...originalModule,
-    getSocket: () => new MockWsClient(),
-  }
-})
+jest.mock('xrpl-client', () => ({
+  XrplClient: jest.fn(),
+}))
 
 jest.mock('../../../rippled/lib/rippled', () => {
   const originalModule = jest.requireActual('../../../rippled/lib/rippled')
@@ -44,8 +40,13 @@ jest.mock('../../../rippled/lib/rippled', () => {
   }
 })
 
+const mockXrplClient = XrplClient
 const mockGetAccountInfo = getAccountInfo
 
+function flushPromises() {
+  return new Promise((resolve) => setImmediate(resolve))
+}
+
 describe('App container', () => {
   const mockStore = configureMockStore()
   const createWrapper = (path = '/') => {
@@ -64,11 +65,17 @@ describe('App container', () => {
   const oldEnvs = process.env
 
   beforeEach(() => {
+    moxios.install()
+    moxios.stubRequest(
+      `${process.env.VITE_DATA_URL}/get_network/s2.ripple.com`,
+      { status: 200, response: { result: 'success', network: '3' } },
+    )
     mockGetAccountInfo.mockImplementation(() =>
       Promise.resolve({
         flags: 0,
       }),
     )
+    mockXrplClient.mockImplementation(() => new MockWsClient())
     process.env = { ...oldEnvs, VITE_ENVIRONMENT: 'mainnet' }
   })
 
@@ -192,4 +199,15 @@ describe('App container', () => {
       wrapper.unmount()
     })
   })
+
+  it('renders custom mode', async () => {
+    process.env.VITE_ENVIRONMENT = 'custom'
+    delete process.env.VITE_P2P_RIPPLED_HOST //  For custom as there is no p2p.
+    const wrapper = createWrapper('/s2.ripple.com/')
+    await flushPromises()
+    wrapper.update()
+    // Make sure the sockets aren't double initialized.
+    expect(XrplClient).toHaveBeenCalledTimes(1)
+    wrapper.unmount()
+  })
 })