Repository: polkadot-js/extension (ts)

Issue:
Add ErrorBoundary (as applicable)
Wile https://github.com/polkadot-js/extension/issues/468 (based on description) seems like a background crash (???), the UI itself should never go "white page" or drop out. ErrorBoundary (as used in the apps UI) protects against that.

Need at least one for the authorization/signing where things may go wrong due to bad parsing.

Pull request:
Add error boundaries with fallback component to show any error
closes #469 

Patch:
diff --git a/.gitignore b/.gitignore
index ed7cfb49..19df2314 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,4 +22,4 @@ yarn-error.log*
 package-lock.json
 _book
 docs/html
-.idea
\ No newline at end of file
+.idea
diff --git a/package.json b/package.json
index fef84605..70606d72 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
   "repository": "https://github.com/polkadot-js/extension",
   "author": "Jaco Greeff <jacogr@gmail.com>",
   "license": "Apache-2",
-  "version": "0.35.0-beta.8",
+  "version": "0.35.0-beta.10",
   "private": true,
   "workspaces": [
     "packages/*"
diff --git a/packages/extension-base/package.json b/packages/extension-base/package.json
index 6f219efb..194e70e7 100644
--- a/packages/extension-base/package.json
+++ b/packages/extension-base/package.json
@@ -1,13 +1,13 @@
 {
   "name": "@polkadot/extension-base",
   "description": "Functions, classes and other utilities used in @polkadot/extension",
-  "version": "0.35.0-beta.8",
+  "version": "0.35.0-beta.10",
   "author": "Jaco Greeff <jacogr@gmail.com>",
   "license": "Apache-2",
   "dependencies": {
     "@babel/runtime": "^7.11.2",
     "@polkadot/api": "^2.1.0-beta.5",
-    "@polkadot/extension-inject": "0.35.0-beta.8",
+    "@polkadot/extension-inject": "0.35.0-beta.10",
     "@polkadot/keyring": "^3.5.1",
     "@polkadot/ui-keyring": "^0.60.0-beta.0"
   }
diff --git a/packages/extension-chains/package.json b/packages/extension-chains/package.json
index 268cd76d..2c392b69 100644
--- a/packages/extension-chains/package.json
+++ b/packages/extension-chains/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@polkadot/extension-chains",
   "description": "Definitions for all known chains as exposed by the extension.",
-  "version": "0.35.0-beta.8",
+  "version": "0.35.0-beta.10",
   "author": "Jaco Greeff <jacogr@gmail.com>",
   "license": "Apache-2",
   "dependencies": {
diff --git a/packages/extension-dapp/package.json b/packages/extension-dapp/package.json
index 87d01246..f111e5b2 100644
--- a/packages/extension-dapp/package.json
+++ b/packages/extension-dapp/package.json
@@ -1,12 +1,12 @@
 {
   "name": "@polkadot/extension-dapp",
   "description": "Provides an interfaces around the injected globals for ease of access by dapp developers.",
-  "version": "0.35.0-beta.8",
+  "version": "0.35.0-beta.10",
   "author": "Jaco Greeff <jacogr@gmail.com>",
   "license": "Apache-2",
   "dependencies": {
     "@babel/runtime": "^7.11.2",
-    "@polkadot/extension-inject": "0.35.0-beta.8",
+    "@polkadot/extension-inject": "0.35.0-beta.10",
     "@polkadot/util": "^3.5.1",
     "@polkadot/util-crypto": "^3.5.1"
   },
diff --git a/packages/extension-inject/package.json b/packages/extension-inject/package.json
index d793fd67..e40d84a1 100644
--- a/packages/extension-inject/package.json
+++ b/packages/extension-inject/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@polkadot/extension-inject",
   "description": "A generic injector (usable to any extension), that populates the base exposed interfaces to be used by dapps.",
-  "version": "0.35.0-beta.8",
+  "version": "0.35.0-beta.10",
   "author": "Jaco Greeff <jacogr@gmail.com>",
   "license": "Apache-2",
   "dependencies": {
diff --git a/packages/extension-ui/package.json b/packages/extension-ui/package.json
index 319b6c10..ddafcf73 100644
--- a/packages/extension-ui/package.json
+++ b/packages/extension-ui/package.json
@@ -1,13 +1,13 @@
 {
   "name": "@polkadot/extension-ui",
   "description": "A sample signer extension for the @polkadot/api",
-  "version": "0.35.0-beta.8",
+  "version": "0.35.0-beta.10",
   "author": "Jaco Greeff <jacogr@gmail.com>",
   "license": "Apache-2",
   "dependencies": {
     "@babel/runtime": "^7.11.2",
-    "@polkadot/extension-base": "0.35.0-beta.8",
-    "@polkadot/extension-inject": "0.35.0-beta.8",
+    "@polkadot/extension-base": "0.35.0-beta.10",
+    "@polkadot/extension-inject": "0.35.0-beta.10",
     "@polkadot/keyring": "^3.5.1",
     "@polkadot/react-identicon": "^0.60.0-beta.0",
     "@polkadot/react-qr": "^0.60.0-beta.0",
diff --git a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx
index 9074915b..2a6b14b0 100644
--- a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx
+++ b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx
@@ -24,12 +24,11 @@ export function SelectParent ({ isLocked, onDerivationConfirmed, parentAddress,
   const onAction = useContext(ActionContext);
   const [isBusy, setIsBusy] = useState(false);
   const { accounts, hierarchy } = useContext(AccountContext);
-  const [defaultPath] = useState(nextDerivationPath(accounts, parentAddress));
+  const defaultPath = useMemo(() => nextDerivationPath(accounts, parentAddress), [accounts, parentAddress]);
   const [suriPath, setSuriPath] = useState<null | string>(defaultPath);
   const [parentPassword, setParentPassword] = useState<string>('');
   const [isProperParentPassword, setIsProperParentPassword] = useState(false);
   const [shouldAccountBeDerived, setShouldAccountBeDerived] = useState(true);
-
   const passwordInputRef = useRef<HTMLDivElement>(null);
 
   const allAddresses = useMemo(
diff --git a/packages/extension-ui/src/Popup/index.tsx b/packages/extension-ui/src/Popup/index.tsx
index 3b66230c..ece85658 100644
--- a/packages/extension-ui/src/Popup/index.tsx
+++ b/packages/extension-ui/src/Popup/index.tsx
@@ -9,7 +9,7 @@ import { Route, Switch } from 'react-router';
 import uiSettings from '@polkadot/ui-settings';
 import { setSS58Format } from '@polkadot/util-crypto';
 
-import { Loading } from '../components';
+import { Loading, ErrorBoundary } from '../components';
 import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext } from '../components/contexts';
 import ToastProvider from '../components/Toast/ToastProvider';
 import { subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging';
@@ -103,15 +103,19 @@ export default function Popup (): React.ReactElement {
       .catch(console.error);
   }, [cameraOn]);
 
+  function wrapWithErrorBoundary (component: React.ReactElement, trigger?: string): React.ReactElement {
+    return <ErrorBoundary trigger={trigger}>{component}</ErrorBoundary>;
+  }
+
   const Root = isWelcomeDone
     ? authRequests && authRequests.length
-      ? Authorize
+      ? wrapWithErrorBoundary(<Authorize />, 'authorize')
       : metaRequests && metaRequests.length
-        ? Metadata
+        ? wrapWithErrorBoundary(<Metadata />, 'metadata')
         : signRequests && signRequests.length
-          ? Signing
-          : Accounts
-    : Welcome;
+          ? wrapWithErrorBoundary(<Signing/>, 'signing')
+          : wrapWithErrorBoundary(<Accounts/>, 'accounts')
+    : wrapWithErrorBoundary(<Welcome />, 'welcome');
 
   return (
     <Loading>{accounts && authRequests && metaRequests && signRequests && (
@@ -124,19 +128,19 @@ export default function Popup (): React.ReactElement {
                   <SigningReqContext.Provider value={signRequests}>
                     <ToastProvider>
                       <Switch>
-                        <Route path='/account/create'><CreateAccount /></Route>
-                        <Route path='/account/forget/:address'><Forget /></Route>
-                        <Route path='/account/export/:address'><Export /></Route>
-                        <Route path='/account/import-qr'><ImportQr /></Route>
-                        <Route path='/account/import-seed'><ImportSeed /></Route>
-                        <Route path='/account/restore-json'><RestoreJson /></Route>
-                        <Route path='/account/derive/:address/locked'><Derive isLocked /></Route>
-                        <Route path='/account/derive/:address'><Derive /></Route>
+                        <Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
+                        <Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
+                        <Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
+                        <Route path='/account/import-qr'>{wrapWithErrorBoundary(<ImportQr />, 'import-qr')}</Route>
+                        <Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
+                        <Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
+                        <Route path='/account/derive/:address/locked'>{wrapWithErrorBoundary(<Derive isLocked />, 'derived-address-locked')}</Route>
+                        <Route path='/account/derive/:address'>{wrapWithErrorBoundary(<Derive />, 'derive-address')}</Route>
                         <Route
                           exact
                           path='/'
                         >
-                          <Root />
+                          {Root}
                         </Route>
                       </Switch>
                     </ToastProvider>
diff --git a/packages/extension-ui/src/components/ErrorBoundary.tsx b/packages/extension-ui/src/components/ErrorBoundary.tsx
new file mode 100644
index 00000000..2d93d7a7
--- /dev/null
+++ b/packages/extension-ui/src/components/ErrorBoundary.tsx
@@ -0,0 +1,74 @@
+// Copyright 2019-2020 @polkadot/extension-ui authors & contributor
+// SPDX-License-Identifier: Apache-2.0
+
+import { WithTranslation } from 'react-i18next';
+
+import React from 'react';
+
+import ButtonArea from './ButtonArea';
+import Button from './Button';
+import VerticalSpace from './VerticalSpace';
+import Header from '../partials/Header';
+import translate from './translate';
+
+interface Props extends WithTranslation {
+  children: React.ReactNode;
+  className?: string;
+  error?: Error | null;
+  trigger?: string;
+}
+
+interface State {
+  error: Error | null;
+}
+
+// NOTE: This is the only way to do an error boundary, via extend
+class ErrorBoundary extends React.Component<Props> {
+  state: State = { error: null };
+
+  static getDerivedStateFromError (error: Error): Partial<State> {
+    return { error };
+  }
+
+  componentDidUpdate (prevProps: Props) {
+    const { error } = this.state;
+    const { trigger } = this.props;
+
+    if (error !== null && (prevProps.trigger !== trigger)) {
+      this.setState({ error: null });
+    }
+  }
+
+  goHome = () => {
+    this.setState({ error: null });
+    window.location.hash = '/';
+  };
+
+  public render (): React.ReactNode {
+    const { children, t } = this.props;
+    const { error } = this.state;
+
+    return error
+      ? (
+        <>
+          <Header text={t<string>('An error occured')} />
+          <div>
+            {t<string>('Something went wrong with the query and rendering of this component. {{message}}', {
+              replace: { message: error.message }
+            })}
+          </div>
+          <VerticalSpace/>
+          <ButtonArea>
+            <Button
+              onClick={this.goHome}
+            >
+              {t<string>('Back to home')}
+            </Button>
+          </ButtonArea>
+        </>
+      )
+      : children;
+  }
+}
+
+export default translate(ErrorBoundary);
diff --git a/packages/extension-ui/src/components/index.ts b/packages/extension-ui/src/components/index.ts
index ef862f67..c95db831 100644
--- a/packages/extension-ui/src/components/index.ts
+++ b/packages/extension-ui/src/components/index.ts
@@ -11,6 +11,7 @@ export { default as ButtonArea } from './ButtonArea';
 export { default as ButtonWithSubtitle } from './ButtonWithSubtitle';
 export { default as Checkbox } from './Checkbox';
 export { default as Dropdown } from './Dropdown';
+export { default as ErrorBoundary } from './ErrorBoundary';
 export { default as Fonts } from './Fonts';
 export { default as Icon } from './Icon';
 export { default as Identicon } from './Identicon';
diff --git a/packages/extension-ui/src/components/translate.ts b/packages/extension-ui/src/components/translate.ts
new file mode 100644
index 00000000..36438c12
--- /dev/null
+++ b/packages/extension-ui/src/components/translate.ts
@@ -0,0 +1,10 @@
+// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { useTranslation as useTranslationBase, UseTranslationResponse, withTranslation } from 'react-i18next';
+
+export function useTranslation (): UseTranslationResponse {
+  return useTranslationBase();
+}
+
+export default withTranslation();
diff --git a/packages/extension-ui/src/partials/Header.tsx b/packages/extension-ui/src/partials/Header.tsx
index eb9013a3..f52f4a43 100644
--- a/packages/extension-ui/src/partials/Header.tsx
+++ b/packages/extension-ui/src/partials/Header.tsx
@@ -10,7 +10,8 @@ import ArrowLeftImage from '../assets/arrowLeft.svg';
 import gear from '../assets/gear.svg';
 import plus from '../assets/plus.svg';
 import logo from '../assets/pjs.svg';
-import { Link, Svg } from '../components';
+import Link from '../components/Link';
+import Svg from '../components/Svg';
 import useOutsideClick from '../hooks/useOutsideClick';
 import MenuAdd from './MenuAdd';
 import MenuSettings from './MenuSettings';
diff --git a/packages/extension-ui/src/util/buildHierarchy.ts b/packages/extension-ui/src/util/buildHierarchy.ts
index 234ed6bb..05fc26d7 100644
--- a/packages/extension-ui/src/util/buildHierarchy.ts
+++ b/packages/extension-ui/src/util/buildHierarchy.ts
@@ -3,18 +3,58 @@
 
 import { AccountJson, AccountWithChildren } from '@polkadot/extension-base/background/types';
 
+import getNetworkMap from './getNetworkMap';
+
 type ChildFilter = (account: AccountJson) => AccountWithChildren;
 
-function compareByCreationTime (a: AccountJson, b: AccountJson): number {
+function compareByCreation (a: AccountJson, b: AccountJson): number {
   return (a.whenCreated || Infinity) - (b.whenCreated || Infinity);
 }
 
+function compareByName (a: AccountJson, b: AccountJson): number {
+  const nameA = a.name?.toUpperCase() || '';
+  const nameB = b.name?.toUpperCase() || '';
+
+  return nameA.localeCompare(nameB);
+}
+
+function compareByPath (a: AccountJson, b: AccountJson): number {
+  const suriA = a.suri?.toUpperCase() || '';
+  const suriB = b.suri?.toUpperCase() || '';
+
+  return suriA.localeCompare(suriB);
+}
+
+function compareByNetwork (a: AccountJson, b: AccountJson): number {
+  const networkMap = getNetworkMap();
+  const networkA = networkMap.get(a?.genesisHash || '') || '';
+  const networkB = networkMap.get(b?.genesisHash || '') || '';
+
+  return networkA.localeCompare(networkB);
+}
+
+function compareByPathThenCreation (a: AccountJson, b: AccountJson): number {
+  // if the paths are equal, compare by creation time
+  return compareByPath(a, b) || compareByCreation(a, b);
+}
+
+function compareByNameThenPathThenCreation (a: AccountJson, b: AccountJson): number {
+  // This comparison happens after an initial sorting by network.
+  // if the 2 accounts are from different networks, don't touch their order
+  if (a.genesisHash !== b.genesisHash) {
+    return 0;
+  }
+
+  // if the names are equal, compare by path then creation time
+  return compareByName(a, b) || compareByPathThenCreation(a, b);
+}
+
 export function accountWithChildren (accounts: AccountJson[]): ChildFilter {
   return (account: AccountJson): AccountWithChildren => {
     const children = accounts
       .filter(({ parentAddress }) => account.address === parentAddress)
       .map(accountWithChildren(accounts))
-      .sort(compareByCreationTime);
+      .sort(compareByNameThenPathThenCreation);
 
     return children.length === 0
       ? account
@@ -31,5 +71,6 @@ export function buildHierarchy (accounts: AccountJson[]): AccountWithChildren[]
       !accounts.some(({ address }) => parentAddress === address)
     )
     .map(accountWithChildren(accounts))
-    .sort(compareByCreationTime);
+    .sort(compareByNetwork)
+    .sort(compareByNameThenPathThenCreation);
 }
diff --git a/packages/extension-ui/src/util/getNetworkMap.ts b/packages/extension-ui/src/util/getNetworkMap.ts
new file mode 100644
index 00000000..9729fc33
--- /dev/null
+++ b/packages/extension-ui/src/util/getNetworkMap.ts
@@ -0,0 +1,14 @@
+// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import chains from '@polkadot/extension-chains/chains';
+
+export default function getNetworkMap (): Map<string, string> {
+  const res = new Map<string, string>();
+
+  chains.forEach((chain) => {
+    res.set(chain.genesisHash, chain.chain);
+  });
+
+  return res;
+}
diff --git a/packages/extension/package.json b/packages/extension/package.json
index 897b571f..279f45fb 100644
--- a/packages/extension/package.json
+++ b/packages/extension/package.json
@@ -1,14 +1,14 @@
 {
   "name": "@polkadot/extension",
   "description": "A sample signer extension for the @polkadot/api",
-  "version": "0.35.0-beta.8",
+  "version": "0.35.0-beta.10",
   "author": "Jaco Greeff <jacogr@gmail.com>",
   "license": "Apache-2",
   "dependencies": {
     "@babel/runtime": "^7.11.2",
-    "@polkadot/extension-base": "0.35.0-beta.8",
-    "@polkadot/extension-inject": "0.35.0-beta.8",
-    "@polkadot/extension-ui": "0.35.0-beta.8"
+    "@polkadot/extension-base": "0.35.0-beta.10",
+    "@polkadot/extension-inject": "0.35.0-beta.10",
+    "@polkadot/extension-ui": "0.35.0-beta.10"
   },
   "devDependencies": {
     "@polkadot/dev": "^0.57.1",
diff --git a/yarn.lock b/yarn.lock
index 48acd305..21320bc5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1983,13 +1983,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@polkadot/extension-base@0.35.0-beta.8, @polkadot/extension-base@workspace:packages/extension-base":
+"@polkadot/extension-base@0.35.0-beta.10, @polkadot/extension-base@workspace:packages/extension-base":
   version: 0.0.0-use.local
   resolution: "@polkadot/extension-base@workspace:packages/extension-base"
   dependencies:
     "@babel/runtime": ^7.11.2
     "@polkadot/api": ^2.1.0-beta.5
-    "@polkadot/extension-inject": 0.35.0-beta.8
+    "@polkadot/extension-inject": 0.35.0-beta.10
     "@polkadot/keyring": ^3.5.1
     "@polkadot/ui-keyring": ^0.60.0-beta.0
   languageName: unknown
@@ -2010,7 +2010,7 @@ __metadata:
   resolution: "@polkadot/extension-dapp@workspace:packages/extension-dapp"
   dependencies:
     "@babel/runtime": ^7.11.2
-    "@polkadot/extension-inject": 0.35.0-beta.8
+    "@polkadot/extension-inject": 0.35.0-beta.10
     "@polkadot/util": ^3.5.1
     "@polkadot/util-crypto": ^3.5.1
   peerDependencies:
@@ -2018,7 +2018,7 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"@polkadot/extension-inject@0.35.0-beta.8, @polkadot/extension-inject@workspace:packages/extension-inject":
+"@polkadot/extension-inject@0.35.0-beta.10, @polkadot/extension-inject@workspace:packages/extension-inject":
   version: 0.0.0-use.local
   resolution: "@polkadot/extension-inject@workspace:packages/extension-inject"
   dependencies:
@@ -2030,13 +2030,13 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"@polkadot/extension-ui@0.35.0-beta.8, @polkadot/extension-ui@workspace:packages/extension-ui":
+"@polkadot/extension-ui@0.35.0-beta.10, @polkadot/extension-ui@workspace:packages/extension-ui":
   version: 0.0.0-use.local
   resolution: "@polkadot/extension-ui@workspace:packages/extension-ui"
   dependencies:
     "@babel/runtime": ^7.11.2
-    "@polkadot/extension-base": 0.35.0-beta.8
-    "@polkadot/extension-inject": 0.35.0-beta.8
+    "@polkadot/extension-base": 0.35.0-beta.10
+    "@polkadot/extension-inject": 0.35.0-beta.10
     "@polkadot/keyring": ^3.5.1
     "@polkadot/react-identicon": ^0.60.0-beta.0
     "@polkadot/react-qr": ^0.60.0-beta.0
@@ -2072,9 +2072,9 @@ __metadata:
   dependencies:
     "@babel/runtime": ^7.11.2
     "@polkadot/dev": ^0.57.1
-    "@polkadot/extension-base": 0.35.0-beta.8
-    "@polkadot/extension-inject": 0.35.0-beta.8
-    "@polkadot/extension-ui": 0.35.0-beta.8
+    "@polkadot/extension-base": 0.35.0-beta.10
+    "@polkadot/extension-inject": 0.35.0-beta.10
+    "@polkadot/extension-ui": 0.35.0-beta.10
     babel-loader: ^8.1.0
     extract-loader: ^5.1.0
     file-loader: ^6.1.0


Test patch:
diff --git a/packages/extension-ui/src/Popup/Derive/Derive.test.tsx b/packages/extension-ui/src/Popup/Derive/Derive.test.tsx
index 6eb240ef..2bcc50c6 100644
--- a/packages/extension-ui/src/Popup/Derive/Derive.test.tsx
+++ b/packages/extension-ui/src/Popup/Derive/Derive.test.tsx
@@ -57,6 +57,9 @@ describe('Derive', () => {
     return { onActionStub, wrapper };
   };
 
+  let wrapper: ReactWrapper;
+  let onActionStub: jest.Mock;
+
   const uncheck = (input: ReactWrapper): unknown => input.simulate('change', { target: { checked: false } });
 
   const type = async (input: ReactWrapper, value: string): Promise<void> => {
@@ -66,52 +69,48 @@ describe('Derive', () => {
   };
 
   describe('Parent selection screen', () => {
+    beforeEach(async () => {
+      const mountedComponent = await mountComponent();
+
+      wrapper = mountedComponent.wrapper;
+      onActionStub = mountedComponent.onActionStub;
+    });
+
     // eslint-disable-next-line @typescript-eslint/require-await
     jest.spyOn(messaging, 'validateAccount').mockImplementation(async (_, pass: string) => pass === 'pass');
 
-    it('"Derive" checkbox is selected by default', async () => {
-      const { wrapper } = await mountComponent();
+    it('"Derive" checkbox is selected by default', () => {
       const checkbox = wrapper.find('input[type="checkbox"]');
 
       expect(checkbox.prop('checked')).toBe(true);
     });
 
-    it('"Create new root" button becomes visible after checkbox is unchecked', async () => {
-      const { wrapper } = await mountComponent();
-
+    it('"Create new root" button becomes visible after checkbox is unchecked', () => {
       uncheck(wrapper.find('input[type="checkbox"]'));
 
       expect(wrapper.exists('[data-button-action="create root account"]')).toBe(true);
       expect(wrapper.exists('[data-button-action="create derived account"]')).toBe(false);
     });
 
-    it('"Create new root" button redirects to /account/create', async () => {
-      const { onActionStub, wrapper } = await mountComponent();
-
+    it('"Create new root" button redirects to /account/create', () => {
       uncheck(wrapper.find('input[type="checkbox"]'));
       wrapper.find('[data-button-action="create root account"] button').simulate('click');
 
       expect(onActionStub).toBeCalledWith('/account/create');
     });
 
-    it('"Create derived account" is visible when checkbox is checked', async () => {
-      const { wrapper } = await mountComponent();
-
+    it('"Create derived account" is visible when checkbox is checked', () => {
       expect(wrapper.exists('[data-button-action="create derived account"]')).toBe(true);
       expect(wrapper.exists('[data-button-action="create root account"]')).toBe(false);
     });
 
-    it('"Create derived account" is disabled when password is incorrect', async () => {
-      const { wrapper } = await mountComponent();
-
+    it('"Create derived account" is disabled when password is incorrect', () => {
       const button = wrapper.find('[data-button-action="create derived account"] button');
 
       expect(button.prop('disabled')).toBe(true);
     });
 
     it('"Create derived account" is enabled when password is correct', async () => {
-      const { wrapper } = await mountComponent();
-
       await type(wrapper.find('input[type="password"]'), 'pass');
 
       const button = wrapper.find('[data-button-action="create derived account"] button');
@@ -120,8 +119,6 @@ describe('Derive', () => {
     });
 
     it('"Create derived account" is disabled when suri is incorrect', async () => {
-      const { wrapper } = await mountComponent();
-
       await type(wrapper.find('input[type="password"]'), 'pass');
       await type(wrapper.find('[data-input-suri] input'), '//');
 
@@ -130,23 +127,17 @@ describe('Derive', () => {
       expect(button.prop('disabled')).not.toBe(true);
     });
 
-    it('takes selected address from URL as parent account', async () => {
-      const { wrapper } = await mountComponent();
-
+    it('takes selected address from URL as parent account', () => {
       expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
     });
 
-    it('selects internal root accounts as other options', async () => {
-      const { wrapper } = await mountComponent();
-
+    it('selects internal root accounts as other options', () => {
       const options = wrapper.find('[data-parent-option] [data-field="name"]').map((el) => el.text());
 
       expect(options).toEqual(['A', 'B', 'D']);
     });
 
-    it('redirects to derive from next account when other option is selected', async () => {
-      const { onActionStub, wrapper } = await mountComponent();
-
+    it('redirects to derive from next account when other option is selected', () => {
       wrapper.find('[data-parent-option]').first().simulate('click');
 
       expect(onActionStub).toBeCalledWith(`/account/derive/${accounts[0].address}`);
@@ -154,21 +145,19 @@ describe('Derive', () => {
   });
 
   describe('Locked parent selection', () => {
-    it('checkbox does not exist', async () => {
-      const { wrapper } = await mountComponent(true);
+    beforeAll(async () => {
+      wrapper = (await mountComponent(true)).wrapper;
+    });
 
+    it('checkbox does not exist', () => {
       expect(wrapper.exists('[type="checkbox"]')).toBe(false);
     });
 
-    it('address dropdown does not exist', async () => {
-      const { wrapper } = await mountComponent(true);
-
+    it('address dropdown does not exist', () => {
       expect(wrapper.exists(AddressDropdown)).toBe(false);
     });
 
-    it('parent is taken from URL', async () => {
-      const { wrapper } = await mountComponent(true);
-
+    it('parent is taken from URL', () => {
       expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
     });
   });
diff --git a/packages/extension-ui/src/util/buildHierarchy.test.ts b/packages/extension-ui/src/util/buildHierarchy.test.ts
index 094b868e..c804373b 100644
--- a/packages/extension-ui/src/util/buildHierarchy.test.ts
+++ b/packages/extension-ui/src/util/buildHierarchy.test.ts
@@ -4,18 +4,27 @@
 import { AccountJson, AccountWithChildren } from '@polkadot/extension-base/background/types';
 import { buildHierarchy } from './buildHierarchy';
 
+const genesisExample = {
+  KUSAMA: '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe',
+  POLKADOT: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3'
+};
+
 const testHierarchy = (accounts: AccountJson[], expected: AccountWithChildren[]): void => {
   expect(buildHierarchy(accounts)).toEqual(expected);
 };
 
 describe('Use Account Hierarchy', () => {
-  const acc = (address: string, parentAddress?: string, whenCreated?: number): {
+  const acc = (address: string, parentAddress?: string, whenCreated?: number, name?: string, suri?: string): {
     address: string;
-    whenCreated?: number;
+    name?: string;
     parentAddress?: string;
+    suri?: string;
+    whenCreated?: number;
   } => ({
     address,
+    name,
     parentAddress,
+    suri,
     whenCreated
   });
 
@@ -56,10 +65,38 @@ describe('Use Account Hierarchy', () => {
     }]);
   });
 
-  test('sorts accounts by creation time', () => {
+  test('sorts accounts by network', () => {
+    testHierarchy(
+      [{ address: 'b', genesisHash: genesisExample.KUSAMA }, { address: 'a', genesisHash: genesisExample.POLKADOT }, { address: 'c', genesisHash: genesisExample.KUSAMA }],
+      [{ address: 'b', genesisHash: genesisExample.KUSAMA }, { address: 'c', genesisHash: genesisExample.KUSAMA }, { address: 'a', genesisHash: genesisExample.POLKADOT }]
+    );
+  });
+
+  test('sorts accounts by network and name', () => {
+    testHierarchy(
+      [{ address: 'b', genesisHash: genesisExample.KUSAMA, name: 'b-last-kusama' }, { address: 'a', genesisHash: genesisExample.POLKADOT }, { address: 'c', genesisHash: genesisExample.KUSAMA, name: 'a-first-kusama' }],
+      [{ address: 'c', genesisHash: genesisExample.KUSAMA, name: 'a-first-kusama' }, { address: 'b', genesisHash: genesisExample.KUSAMA, name: 'b-last-kusama' }, { address: 'a', genesisHash: genesisExample.POLKADOT }]
+    );
+  });
+
+  test('sorts accounts by name and creation date', () => {
+    testHierarchy(
+      [acc('b', undefined, 2, 'b'), acc('z', undefined, 1, 'b'), acc('a', undefined, 4, 'a')],
+      [{ address: 'a', name: 'a', whenCreated: 4 }, { address: 'z', name: 'b', whenCreated: 1 }, { address: 'b', name: 'b', whenCreated: 2 }]
+    );
+  });
+
+  test('sorts account children by name and path', () => {
+    testHierarchy(
+      [acc('a', undefined, 1, 'a'), acc('b', 'a', 1, 'b', '/2'), acc('b', 'a', 1, 'b', '/0')],
+      [{ address: 'a', children: [acc('b', 'a', 1, 'b', '/0'), acc('b', 'a', 1, 'b', '/2')], name: 'a', whenCreated: 1 }]
+    );
+  });
+
+  test('sorts accounts with children by name and creation date', () => {
     testHierarchy(
-      [acc('a', undefined, 2), acc('b', 'a', 4), acc('c', 'a', 3), acc('d', undefined, 1)],
-      [acc('d', undefined, 1), { address: 'a', children: [acc('c', 'a', 3), acc('b', 'a', 4)], whenCreated: 2 }]
+      [acc('b', undefined, 2, 'b'), acc('z', undefined, 1, 'b'), acc('d', 'b', 2, 'd'), acc('c', 'b', 3, 'c'), acc('a', undefined, 4, 'a')],
+      [{ address: 'a', name: 'a', whenCreated: 4 }, { address: 'z', name: 'b', whenCreated: 1 }, { address: 'b', children: [acc('c', 'b', 3, 'c'), acc('d', 'b', 2, 'd')], name: 'b', whenCreated: 2 }]
     );
   });